fix(metadata): support CBZ archive comments

Retry central-directory discovery at the ZIP comment limit when the default scan reaches the end of the file. Preserve the archive comment while rewriting ComicInfo.xml.

Fixes #1272
This commit is contained in:
neon
2026-09-05 09:49:57 +00:00
parent e63c4b3685
commit 43bf1b7cfa
3 changed files with 52 additions and 8 deletions
@@ -4,25 +4,42 @@ import * as unzipper from 'unzipper';
import { replaceFileAtomically } from '../shared/atomic-file-replace';
import { writeZipArchive, type ZipRewriteEntry } from '../shared/zip-rewrite';
const ZIP_END_OF_CENTRAL_DIRECTORY_SIZE_BYTES = 22;
const MAX_ZIP_COMMENT_SIZE_BYTES = 0xffff;
const ZIP_TAIL_SIZE_BYTES = ZIP_END_OF_CENTRAL_DIRECTORY_SIZE_BYTES + MAX_ZIP_COMMENT_SIZE_BYTES;
type ZipCentralDirectory = unzipper.CentralDirectory & { comment?: string };
const openZipFileWithOptions = unzipper.Open.file as (filePath: string, options: { tailSize: number }) => Promise<ZipCentralDirectory>;
async function openZipFile(filePath: string): Promise<ZipCentralDirectory> {
try {
return await unzipper.Open.file(filePath);
} catch (error) {
if (!(error instanceof Error) || error.message !== 'FILE_ENDED') throw error;
return openZipFileWithOptions(filePath, { tailSize: ZIP_TAIL_SIZE_BYTES });
}
}
function isComicInfoEntry(entryPath: string): boolean {
const normalized = entryPath.replace(/\\/g, '/').toLowerCase();
return normalized === 'comicinfo.xml' || normalized.endsWith('/comicinfo.xml');
}
export async function readComicInfoFromZip(filePath: string): Promise<string | null> {
const zip = await unzipper.Open.file(filePath);
const zip = await openZipFile(filePath);
const entry = zip.files.find((f) => isComicInfoEntry(f.path));
if (!entry) return null;
return (await entry.buffer()).toString('utf-8');
}
export async function writeComicInfoToZip(filePath: string, xmlContent: string): Promise<void> {
const zip = await unzipper.Open.file(filePath);
const zip = await openZipFile(filePath);
const existing = zip.files.find((f) => isComicInfoEntry(f.path));
const xmlEntryPath = existing?.path ?? 'ComicInfo.xml';
const tmpPath = join(dirname(filePath), `.cbx-write-${randomUUID()}`);
await writeZipArchive(tmpPath, rewriteEntries(zip.files, xmlEntryPath, xmlContent));
await writeZipArchive(tmpPath, rewriteEntries(zip.files, xmlEntryPath, xmlContent), { comment: zip.comment });
await replaceFileAtomically(tmpPath, filePath);
}
@@ -1,6 +1,6 @@
import { ZipArchive } from 'archiver';
import { createWriteStream } from 'fs';
import { mkdtemp, readdir, rm } from 'fs/promises';
import { mkdtemp, readFile, readdir, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import * as unzipper from 'unzipper';
@@ -9,6 +9,9 @@ import { readComicInfoFromZip, writeComicInfoToZip } from './cbz-zip-patcher';
let testRoot: string;
const MAX_ZIP_COMMENT_BYTES = 0xffff;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
beforeEach(async () => {
testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-cbz-roundtrip-'));
});
@@ -23,9 +26,9 @@ function pageBytes(index: number): Buffer {
return bytes;
}
async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>): Promise<void> {
async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>, comment = ''): Promise<void> {
const output = createWriteStream(filePath);
const archive = new ZipArchive({ zlib: { level: 6 } });
const archive = new ZipArchive({ zlib: { level: 6 }, comment });
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
@@ -37,6 +40,15 @@ async function writeArchive(filePath: string, entries: Array<{ path: string; con
});
}
async function readArchiveComment(filePath: string): Promise<string> {
const contents = await readFile(filePath);
const endRecordOffset = contents.lastIndexOf(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE);
if (endRecordOffset < 0) throw new Error('ZIP end of central directory record not found');
const commentLength = contents.readUInt16LE(endRecordOffset + 20);
return contents.subarray(endRecordOffset + 22, endRecordOffset + 22 + commentLength).toString('utf-8');
}
async function readArchive(filePath: string): Promise<Array<{ path: string; content: Buffer }>> {
const zip = await unzipper.Open.file(filePath);
return Promise.all(zip.files.map(async (entry) => ({ path: entry.path, content: await entry.buffer() })));
@@ -85,4 +97,15 @@ describe('cbz zip round trip', () => {
await expect(readdir(testRoot)).resolves.toEqual(['volume.cbz']);
});
it('updates comic info and preserves an archive comment at the ZIP format limit', async () => {
const target = join(testRoot, 'volume.cbz');
const archiveComment = 'x'.repeat(MAX_ZIP_COMMENT_BYTES);
await writeArchive(target, comicPages(3), archiveComment);
await writeComicInfoToZip(target, '<ComicInfo><Title>Updated</Title></ComicInfo>');
await expect(readComicInfoFromZip(target)).resolves.toBe('<ComicInfo><Title>Updated</Title></ComicInfo>');
await expect(readArchiveComment(target)).resolves.toBe(archiveComment);
});
});
@@ -10,14 +10,18 @@ export interface ZipRewriteEntry {
store?: boolean;
}
export interface ZipRewriteOptions {
comment?: string;
}
/**
* Archiver pipes an appended stream into its queue immediately, so appending every entry of a large
* archive up front holds one descriptor and one decompression buffer per entry until the rewrite
* finishes. Entries are appended one at a time and their sources opened only once archiver is ready
* for them, which keeps descriptors and memory flat regardless of entry count.
*/
export async function writeZipArchive(tmpPath: string, entries: Iterable<ZipRewriteEntry>): Promise<void> {
const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL } });
export async function writeZipArchive(tmpPath: string, entries: Iterable<ZipRewriteEntry>, options: ZipRewriteOptions = {}): Promise<void> {
const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL }, comment: options.comment });
const output = createWriteStream(tmpPath);
let fail!: (error: Error) => void;