Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ For each CBZ, the tool:
3. Detects double-page artwork using its width-to-height ratio.
4. Splits each detected spread into its original left and right halves and places them in one native facing-page KFX section.
5. Resizes artwork without changing its aspect ratio, up to 1986×2648 pixels per physical page.
6. Uses the artwork dimensions as the KFX logical page dimensions. This prevents Kindle's four Virtual Panels from magnifying black aspect-ratio margins.
7. Adds a black fixed-layout page background, comic flags, horizontal Virtual Panels, reading direction, cover, and native spread metadata.
8. Builds a Kindle Publishing Format (`.kpf`) package in Node.js.
9. Uses Calibre's KFX Output plugin to compile the KPF into a sideloadable `.kfx` file.
10. Writes supported ComicInfo fields into the final KFX metadata with Calibre's `ebook-meta`.
6. Encodes artwork as 4:4:4 JPEGs using MozJPEG by default, with JPEGli and the former fast encoder available as alternatives.
7. Uses the artwork dimensions as the KFX logical page dimensions. This prevents Kindle's four Virtual Panels from magnifying black aspect-ratio margins.
8. Adds a black fixed-layout page background, comic flags, horizontal Virtual Panels, reading direction, cover, and native spread metadata.
9. Builds a Kindle Publishing Format (`.kpf`) package in Node.js.
10. Uses Calibre's KFX Output plugin to compile the KPF into a sideloadable `.kfx` file.
11. Writes supported ComicInfo fields into the final KFX metadata with Calibre's `ebook-meta`.

The original CBZ is never modified.

Expand All @@ -33,6 +34,8 @@ The original CBZ is never modified.

Windows is the primary tested platform. The tool also checks the standard macOS Calibre location and can use `calibre-debug` from `PATH` on other systems. A working KFX Output/Kindle Previewer setup is still required.

[Google JPEGli](https://github.com/google/jpegli)'s `cjpegli` executable is an optional requirement only when using `--jpeg-encoder jpegli`. Build JPEGli according to its project instructions, then put `cjpegli` on `PATH`, set `CJPEGLI_PATH`, or pass its location with `--cjpegli`. JPEGli is not bundled with this npm package.

### Installing KFX Output

1. Install and start Calibre once.
Expand Down Expand Up @@ -69,7 +72,7 @@ npx cbz2scribecolor "D:\Comics\Issue 001.cbz"
```

> [!NOTE]
> `npx` supplies only the CBZ2ScribeColor Node.js command. You still need Node.js 22.5 or newer, Calibre, the KFX Output plugin installed in Calibre, and Kindle Previewer 3. Ensure `calibre-debug` is discoverable in a standard Calibre installation or on `PATH`; otherwise pass `--calibre-debug <path>` or set `CALIBRE_DEBUG_PATH`.
> `npx` supplies only the CBZ2ScribeColor Node.js command. You still need Node.js 22.5 or newer, Calibre, the KFX Output plugin installed in Calibre, and Kindle Previewer 3. Ensure `calibre-debug` is discoverable in a standard Calibre installation or on `PATH`; otherwise pass `--calibre-debug <path>` or set `CALIBRE_DEBUG_PATH`. Selecting JPEGli also requires a separate `cjpegli` installation; `npx` does not provide it.

Pass CLI options after the input path in the same way as a global installation:

Expand Down Expand Up @@ -143,6 +146,20 @@ Force right-to-left manga reading and retain the generated KPF for inspection:
cbz2scribe manga.cbz --direction rtl --keep-kpf
```

Use JPEGli for a middle ground between compression and speed:

```bash
cbz2scribe manga.cbz --jpeg-encoder jpegli --cjpegli "C:\Tools\jpegli\cjpegli.exe"
```

Use the former, faster JPEG encoder when conversion speed matters more than output size:

```bash
cbz2scribe manga.cbz --jpeg-encoder legacy
```

`--no-mozjpeg` remains a shortcut for `--jpeg-encoder legacy`.

## Command-line options

| Option | Default | Description |
Expand All @@ -157,6 +174,9 @@ cbz2scribe manga.cbz --direction rtl --keep-kpf
| `--calibre-debug <path>` | Auto-detected | Full path to Calibre's `calibre-debug` executable. |
| `-j, --jobs <count>` | `12` | Maximum number of CBZ files prepared concurrently. Each completed KPF is then compiled through KFX Output. |
| `--keep-kpf` | Off | Keep the intermediate Kindle Publishing Format package after successful KFX compilation. Failed conversions retain their KPF for diagnosis. |
| `--jpeg-encoder <mozjpeg\|jpegli\|legacy>` | `mozjpeg` | Select tuned MozJPEG, external Google JPEGli, or the former fast JPEG encoder. |
| `--cjpegli <path>` | Auto-detected | Full path to `cjpegli`. Used only with `--jpeg-encoder jpegli`; otherwise checks `CJPEGLI_PATH` and then `PATH`. |
| `--no-mozjpeg` | Off | Shortcut for `--jpeg-encoder legacy`. Cannot be combined with `--jpeg-encoder`. |
| `-h, --help` | — | Show CLI help. |

Existing destination KFX files with the same name are replaced. When processing multiple files, successful conversions are retained even if another input fails; the command exits non-zero and reports every failed filename at the end.
Expand Down Expand Up @@ -193,6 +213,20 @@ A source image whose width-to-height ratio meets `--wide-ratio` is treated as a

KFX image resources contain only the resized artwork bounds. Letterboxing is provided by the black logical page background rather than being baked into those resources. Kindle's synthetic four-corner Virtual Panels therefore divide and magnify the artwork rather than including top, bottom, or outside-edge bars.

## JPEG compression

Page artwork is encoded by default with Sharp's built-in MozJPEG mode using progressive scans and 4:4:4 chroma sampling. JPEGli uses distance 0.8, progressive level 2, and 4:4:4 chroma. Both profiles were calibrated against the previous quality-90 JPEG encoder rather than assuming that numeric quality settings are comparable across encoders. MozJPEG needs no additional executable; JPEGli invokes a separately installed `cjpegli` process and streams each resized page through it without temporary files.

An encoder-only benchmark on this project's primary Windows test PC used `cjpegli` 0.11.2 and three full comics: 82 source images producing 87 physical pages, including five split spreads. Every candidate encoded the same resized, uncompressed pixels. JPEGli's timing includes process startup and streaming for every page.

| Encoder | Page data | JPEG encode time | PSNR | Mean global SSIM |
| --- | ---: | ---: | ---: | ---: |
| Previous quality-90, 4:4:4 JPEG | 123.28 MiB | 3.5 s | 40.12 dB | 0.99958 |
| Tuned MozJPEG, 4:4:4 | 98.57 MiB | 54.5 s | 40.91 dB | 0.99966 |
| JPEGli distance 0.8, 4:4:4 | 110.80 MiB | 10.0 s | 40.24 dB | 0.99974 |

In this sample, MozJPEG made the image payload **20.0% smaller** and took **15.6 times** as long to encode. JPEGli made it **10.1% smaller** and took **2.86 times** as long as legacy; it was **5.47 times faster than MozJPEG**. All three calibrated profiles met or exceeded the legacy fidelity scores in this sample. Total conversion slowdowns will be lower because CBZ reading, image decoding and resizing, KPF assembly, KFX compilation, and metadata writing are unchanged. Finished KPF/KFX savings vary with artwork and non-image container overhead.

## Reading direction

Direction affects both page turns and the order of split spread halves:
Expand Down
2 changes: 2 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

The native Node KPF writer in `src/kindle-kpf.ts` is based on format research and the MIT-licensed implementation published in [HankunYu/kindle-comic-workaround-5.19.x](https://github.com/HankunYu/kindle-comic-workaround-5.19.x). It has been independently adapted to TypeScript and extended for explicit source-spread pairing, Scribe Colorsoft artwork bounds, black fixed-layout backgrounds, and Kindle Virtual Panel metadata.

Optional JPEGli encoding invokes a separately installed [`cjpegli`](https://github.com/google/jpegli) executable. JPEGli is distributed by Google under the BSD 3-Clause License and is not bundled with CBZ2ScribeColor.

The generated KPF/KFX structures use reverse-engineered, undocumented Amazon Kindle formats. Amazon, Kindle, Kindle Create, Kindle Previewer, and KFX are trademarks of Amazon.com, Inc. or its affiliates. This project is independent and is not affiliated with or endorsed by Amazon.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"cbz2scribecolor": "dist/cli.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clean && tsc -p tsconfig.json",
"prepack": "npm run build",
"start": "node dist/cli.js",
"test": "vitest run",
Expand All @@ -40,6 +41,7 @@
"kindle",
"kfx",
"comic",
"jpegli",
"kindle-scribe",
"colorsoft"
],
Expand Down
29 changes: 29 additions & 0 deletions src/cbz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ describe("CBZ to KPF preparation", () => {
expect([single.width, single.height]).toEqual([1986, 1986]);
expect([firstHalf.width, firstHalf.height]).toEqual([1765, 2648]);
expect([secondHalf.width, secondHalf.height]).toEqual([1765, 2648]);
expect([single.isProgressive, firstHalf.isProgressive, secondHalf.isProgressive])
.toEqual([true, true, true]);
expect([single.chromaSubsampling, firstHalf.chromaSubsampling, secondHalf.chromaSubsampling])
.toEqual(["4:4:4", "4:4:4", "4:4:4"]);
const kcb = JSON.parse(kpf.readAsText("book.kcb")) as { book_state: { book_reading_option: number; book_virtual_panelmovement: number } };
expect(kcb.book_state.book_reading_option).toBe(2);
expect(kcb.book_state.book_virtual_panelmovement).toBe(1);
Expand All @@ -59,6 +63,31 @@ describe("CBZ to KPF preparation", () => {
expect(plan.direction).toBe("rtl");
expect(plan.comicInfoFound).toBe(false);
});

it("can use the faster legacy JPEG encoder", async () => {
const directory = await temporaryDirectory();
const input = join(directory, "Fast.cbz");
const output = join(directory, "Fast.kpf");
await createCbz(input, undefined, [[1000, 1500]]);

await createKpfFromCbz(input, output, { jpegEncoder: "legacy" });

const page = await sharp(new AdmZip(output).getEntry("book_1.jpg")?.getData()).metadata();
expect(page.isProgressive).toBe(false);
expect(page.chromaSubsampling).toBe("4:4:4");
});

it("reports a missing JPEGli executable", async () => {
const directory = await temporaryDirectory();
const input = join(directory, "Jpegli.cbz");
const output = join(directory, "Jpegli.kpf");
await createCbz(input, undefined, [[100, 150]]);

await expect(createKpfFromCbz(input, output, {
jpegEncoder: "jpegli",
cjpegliPath: join(directory, "missing-cjpegli"),
})).rejects.toThrow("Could not start cjpegli");
});
});

async function temporaryDirectory(): Promise<string> {
Expand Down
58 changes: 50 additions & 8 deletions src/cbz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import { mkdir } from "node:fs/promises";
import { basename, dirname, extname, resolve } from "node:path";
import AdmZip from "adm-zip";
import sharp from "sharp";
import { encodeJpegli } from "./jpegli.js";
import { createComicKpf, type KpfPage } from "./kindle-kpf.js";

export const SCRIBE_COLORSOFT_WIDTH = 1986;
export const SCRIBE_COLORSOFT_HEIGHT = 2648;

export type ReadingDirection = "ltr" | "rtl";
export type PageJpegEncoder = "mozjpeg" | "jpegli" | "legacy";

export interface CbzOptions {
direction?: ReadingDirection | "auto";
wideRatio?: number;
jpegEncoder?: PageJpegEncoder;
cjpegliPath?: string;
}

export interface BookMetadata {
Expand Down Expand Up @@ -64,6 +68,17 @@ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".av
const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const DEFAULT_WIDE_RATIO = 1.125;
const PAGE_BACKGROUND = "#000000";
// Table 1 at quality 76 matched or exceeded the former quality-90 encoder in the project benchmark.
const MOZJPEG_OPTIONS = {
quality: 76,
chromaSubsampling: "4:4:4",
mozjpeg: true,
quantisationTable: 1,
} as const;
const FAST_JPEG_OPTIONS = {
quality: 90,
chromaSubsampling: "4:4:4",
} as const;

export async function planCbz(inputPath: string, options: CbzOptions = {}): Promise<ConversionPlan> {
return (await inspectCbz(resolve(inputPath), options)).plan;
Expand All @@ -75,7 +90,11 @@ export async function createKpfFromCbz(
options: CbzOptions = {},
): Promise<ConversionPlan> {
const inspection = await inspectCbz(resolve(inputPath), options);
const pages = await preparePages(inspection);
const jpegEncoder = options.jpegEncoder ?? "mozjpeg";
if (!["mozjpeg", "jpegli", "legacy"].includes(jpegEncoder)) {
throw new Error(`Unsupported JPEG encoder: ${jpegEncoder}`);
}
const pages = await preparePages(inspection, jpegEncoder, options.cjpegliPath);
const destination = resolve(outputPath);
await mkdir(dirname(destination), { recursive: true });
await createComicKpf(destination, pages, {
Expand Down Expand Up @@ -140,7 +159,11 @@ async function inspectCbz(inputPath: string, options: CbzOptions): Promise<Inspe
};
}

async function preparePages(inspection: Inspection): Promise<KpfPage[]> {
async function preparePages(
inspection: Inspection,
jpegEncoder: PageJpegEncoder,
cjpegliPath?: string,
): Promise<KpfPage[]> {
const pages: KpfPage[] = [];
for (const [sourceIndex, source] of inspection.plan.sourcePages.entries()) {
const entry = inspection.imageEntries[sourceIndex];
Expand All @@ -150,8 +173,11 @@ async function preparePages(inspection: Inspection): Promise<KpfPage[]> {
if (!source.doublePage) {
const bounds = containBounds(source.width, source.height);
pages.push({
data: await pipeline.resize(bounds.width, bounds.height, { fit: "fill" })
.jpeg({ quality: 90, chromaSubsampling: "4:4:4" }).toBuffer(),
data: await encodePage(
pipeline.resize(bounds.width, bounds.height, { fit: "fill" }),
jpegEncoder,
cjpegliPath,
),
width: bounds.width,
height: bounds.height,
sourceName: source.sourceName,
Expand All @@ -174,10 +200,13 @@ async function preparePages(inspection: Inspection): Promise<KpfPage[]> {
for (const half of halves) {
const bounds = containBounds(half.width, source.height);
pages.push({
data: await pipeline.clone()
.extract({ left: half.left, top: 0, width: half.width, height: source.height })
.resize(bounds.width, bounds.height, { fit: "fill" })
.jpeg({ quality: 90, chromaSubsampling: "4:4:4" }).toBuffer(),
data: await encodePage(
pipeline.clone()
.extract({ left: half.left, top: 0, width: half.width, height: source.height })
.resize(bounds.width, bounds.height, { fit: "fill" }),
jpegEncoder,
cjpegliPath,
),
width: bounds.width,
height: bounds.height,
sourceName: `${source.sourceName}#${half.side}`,
Expand All @@ -188,6 +217,19 @@ async function preparePages(inspection: Inspection): Promise<KpfPage[]> {
return pages;
}

async function encodePage(
pipeline: ReturnType<typeof sharp>,
jpegEncoder: PageJpegEncoder,
cjpegliPath?: string,
): Promise<Buffer> {
if (jpegEncoder === "mozjpeg") return pipeline.jpeg(MOZJPEG_OPTIONS).toBuffer();
if (jpegEncoder === "legacy") return pipeline.jpeg(FAST_JPEG_OPTIONS).toBuffer();

const { data, info } = await pipeline.removeAlpha().raw().toBuffer({ resolveWithObject: true });
if (info.channels !== 3) throw new Error(`JPEGli requires RGB input; Sharp produced ${info.channels} channels`);
return encodeJpegli(data, info.width, info.height, cjpegliPath ?? process.env.CJPEGLI_PATH ?? "cjpegli");
}

function containBounds(sourceWidth: number, sourceHeight: number): Bounds {
const scale = Math.min(SCRIBE_COLORSOFT_WIDTH / sourceWidth, SCRIBE_COLORSOFT_HEIGHT / sourceHeight);
return {
Expand Down
27 changes: 25 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { readdir, rm, stat } from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { Command, Option } from "commander";
import { createKpfFromCbz, planCbz, type ConversionPlan, type ReadingDirection } from "./cbz.js";
import { createKpfFromCbz, planCbz, type ConversionPlan, type PageJpegEncoder, type ReadingDirection } from "./cbz.js";
import { runWithConcurrency } from "./concurrency.js";
import { findCjpegli } from "./jpegli.js";
import { compileKfx, findKfxOutput } from "./kfx-output.js";

interface CliOptions {
Expand All @@ -16,6 +17,9 @@ interface CliOptions {
calibreDebug?: string;
jobs: string;
keepKpf: boolean;
jpegEncoder: PageJpegEncoder;
cjpegli?: string;
mozjpeg: boolean;
}

const program = new Command()
Expand All @@ -31,6 +35,9 @@ const program = new Command()
.option("--calibre-debug <path>", "explicit path to calibre-debug with the KFX Output plugin")
.option("-j, --jobs <count>", "maximum files processed concurrently", "12")
.option("--keep-kpf", "retain the generated KPF source package", false)
.addOption(new Option("--jpeg-encoder <encoder>", "page JPEG encoder").choices(["mozjpeg", "jpegli", "legacy"]).default("mozjpeg"))
.option("--cjpegli <path>", "explicit path to cjpegli for --jpeg-encoder jpegli")
.option("--no-mozjpeg", "shortcut for --jpeg-encoder legacy")
.showHelpAfterError()
.parse();

Expand All @@ -48,6 +55,10 @@ try {
async function run(inputPath: string, options: CliOptions): Promise<void> {
const wideRatio = positiveNumber(options.wideRatio, "wide-ratio");
const jobs = positiveInteger(options.jobs, "jobs");
const jpegEncoder = options.mozjpeg ? options.jpegEncoder : "legacy";
if (!options.mozjpeg && program.getOptionValueSource("jpegEncoder") === "cli") {
throw new Error("--no-mozjpeg cannot be combined with --jpeg-encoder");
}
const inputInfo = await stat(inputPath);
const sources = await findCbzFiles(inputPath, inputInfo.isDirectory(), options.recursive);
if (sources.length === 0) throw new Error("No CBZ files found");
Expand All @@ -58,6 +69,13 @@ async function run(inputPath: string, options: CliOptions): Promise<void> {
}
if (calibreDebug) console.log(`Using KFX Output through: ${calibreDebug}`);

const cjpegli = !options.dryRun && jpegEncoder === "jpegli" ? await findCjpegli(options.cjpegli) : undefined;
if (!options.dryRun && jpegEncoder === "jpegli" && !cjpegli) {
throw new Error("cjpegli was not found. Install Google JPEGli and put cjpegli on PATH, set CJPEGLI_PATH, or pass --cjpegli <path>.");
}
if (cjpegli) console.log(`Using JPEGli through: ${cjpegli}`);
if (!options.dryRun) console.log(`JPEG encoder: ${jpegEncoder}`);

const outputRoot = resolve(options.output);
const failures: string[] = [];
const concurrency = Math.min(jobs, sources.length);
Expand All @@ -68,7 +86,12 @@ async function run(inputPath: string, options: CliOptions): Promise<void> {
const kfxPath = outputPathFor(source, inputPath, inputInfo.isDirectory(), outputRoot, options.inPlace, ".kfx");
const kpfPath = outputPathFor(source, inputPath, inputInfo.isDirectory(), outputRoot, options.inPlace, ".kpf");
try {
const conversionOptions = { direction: options.direction, wideRatio };
const conversionOptions = {
direction: options.direction,
wideRatio,
jpegEncoder,
...(cjpegli ? { cjpegliPath: cjpegli } : {}),
};
const plan = options.dryRun ? await planCbz(source, conversionOptions) : await createKpfFromCbz(source, kpfPath, conversionOptions);
printPlan(label, plan, options.dryRun);
if (!options.dryRun) {
Expand Down
Loading
Loading