Skip to content

Images ​

Types: ImageRequest, RedrawWithSizeCommand

By default Mana downloads cover and page URLs itself, without calling your source. Add a transport when images need headers, cookies, a rate limit or decryption.

Image loading
Mana fetches URLs itself unless your source provides a transport

Transports ​

ts
import { HttpClient, RequestOrigin, type ImageRequest } from "@mana-app/types";

private readonly http = new HttpClient()
  .setBaseURL("https://example.org")
  .setRateLimit(6, 1_000)
  .addRequestInterceptor((request, context) => {
    if (context.origin !== RequestOrigin.General) {
      request.headers.set("Referer", "https://example.org/");
    }
    return request;
  });

// Covers in search results, home rows and the library.
getCoverTransport(request: ImageRequest): HttpClient {
  return this.http;
}

// Reader pages and chapter downloads.
getPageTransport(request: ImageRequest): HttpClient {
  return this.http;
}
  • Implement only the ones you need: a source whose covers load fine should skip getCoverTransport so covers never start your source.
  • Return a long-lived client. A new client per call starts a fresh rate limiter each time.
  • request.url and request.origin (RequestOrigin.Cover or RequestOrigin.Page) are available if you want separate clients.
ts
private readonly cdn = new HttpClient().setRateLimit(20, 1_000);

getPageTransport(request: ImageRequest): HttpClient {
  return request.url.includes("cdn.example.org") ? this.cdn : this.http;
}

Images are cached by URL. If one URL can produce different images, put the distinguishing value in the URL (a query or fragment).

Decrypting or changing images ​

Use a response interceptor. Check context.origin so API responses are left alone.

ts
import { Cipher, RequestOrigin } from "@mana-app/types";

this.http.addResponseInterceptor(async (response, context) => {
  if (context.origin !== RequestOrigin.Page) return response;

  // Key travels with the URL fragment from getChapterData (never sent to the server).
  const key = Buffer.from(response.request.fragmentParams.key, "hex");
  return response.decrypt(Cipher.xor({ key }));     // image type is detected
});
ts
async getChapterData(contentId: string, chapterId: string): Promise<ChapterData> {
  const json = await (await this.http.get(`/api/chapter/${chapterId}`)).requireOk().json<any>();
  return { pages: json.pages.map((p: any) => ({ url: `${p.url}#key=${p.key}` })) };
}

To edit bytes yourself (slower, the image passes through JavaScript):

ts
this.http.addResponseInterceptor(async (response, context) => {
  if (context.origin !== RequestOrigin.Page) return response;
  const bytes = await response.bytes();
  return response.withBody(stripHeader(bytes), { contentType: "image/webp" });
});

See Crypto for all ciphers.

Where the bytes go ​

Image bytes normally stay in Mana's native memory: your source only handles a reference to the body. They are copied into the JavaScript runtime only when your code reads them (bytes(), arrayBuffer(), base64(), text()) or returns them (withBody(), raw).

Where image bytes live
Native memory by default · JavaScript only when you read or return bytes yourself
CaseSource runs?Bytes enter JavaScript?Cost
No transportNoNoCheapest
Transport, headers onlyYesNoOne JavaScript call per image
Native decryptionYesNoDecryption runs natively
Reading bytesYesYes, copied in and outTwo copies plus your JavaScript work
DocumentYes, once per chapterNoOne download, opened natively
Inline raw pagesYesYes, as base64 textLargest: text is about a third bigger than the image

No transport ​

The source has no getCoverTransport / getPageTransport, so Mana loads the URL itself and your JavaScript never starts for images.

Case 1 · No transport
Mana downloads the URL itself · your source is never called

Transport with headers ​

A request interceptor adds headers; the image comes back as a reference and is handed straight to Mana. Chapter downloads take the same path.

Case 2 · Transport, bytes stay native
Interceptors change the request · the image itself never enters JavaScript

Native decryption ​

response.decrypt(cipher) asks Mana to decrypt the body where it is held. JavaScript only sends the cipher spec.

Case 3 · Native decryption
The key goes to Mana · the encrypted bytes never enter JavaScript

Reading and replacing bytes ​

Calling response.bytes() copies the image into JavaScript (a Uint8Array; base64 text under WebKit). Returning withBody(newBytes) copies the result back once. Use this only when no native cipher fits.

Case 4 · Bytes loaded into the runtime
Two copies across the bridge plus your JavaScript work, for every image

Documents ​

A CBZ, EPUB, PDF or text file returned as ChapterData.document is handed to Mana as a native body when getChapterData finishes.

Case 5 · Document handed to the app
One download per chapter · Mana opens the file natively

Inline raw pages ​

Pages returned as raw base64 strings pass through JavaScript as text and are decoded by Mana. Prefer url with a transport where you can.

Case 6 · Inline raw pages
The whole image crosses into JavaScript and back as base64 text

Descrambling tiles ​

For sites that cut pages into shuffled tiles. Both methods are required.

Tile descrambling
You return rects; Mana copies them into a new canvas
ts
import type { BooleanState, CGSize, RedrawWithSizeCommand } from "@mana-app/types";

async shouldRedrawImage(url: string): Promise<BooleanState> {
  return { state: url.includes("/scrambled/") };
}

async redrawImageWithSize(size: CGSize): Promise<RedrawWithSizeCommand> {
  const columns = 4;
  const rows = 4;
  const w = Math.floor(size.width / columns);
  const h = Math.floor(size.height / rows);
  const order = [5, 2, 9, 0, 14, 7, 1, 12, 3, 10, 6, 15, 8, 13, 4, 11]; // site's tile order

  return {
    size,
    commands: order.map((from, to) => ({
      source: { origin: { x: (from % columns) * w, y: Math.floor(from / columns) * h }, size: { width: w, height: h } },
      destination: { origin: { x: (to % columns) * w, y: Math.floor(to / columns) * h }, size: { width: w, height: h } },
    })),
  };
}

redrawImageWithSize only receives the image size, so the tile order must not depend on the URL. If it does, decrypt or rearrange in a response interceptor instead.