yom-format 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/cli.d.ts +30 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +344 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/pipeline/avif.d.ts +3 -0
- package/dist/pipeline/avif.d.ts.map +1 -0
- package/dist/pipeline/avif.js +30 -0
- package/dist/pipeline/avif.js.map +1 -0
- package/dist/pipeline/convert.d.ts +28 -0
- package/dist/pipeline/convert.d.ts.map +1 -0
- package/dist/pipeline/convert.js +111 -0
- package/dist/pipeline/convert.js.map +1 -0
- package/dist/pipeline/index.d.ts +17 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +17 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/pipeline/ocrLayoutBuilder.d.ts +10 -0
- package/dist/pipeline/ocrLayoutBuilder.d.ts.map +1 -0
- package/dist/pipeline/ocrLayoutBuilder.js +62 -0
- package/dist/pipeline/ocrLayoutBuilder.js.map +1 -0
- package/dist/pipeline/pdfDocument.d.ts +12 -0
- package/dist/pipeline/pdfDocument.d.ts.map +1 -0
- package/dist/pipeline/pdfDocument.js +76 -0
- package/dist/pipeline/pdfDocument.js.map +1 -0
- package/dist/pipeline/renderPage.d.ts +10 -0
- package/dist/pipeline/renderPage.d.ts.map +1 -0
- package/dist/pipeline/renderPage.js +36 -0
- package/dist/pipeline/renderPage.js.map +1 -0
- package/dist/pipeline/textExtract.d.ts +29 -0
- package/dist/pipeline/textExtract.d.ts.map +1 -0
- package/dist/pipeline/textExtract.js +48 -0
- package/dist/pipeline/textExtract.js.map +1 -0
- package/dist/yom/blobLimits.d.ts +15 -0
- package/dist/yom/blobLimits.d.ts.map +1 -0
- package/dist/yom/blobLimits.js +25 -0
- package/dist/yom/blobLimits.js.map +1 -0
- package/dist/yom/byteSource.d.ts +56 -0
- package/dist/yom/byteSource.d.ts.map +1 -0
- package/dist/yom/byteSource.js +111 -0
- package/dist/yom/byteSource.js.map +1 -0
- package/dist/yom/bytes.d.ts +2 -0
- package/dist/yom/bytes.d.ts.map +1 -0
- package/dist/yom/bytes.js +13 -0
- package/dist/yom/bytes.js.map +1 -0
- package/dist/yom/constants.d.ts +34 -0
- package/dist/yom/constants.d.ts.map +1 -0
- package/dist/yom/constants.js +82 -0
- package/dist/yom/constants.js.map +1 -0
- package/dist/yom/errors.d.ts +44 -0
- package/dist/yom/errors.d.ts.map +1 -0
- package/dist/yom/errors.js +44 -0
- package/dist/yom/errors.js.map +1 -0
- package/dist/yom/flatbufferVerify.d.ts +21 -0
- package/dist/yom/flatbufferVerify.d.ts.map +1 -0
- package/dist/yom/flatbufferVerify.js +255 -0
- package/dist/yom/flatbufferVerify.js.map +1 -0
- package/dist/yom/generated/yomu/yom/blob-kind.d.ts +9 -0
- package/dist/yom/generated/yomu/yom/blob-kind.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/blob-kind.js +12 -0
- package/dist/yom/generated/yomu/yom/blob-kind.js.map +1 -0
- package/dist/yom/generated/yomu/yom/blob-ref.d.ts +45 -0
- package/dist/yom/generated/yomu/yom/blob-ref.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/blob-ref.js +141 -0
- package/dist/yom/generated/yomu/yom/blob-ref.js.map +1 -0
- package/dist/yom/generated/yomu/yom/book-metadata.d.ts +32 -0
- package/dist/yom/generated/yomu/yom/book-metadata.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/book-metadata.js +89 -0
- package/dist/yom/generated/yomu/yom/book-metadata.js.map +1 -0
- package/dist/yom/generated/yomu/yom/compression.d.ts +5 -0
- package/dist/yom/generated/yomu/yom/compression.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/compression.js +8 -0
- package/dist/yom/generated/yomu/yom/compression.js.map +1 -0
- package/dist/yom/generated/yomu/yom/main-index.d.ts +63 -0
- package/dist/yom/generated/yomu/yom/main-index.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/main-index.js +208 -0
- package/dist/yom/generated/yomu/yom/main-index.js.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrblock.d.ts +32 -0
- package/dist/yom/generated/yomu/yom/ocrblock.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrblock.js +84 -0
- package/dist/yom/generated/yomu/yom/ocrblock.js.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrlayout.d.ts +27 -0
- package/dist/yom/generated/yomu/yom/ocrlayout.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrlayout.js +76 -0
- package/dist/yom/generated/yomu/yom/ocrlayout.js.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrline.d.ts +35 -0
- package/dist/yom/generated/yomu/yom/ocrline.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrline.js +95 -0
- package/dist/yom/generated/yomu/yom/ocrline.js.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrpage.d.ts +46 -0
- package/dist/yom/generated/yomu/yom/ocrpage.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrpage.js +147 -0
- package/dist/yom/generated/yomu/yom/ocrpage.js.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrtoken.d.ts +44 -0
- package/dist/yom/generated/yomu/yom/ocrtoken.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/ocrtoken.js +128 -0
- package/dist/yom/generated/yomu/yom/ocrtoken.js.map +1 -0
- package/dist/yom/generated/yomu/yom/outline-entry.d.ts +31 -0
- package/dist/yom/generated/yomu/yom/outline-entry.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/outline-entry.js +92 -0
- package/dist/yom/generated/yomu/yom/outline-entry.js.map +1 -0
- package/dist/yom/generated/yomu/yom/page-entry.d.ts +95 -0
- package/dist/yom/generated/yomu/yom/page-entry.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/page-entry.js +288 -0
- package/dist/yom/generated/yomu/yom/page-entry.js.map +1 -0
- package/dist/yom/generated/yomu/yom/pdfexport-metadata.d.ts +32 -0
- package/dist/yom/generated/yomu/yom/pdfexport-metadata.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/pdfexport-metadata.js +96 -0
- package/dist/yom/generated/yomu/yom/pdfexport-metadata.js.map +1 -0
- package/dist/yom/generated/yomu/yom/reading-direction.d.ts +5 -0
- package/dist/yom/generated/yomu/yom/reading-direction.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/reading-direction.js +8 -0
- package/dist/yom/generated/yomu/yom/reading-direction.js.map +1 -0
- package/dist/yom/generated/yomu/yom/search-term-ref.d.ts +34 -0
- package/dist/yom/generated/yomu/yom/search-term-ref.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom/search-term-ref.js +99 -0
- package/dist/yom/generated/yomu/yom/search-term-ref.js.map +1 -0
- package/dist/yom/generated/yomu/yom.d.ts +6 -0
- package/dist/yom/generated/yomu/yom.d.ts.map +1 -0
- package/dist/yom/generated/yomu/yom.js +8 -0
- package/dist/yom/generated/yomu/yom.js.map +1 -0
- package/dist/yom/hash.d.ts +4 -0
- package/dist/yom/hash.d.ts.map +1 -0
- package/dist/yom/hash.js +21 -0
- package/dist/yom/hash.js.map +1 -0
- package/dist/yom/header.d.ts +44 -0
- package/dist/yom/header.d.ts.map +1 -0
- package/dist/yom/header.js +115 -0
- package/dist/yom/header.js.map +1 -0
- package/dist/yom/reader.d.ts +57 -0
- package/dist/yom/reader.d.ts.map +1 -0
- package/dist/yom/reader.js +141 -0
- package/dist/yom/reader.js.map +1 -0
- package/dist/yom/searchIndex.d.ts +41 -0
- package/dist/yom/searchIndex.d.ts.map +1 -0
- package/dist/yom/searchIndex.js +133 -0
- package/dist/yom/searchIndex.js.map +1 -0
- package/dist/yom/textNormalizer.d.ts +19 -0
- package/dist/yom/textNormalizer.d.ts.map +1 -0
- package/dist/yom/textNormalizer.js +77 -0
- package/dist/yom/textNormalizer.js.map +1 -0
- package/dist/yom/validator.d.ts +14 -0
- package/dist/yom/validator.d.ts.map +1 -0
- package/dist/yom/validator.js +197 -0
- package/dist/yom/validator.js.map +1 -0
- package/dist/yom/writer.d.ts +76 -0
- package/dist/yom/writer.d.ts.map +1 -0
- package/dist/yom/writer.js +154 -0
- package/dist/yom/writer.js.map +1 -0
- package/dist/yom/zstd.d.ts +14 -0
- package/dist/yom/zstd.d.ts.map +1 -0
- package/dist/yom/zstd.js +43 -0
- package/dist/yom/zstd.js.map +1 -0
- package/package.json +65 -0
- package/src/cli.ts +372 -0
- package/src/index.ts +109 -0
- package/src/pipeline/avif.ts +33 -0
- package/src/pipeline/convert.ts +148 -0
- package/src/pipeline/index.ts +28 -0
- package/src/pipeline/ocrLayoutBuilder.ts +73 -0
- package/src/pipeline/pdfDocument.ts +86 -0
- package/src/pipeline/renderPage.ts +48 -0
- package/src/pipeline/textExtract.ts +64 -0
- package/src/worker/convert.worker.ts +54 -0
- package/src/yom/blobLimits.ts +30 -0
- package/src/yom/byteSource.ts +144 -0
- package/src/yom/bytes.ts +11 -0
- package/src/yom/constants.ts +89 -0
- package/src/yom/errors.ts +43 -0
- package/src/yom/flatbufferVerify.ts +298 -0
- package/src/yom/generated/yomu/yom/blob-kind.ts +12 -0
- package/src/yom/generated/yomu/yom/blob-ref.ts +184 -0
- package/src/yom/generated/yomu/yom/book-metadata.ts +121 -0
- package/src/yom/generated/yomu/yom/compression.ts +8 -0
- package/src/yom/generated/yomu/yom/main-index.ts +257 -0
- package/src/yom/generated/yomu/yom/ocrblock.ts +117 -0
- package/src/yom/generated/yomu/yom/ocrlayout.ts +102 -0
- package/src/yom/generated/yomu/yom/ocrline.ts +131 -0
- package/src/yom/generated/yomu/yom/ocrpage.ts +191 -0
- package/src/yom/generated/yomu/yom/ocrtoken.ts +173 -0
- package/src/yom/generated/yomu/yom/outline-entry.ts +124 -0
- package/src/yom/generated/yomu/yom/page-entry.ts +388 -0
- package/src/yom/generated/yomu/yom/pdfexport-metadata.ts +128 -0
- package/src/yom/generated/yomu/yom/reading-direction.ts +8 -0
- package/src/yom/generated/yomu/yom/search-term-ref.ts +134 -0
- package/src/yom/generated/yomu/yom.ts +9 -0
- package/src/yom/hash.ts +20 -0
- package/src/yom/header.ts +132 -0
- package/src/yom/reader.ts +163 -0
- package/src/yom/searchIndex.ts +178 -0
- package/src/yom/textNormalizer.ts +92 -0
- package/src/yom/validator.ts +245 -0
- package/src/yom/writer.ts +337 -0
- package/src/yom/zstd.ts +47 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whitespace-insensitive Japanese text normalization for YOM search. Ports
|
|
3
|
+
* the Swift implementation in `swift/Sources/YOMFormat/YOMTextNormalizer.swift`:
|
|
4
|
+
* NFKC-normalized text with all
|
|
5
|
+
* whitespace (including the full-width space U+3000) removed entirely, not
|
|
6
|
+
* just collapsed, since Japanese text carries no meaningful inter-word
|
|
7
|
+
* spacing and OCR/PDF line wraps insert whitespace at essentially arbitrary
|
|
8
|
+
* points that must not affect matching.
|
|
9
|
+
*
|
|
10
|
+
* Matches Swift's `CharacterSet.whitespacesAndNewlines` (Unicode `Zs` +
|
|
11
|
+
* newlines) rather than JS's broader `\s` (which also strips U+FEFF BOM).
|
|
12
|
+
* Every code point below is written as an explicit `\uXXXX` escape so the
|
|
13
|
+
* exact whitespace set is auditable rather than relying on invisible
|
|
14
|
+
* characters in source.
|
|
15
|
+
*/
|
|
16
|
+
const WHITESPACE_CODEPOINTS = [
|
|
17
|
+
0x0009, // tab
|
|
18
|
+
0x000a, // LF
|
|
19
|
+
0x000b, // vertical tab
|
|
20
|
+
0x000c, // form feed
|
|
21
|
+
0x000d, // CR
|
|
22
|
+
0x0020, // space
|
|
23
|
+
0x0085, // NEL
|
|
24
|
+
0x00a0, // no-break space (Zs)
|
|
25
|
+
0x1680, // ogham space mark (Zs)
|
|
26
|
+
0x2000,
|
|
27
|
+
0x2001,
|
|
28
|
+
0x2002,
|
|
29
|
+
0x2003,
|
|
30
|
+
0x2004,
|
|
31
|
+
0x2005,
|
|
32
|
+
0x2006,
|
|
33
|
+
0x2007,
|
|
34
|
+
0x2008,
|
|
35
|
+
0x2009,
|
|
36
|
+
0x200a, // en/em spaces etc. (Zs)
|
|
37
|
+
0x2028, // line separator
|
|
38
|
+
0x2029, // paragraph separator
|
|
39
|
+
0x202f, // narrow no-break space (Zs)
|
|
40
|
+
0x205f, // medium mathematical space (Zs)
|
|
41
|
+
0x3000, // ideographic (full-width) space (Zs)
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const WHITESPACE_PATTERN = new RegExp(
|
|
45
|
+
`[${WHITESPACE_CODEPOINTS.map((cp) => `\\u{${cp.toString(16)}}`).join("")}]`,
|
|
46
|
+
"gu",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export interface NormalizedTextBlob {
|
|
50
|
+
data: Uint8Array;
|
|
51
|
+
normalizedPageTexts: string[];
|
|
52
|
+
/** Each page's byte range within `data` (end-exclusive). */
|
|
53
|
+
pageRanges: Array<{ start: number; end: number }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** NFKC + whitespace-removal for a single page's text. */
|
|
57
|
+
export function normalizeText(text: string): string {
|
|
58
|
+
return text.normalize("NFKC").replace(WHITESPACE_PATTERN, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds the single blob that stores every page's normalized text back to
|
|
63
|
+
* back, each page separated by `"\n"` (a normalized page's text never
|
|
64
|
+
* contains raw whitespace itself since it was just stripped, so `"\n"` is an
|
|
65
|
+
* unambiguous separator).
|
|
66
|
+
*/
|
|
67
|
+
export function buildNormalizedTextBlob(pageTexts: string[]): NormalizedTextBlob {
|
|
68
|
+
const normalizedPages = pageTexts.map(normalizeText);
|
|
69
|
+
const encoder = new TextEncoder();
|
|
70
|
+
const chunks: Uint8Array[] = [];
|
|
71
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
|
|
74
|
+
for (const page of normalizedPages) {
|
|
75
|
+
const bytes = encoder.encode(page);
|
|
76
|
+
chunks.push(bytes);
|
|
77
|
+
ranges.push({ start: cursor, end: cursor + bytes.length });
|
|
78
|
+
cursor += bytes.length;
|
|
79
|
+
const newline = encoder.encode("\n");
|
|
80
|
+
chunks.push(newline);
|
|
81
|
+
cursor += newline.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = new Uint8Array(cursor);
|
|
85
|
+
let offset = 0;
|
|
86
|
+
for (const chunk of chunks) {
|
|
87
|
+
data.set(chunk, offset);
|
|
88
|
+
offset += chunk.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { data, normalizedPageTexts: normalizedPages, pageRanges: ranges };
|
|
92
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { MainIndex } from "./generated/yomu/yom/main-index.js";
|
|
2
|
+
import { BlobKind } from "./generated/yomu/yom/blob-kind.js";
|
|
3
|
+
import { Compression } from "./generated/yomu/yom/compression.js";
|
|
4
|
+
import type { BlobRef } from "./generated/yomu/yom/blob-ref.js";
|
|
5
|
+
import {
|
|
6
|
+
MAX_TOTAL_UNCOMPRESSED,
|
|
7
|
+
YOM_HEADER_SIZE,
|
|
8
|
+
YOM_IMAGE_BLOB_ALIGNMENT,
|
|
9
|
+
YOM_MAGIC,
|
|
10
|
+
YOM_FORMAT_VERSION,
|
|
11
|
+
requiredCompression,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
import { getVerifiedRootAsMainIndex } from "./flatbufferVerify.js";
|
|
14
|
+
import { enforceBlobSizePolicy } from "./blobLimits.js";
|
|
15
|
+
import { BlobTooLargeError } from "./errors.js";
|
|
16
|
+
import {
|
|
17
|
+
HEADER_FIELDS_SIZE,
|
|
18
|
+
encodeFooter,
|
|
19
|
+
encodeHeader,
|
|
20
|
+
headersEqual,
|
|
21
|
+
parseHeaderFields,
|
|
22
|
+
zeroingHashes,
|
|
23
|
+
type YOMHeader,
|
|
24
|
+
} from "./header.js";
|
|
25
|
+
import { bytesEqual, sha256 } from "./hash.js";
|
|
26
|
+
import { zstdDecompress } from "./zstd.js";
|
|
27
|
+
import { concatBytes } from "./bytes.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strict, standalone validation of a YOM v1 packfile, independent of
|
|
31
|
+
* `YOMPackfileReader`. Ports the Swift implementation in
|
|
32
|
+
* `swift/Sources/YOMFormat/YOMValidator.swift`: magic/
|
|
33
|
+
* version, file-size consistency, index/file/blob hashes, blob offset bounds
|
|
34
|
+
* and alignment, the fixed compression-by-kind policy, and the "exactly one
|
|
35
|
+
* page image blob + one thumbnail blob per page" rule.
|
|
36
|
+
*
|
|
37
|
+
* Throws on the first failure found rather than collecting every violation.
|
|
38
|
+
*/
|
|
39
|
+
export class YOMValidationError extends Error {}
|
|
40
|
+
|
|
41
|
+
export async function validateYOM(data: Uint8Array): Promise<void> {
|
|
42
|
+
const header = validateHeaderAndFooter(data);
|
|
43
|
+
const indexEnd = header.indexOffset + header.indexUncompressedLength;
|
|
44
|
+
const footerStart = data.length - HEADER_FIELDS_SIZE;
|
|
45
|
+
const indexBytes = data.subarray(Number(header.indexOffset), Number(indexEnd));
|
|
46
|
+
|
|
47
|
+
await validateIndexAndFileHashes(data, header, indexBytes, footerStart);
|
|
48
|
+
|
|
49
|
+
// Full bounded structural verification of the index before traversal (Gap 3).
|
|
50
|
+
const mainIndex = getVerifiedRootAsMainIndex(indexBytes, true);
|
|
51
|
+
|
|
52
|
+
const blobCounts = await validateBlobs(mainIndex, data, indexEnd, footerStart);
|
|
53
|
+
|
|
54
|
+
const pageCount = mainIndex.pagesLength();
|
|
55
|
+
if (blobCounts.image !== pageCount) {
|
|
56
|
+
throw new YOMValidationError(`expected ${pageCount} PageImage blobs, found ${blobCounts.image}`);
|
|
57
|
+
}
|
|
58
|
+
if (blobCounts.thumbnail !== pageCount) {
|
|
59
|
+
throw new YOMValidationError(`expected ${pageCount} ThumbnailImage blobs, found ${blobCounts.thumbnail}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validatePageBlobReferences(mainIndex);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parses and cross-checks the fixed header against its footer mirror. */
|
|
66
|
+
function validateHeaderAndFooter(data: Uint8Array): YOMHeader {
|
|
67
|
+
if (data.length < YOM_HEADER_SIZE + HEADER_FIELDS_SIZE) {
|
|
68
|
+
throw new YOMValidationError("file too small");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const header = parseHeaderFields(data.subarray(0, YOM_HEADER_SIZE));
|
|
72
|
+
if (!bytesEqual(header.magic, YOM_MAGIC)) throw new YOMValidationError("bad magic");
|
|
73
|
+
if (header.formatVersion !== YOM_FORMAT_VERSION) {
|
|
74
|
+
throw new YOMValidationError(`unsupported format version ${header.formatVersion}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const indexEnd = header.indexOffset + header.indexUncompressedLength;
|
|
78
|
+
if (header.indexOffset !== BigInt(YOM_HEADER_SIZE) || indexEnd > BigInt(data.length)) {
|
|
79
|
+
throw new YOMValidationError("index out of bounds");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const footerStart = data.length - HEADER_FIELDS_SIZE;
|
|
83
|
+
if (BigInt(footerStart) < indexEnd) throw new YOMValidationError("index out of bounds");
|
|
84
|
+
const footer = parseHeaderFields(data.subarray(footerStart));
|
|
85
|
+
if (!headersEqual(footer, header)) throw new YOMValidationError("footer does not match header");
|
|
86
|
+
|
|
87
|
+
return header;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Recomputes the index hash and the whole-file hash (with hash fields
|
|
92
|
+
* zeroed, matching the writer's convention) and compares them against the
|
|
93
|
+
* values stored in the header.
|
|
94
|
+
*/
|
|
95
|
+
async function validateIndexAndFileHashes(
|
|
96
|
+
data: Uint8Array,
|
|
97
|
+
header: YOMHeader,
|
|
98
|
+
indexBytes: Uint8Array,
|
|
99
|
+
footerStart: number,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const actualIndexHash = await sha256(indexBytes);
|
|
102
|
+
if (!bytesEqual(actualIndexHash, header.indexHash)) throw new YOMValidationError("index hash mismatch");
|
|
103
|
+
|
|
104
|
+
const hashingHeader = zeroingHashes(header);
|
|
105
|
+
const recomputeInput = concatBytes([
|
|
106
|
+
encodeHeader(hashingHeader),
|
|
107
|
+
data.subarray(YOM_HEADER_SIZE, footerStart),
|
|
108
|
+
encodeFooter(hashingHeader),
|
|
109
|
+
]);
|
|
110
|
+
const actualFileHash = await sha256(recomputeInput);
|
|
111
|
+
if (!bytesEqual(actualFileHash, header.fileHash)) throw new YOMValidationError("file hash mismatch");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validates every blob's compression policy, alignment, bounds, and hash.
|
|
116
|
+
* Returns how many PageImage/ThumbnailImage blobs were found, so the caller
|
|
117
|
+
* can enforce "exactly one of each per page, no extras".
|
|
118
|
+
*/
|
|
119
|
+
async function validateBlobs(
|
|
120
|
+
mainIndex: MainIndex,
|
|
121
|
+
data: Uint8Array,
|
|
122
|
+
indexEnd: bigint,
|
|
123
|
+
footerStart: number,
|
|
124
|
+
): Promise<{ image: number; thumbnail: number }> {
|
|
125
|
+
let imageBlobCount = 0;
|
|
126
|
+
let thumbnailBlobCount = 0;
|
|
127
|
+
let totalUncompressed = 0;
|
|
128
|
+
|
|
129
|
+
for (let blobIndex = 0; blobIndex < mainIndex.blobsLength(); blobIndex++) {
|
|
130
|
+
const blob = mainIndex.blobs(blobIndex);
|
|
131
|
+
if (!blob) throw new YOMValidationError(`blob ${blobIndex} missing`);
|
|
132
|
+
validateBlobCompressionAndAlignment(blob, blobIndex);
|
|
133
|
+
|
|
134
|
+
const blobEnd = blob.offset() + blob.compressedLength();
|
|
135
|
+
if (blob.offset() < indexEnd || blobEnd > BigInt(footerStart)) {
|
|
136
|
+
throw new YOMValidationError(`blob ${blobIndex} out of bounds`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Reader-side size caps before touching bytes (Gap 2).
|
|
140
|
+
const compressedLength = Number(blob.compressedLength());
|
|
141
|
+
const uncompressedLength = Number(blob.uncompressedLength());
|
|
142
|
+
try {
|
|
143
|
+
enforceBlobSizePolicy(blob.kind(), compressedLength, uncompressedLength);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err instanceof BlobTooLargeError) {
|
|
146
|
+
throw new YOMValidationError(`blob ${blobIndex} ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
totalUncompressed += uncompressedLength;
|
|
151
|
+
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
|
152
|
+
throw new YOMValidationError(`total uncompressed size exceeds cap ${MAX_TOTAL_UNCOMPRESSED}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rawBytes = data.subarray(Number(blob.offset()), Number(blobEnd));
|
|
156
|
+
await validateBlobHash(blob, blobIndex, uncompressedLength, rawBytes);
|
|
157
|
+
|
|
158
|
+
if (blob.kind() === BlobKind.PageImage) imageBlobCount += 1;
|
|
159
|
+
if (blob.kind() === BlobKind.ThumbnailImage) thumbnailBlobCount += 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { image: imageBlobCount, thumbnail: thumbnailBlobCount };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function validateBlobCompressionAndAlignment(blob: BlobRef, blobIndex: number): void {
|
|
166
|
+
const required = requiredCompression(blob.kind());
|
|
167
|
+
if (blob.compression() !== required) {
|
|
168
|
+
throw new YOMValidationError(
|
|
169
|
+
`blob ${blobIndex} compression policy violation: kind ${blob.kind()} declared ${blob.compression()} required ${required}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const alignment = BigInt(Math.max(blob.alignment(), 1));
|
|
174
|
+
if (blob.offset() % alignment !== 0n) {
|
|
175
|
+
throw new YOMValidationError(`blob ${blobIndex} alignment violation`);
|
|
176
|
+
}
|
|
177
|
+
if (blob.kind() === BlobKind.PageImage || blob.kind() === BlobKind.ThumbnailImage) {
|
|
178
|
+
if (blob.alignment() < YOM_IMAGE_BLOB_ALIGNMENT) {
|
|
179
|
+
throw new YOMValidationError(`blob ${blobIndex} alignment violation`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function validateBlobHash(
|
|
185
|
+
blob: BlobRef,
|
|
186
|
+
blobIndex: number,
|
|
187
|
+
uncompressedLength: number,
|
|
188
|
+
rawBytes: Uint8Array,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
let original: Uint8Array;
|
|
191
|
+
if (blob.compression() === Compression.None) {
|
|
192
|
+
original = rawBytes;
|
|
193
|
+
} else {
|
|
194
|
+
try {
|
|
195
|
+
original = await zstdDecompress(rawBytes, uncompressedLength);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new YOMValidationError(`blob ${blobIndex} hash mismatch (decompression failed)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const actualHash = await sha256(original);
|
|
201
|
+
const expectedHash = blob.hashArray();
|
|
202
|
+
if (!expectedHash || !bytesEqual(actualHash, expectedHash)) {
|
|
203
|
+
throw new YOMValidationError(`blob ${blobIndex} hash mismatch`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Confirms every page references exactly one PageImage and one
|
|
209
|
+
* ThumbnailImage blob, each of the right kind, with no blob shared between
|
|
210
|
+
* two pages.
|
|
211
|
+
*/
|
|
212
|
+
function validatePageBlobReferences(mainIndex: MainIndex): void {
|
|
213
|
+
const blobCount = mainIndex.blobsLength();
|
|
214
|
+
const usedImageBlobIndices = new Set<number>();
|
|
215
|
+
const usedThumbnailBlobIndices = new Set<number>();
|
|
216
|
+
|
|
217
|
+
for (let pageIndex = 0; pageIndex < mainIndex.pagesLength(); pageIndex++) {
|
|
218
|
+
const page = mainIndex.pages(pageIndex);
|
|
219
|
+
if (!page) throw new YOMValidationError(`page ${pageIndex} missing`);
|
|
220
|
+
|
|
221
|
+
const imageBlobIndex = page.pageImageBlobIndex();
|
|
222
|
+
if (imageBlobIndex < 0 || imageBlobIndex >= blobCount) {
|
|
223
|
+
throw new YOMValidationError(`page ${pageIndex} missing image blob`);
|
|
224
|
+
}
|
|
225
|
+
if (mainIndex.blobs(imageBlobIndex)?.kind() !== BlobKind.PageImage) {
|
|
226
|
+
throw new YOMValidationError(`page ${pageIndex} image blob has wrong kind`);
|
|
227
|
+
}
|
|
228
|
+
if (usedImageBlobIndices.has(imageBlobIndex)) {
|
|
229
|
+
throw new YOMValidationError(`blob ${imageBlobIndex} referenced as image by more than one page`);
|
|
230
|
+
}
|
|
231
|
+
usedImageBlobIndices.add(imageBlobIndex);
|
|
232
|
+
|
|
233
|
+
const thumbnailBlobIndex = page.thumbnailBlobIndex();
|
|
234
|
+
if (thumbnailBlobIndex < 0 || thumbnailBlobIndex >= blobCount) {
|
|
235
|
+
throw new YOMValidationError(`page ${pageIndex} missing thumbnail blob`);
|
|
236
|
+
}
|
|
237
|
+
if (mainIndex.blobs(thumbnailBlobIndex)?.kind() !== BlobKind.ThumbnailImage) {
|
|
238
|
+
throw new YOMValidationError(`page ${pageIndex} thumbnail blob has wrong kind`);
|
|
239
|
+
}
|
|
240
|
+
if (usedThumbnailBlobIndices.has(thumbnailBlobIndex)) {
|
|
241
|
+
throw new YOMValidationError(`blob ${thumbnailBlobIndex} referenced as thumbnail by more than one page`);
|
|
242
|
+
}
|
|
243
|
+
usedThumbnailBlobIndices.add(thumbnailBlobIndex);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { Builder } from "flatbuffers";
|
|
2
|
+
import { BlobKind } from "./generated/yomu/yom/blob-kind.js";
|
|
3
|
+
import { Compression } from "./generated/yomu/yom/compression.js";
|
|
4
|
+
import type { ReadingDirection } from "./generated/yomu/yom/reading-direction.js";
|
|
5
|
+
import { BlobRefT } from "./generated/yomu/yom/blob-ref.js";
|
|
6
|
+
import { PageEntryT } from "./generated/yomu/yom/page-entry.js";
|
|
7
|
+
import { BookMetadataT } from "./generated/yomu/yom/book-metadata.js";
|
|
8
|
+
import { SearchTermRefT } from "./generated/yomu/yom/search-term-ref.js";
|
|
9
|
+
import { OutlineEntryT } from "./generated/yomu/yom/outline-entry.js";
|
|
10
|
+
import { PDFExportMetadataT } from "./generated/yomu/yom/pdfexport-metadata.js";
|
|
11
|
+
import { MainIndexT } from "./generated/yomu/yom/main-index.js";
|
|
12
|
+
import {
|
|
13
|
+
YOM_FORMAT_VERSION,
|
|
14
|
+
YOM_HASH_LENGTH,
|
|
15
|
+
YOM_HEADER_SIZE,
|
|
16
|
+
YOM_MAGIC,
|
|
17
|
+
requiredAlignment,
|
|
18
|
+
requiredCompression,
|
|
19
|
+
} from "./constants.js";
|
|
20
|
+
import { encodeFooter, encodeHeader, zeroingHashes, type YOMHeader } from "./header.js";
|
|
21
|
+
import { sha256 } from "./hash.js";
|
|
22
|
+
import { zstdCompress } from "./zstd.js";
|
|
23
|
+
import { concatBytes } from "./bytes.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Emits a YOM v1 packfile: `[4096-byte header][uncompressed MainIndex
|
|
27
|
+
* FlatBuffer][blob area, each blob aligned per its BlobRef.alignment,
|
|
28
|
+
* offsets relative to file start][footer mirror]`. Ports the Swift
|
|
29
|
+
* implementation in `swift/Sources/YOMFormat/YOMPackfileWriter.swift` +
|
|
30
|
+
* `YOMPackfileWriter+Index.swift` field-for-field.
|
|
31
|
+
*
|
|
32
|
+
* Compression is not caller-selectable: every blob's declared `compression`
|
|
33
|
+
* must equal `requiredCompression(kind)` exactly, checked before any bytes
|
|
34
|
+
* are written. Hashes are always computed over each blob's original
|
|
35
|
+
* (pre-compression) bytes.
|
|
36
|
+
*/
|
|
37
|
+
export interface BlobInput {
|
|
38
|
+
kind: BlobKind;
|
|
39
|
+
data: Uint8Array;
|
|
40
|
+
compression: Compression;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PageInput {
|
|
44
|
+
pageLabel: string;
|
|
45
|
+
pixelWidth: number;
|
|
46
|
+
pixelHeight: number;
|
|
47
|
+
dpi: number;
|
|
48
|
+
colorSpace: string;
|
|
49
|
+
colorProfileRef: string;
|
|
50
|
+
mediaBox: number[];
|
|
51
|
+
cropBox: number[];
|
|
52
|
+
contentBox: number[];
|
|
53
|
+
rotation: number;
|
|
54
|
+
readingDirection: ReadingDirection;
|
|
55
|
+
/** Must have `kind === BlobKind.PageImage`. */
|
|
56
|
+
pageImage: BlobInput;
|
|
57
|
+
/** Must have `kind === BlobKind.ThumbnailImage`. */
|
|
58
|
+
thumbnail: BlobInput;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BookInput {
|
|
62
|
+
title: string;
|
|
63
|
+
readingDirection: ReadingDirection;
|
|
64
|
+
createdAtUnix: bigint;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SearchTermInput {
|
|
68
|
+
term: string;
|
|
69
|
+
dictionaryOffset: bigint;
|
|
70
|
+
dictionaryLength: bigint;
|
|
71
|
+
postingsOffset: bigint;
|
|
72
|
+
postingsLength: bigint;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OutlineInput {
|
|
76
|
+
title: string;
|
|
77
|
+
pageId: number;
|
|
78
|
+
children: OutlineInput[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PDFExportInput {
|
|
82
|
+
pageLabels: string[];
|
|
83
|
+
outline: OutlineInput[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WriterInput {
|
|
87
|
+
book: BookInput;
|
|
88
|
+
pages: PageInput[];
|
|
89
|
+
/** Must have `kind === BlobKind.OCRLayout` when present. */
|
|
90
|
+
ocrLayout?: BlobInput;
|
|
91
|
+
/** Must have `kind === BlobKind.NormalizedText` when present. */
|
|
92
|
+
normalizedText?: BlobInput;
|
|
93
|
+
/** Must have `kind === BlobKind.SearchDictionary` when present. */
|
|
94
|
+
searchDictionary?: BlobInput;
|
|
95
|
+
/** Must have `kind === BlobKind.SearchPostings` when present. */
|
|
96
|
+
searchPostings?: BlobInput;
|
|
97
|
+
searchTerms: SearchTermInput[];
|
|
98
|
+
pdfExport?: PDFExportInput;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class YOMWriterError extends Error {}
|
|
102
|
+
|
|
103
|
+
/** A blob after policy validation and (if required) compression. */
|
|
104
|
+
interface BlobPlan {
|
|
105
|
+
kind: BlobKind;
|
|
106
|
+
compression: Compression;
|
|
107
|
+
alignment: number;
|
|
108
|
+
storedBytes: Uint8Array;
|
|
109
|
+
uncompressedLength: bigint;
|
|
110
|
+
hash: Uint8Array;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function planBlob(input: BlobInput): Promise<BlobPlan> {
|
|
114
|
+
const alignment = requiredAlignment(input.kind);
|
|
115
|
+
const uncompressedLength = BigInt(input.data.length);
|
|
116
|
+
const hash = await sha256(input.data);
|
|
117
|
+
const storedBytes = input.compression === Compression.None ? input.data : await zstdCompress(input.data);
|
|
118
|
+
return { kind: input.kind, compression: input.compression, alignment, storedBytes, uncompressedLength, hash };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validatePolicy(blob: BlobInput, expectedKind: BlobKind): void {
|
|
122
|
+
if (blob.kind !== expectedKind) {
|
|
123
|
+
throw new YOMWriterError(`blob kind mismatch: expected ${expectedKind}, got ${blob.kind}`);
|
|
124
|
+
}
|
|
125
|
+
const required = requiredCompression(blob.kind);
|
|
126
|
+
if (blob.compression !== required) {
|
|
127
|
+
throw new YOMWriterError(
|
|
128
|
+
`compression policy violation for kind ${blob.kind}: declared ${blob.compression}, required ${required}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function writeYOM(input: WriterInput): Promise<Uint8Array> {
|
|
134
|
+
if (input.pages.length === 0) throw new YOMWriterError("YOM package must have at least one page");
|
|
135
|
+
|
|
136
|
+
const blobPlans: BlobPlan[] = [];
|
|
137
|
+
const pageBlobIndices: Array<{ image: number; thumbnail: number }> = [];
|
|
138
|
+
|
|
139
|
+
for (const page of input.pages) {
|
|
140
|
+
validatePolicy(page.pageImage, BlobKind.PageImage);
|
|
141
|
+
validatePolicy(page.thumbnail, BlobKind.ThumbnailImage);
|
|
142
|
+
const imageIndex = blobPlans.length;
|
|
143
|
+
blobPlans.push(await planBlob(page.pageImage));
|
|
144
|
+
const thumbnailIndex = blobPlans.length;
|
|
145
|
+
blobPlans.push(await planBlob(page.thumbnail));
|
|
146
|
+
pageBlobIndices.push({ image: imageIndex, thumbnail: thumbnailIndex });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let ocrIndex = -1;
|
|
150
|
+
if (input.ocrLayout) {
|
|
151
|
+
validatePolicy(input.ocrLayout, BlobKind.OCRLayout);
|
|
152
|
+
ocrIndex = blobPlans.length;
|
|
153
|
+
blobPlans.push(await planBlob(input.ocrLayout));
|
|
154
|
+
}
|
|
155
|
+
let normalizedTextIndex = -1;
|
|
156
|
+
if (input.normalizedText) {
|
|
157
|
+
validatePolicy(input.normalizedText, BlobKind.NormalizedText);
|
|
158
|
+
normalizedTextIndex = blobPlans.length;
|
|
159
|
+
blobPlans.push(await planBlob(input.normalizedText));
|
|
160
|
+
}
|
|
161
|
+
let searchDictionaryIndex = -1;
|
|
162
|
+
if (input.searchDictionary) {
|
|
163
|
+
validatePolicy(input.searchDictionary, BlobKind.SearchDictionary);
|
|
164
|
+
searchDictionaryIndex = blobPlans.length;
|
|
165
|
+
blobPlans.push(await planBlob(input.searchDictionary));
|
|
166
|
+
}
|
|
167
|
+
let searchPostingsIndex = -1;
|
|
168
|
+
if (input.searchPostings) {
|
|
169
|
+
validatePolicy(input.searchPostings, BlobKind.SearchPostings);
|
|
170
|
+
searchPostingsIndex = blobPlans.length;
|
|
171
|
+
blobPlans.push(await planBlob(input.searchPostings));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// FlatBuffers scalar fields are fixed-width, so the encoded index size does
|
|
175
|
+
// not depend on the actual blob offset *values* -- only on how many
|
|
176
|
+
// blobs/pages/strings/etc. exist. Build once with placeholder offsets to
|
|
177
|
+
// learn the index size, then again with the real offsets.
|
|
178
|
+
const placeholderOffsets = blobPlans.map(() => 0n);
|
|
179
|
+
const dryRunIndex = buildIndexBytes(
|
|
180
|
+
input,
|
|
181
|
+
blobPlans,
|
|
182
|
+
placeholderOffsets,
|
|
183
|
+
pageBlobIndices,
|
|
184
|
+
ocrIndex,
|
|
185
|
+
normalizedTextIndex,
|
|
186
|
+
searchDictionaryIndex,
|
|
187
|
+
searchPostingsIndex,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const blobAreaStart = BigInt(YOM_HEADER_SIZE) + BigInt(dryRunIndex.length);
|
|
191
|
+
const blobOffsets = computeBlobOffsets(blobPlans, blobAreaStart);
|
|
192
|
+
|
|
193
|
+
const indexBytes = buildIndexBytes(
|
|
194
|
+
input,
|
|
195
|
+
blobPlans,
|
|
196
|
+
blobOffsets,
|
|
197
|
+
pageBlobIndices,
|
|
198
|
+
ocrIndex,
|
|
199
|
+
normalizedTextIndex,
|
|
200
|
+
searchDictionaryIndex,
|
|
201
|
+
searchPostingsIndex,
|
|
202
|
+
);
|
|
203
|
+
if (indexBytes.length !== dryRunIndex.length) {
|
|
204
|
+
throw new YOMWriterError("YOM index size must not depend on blob offset values");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const bodyChunks: Uint8Array[] = [];
|
|
208
|
+
let cursor = blobAreaStart;
|
|
209
|
+
for (let i = 0; i < blobPlans.length; i++) {
|
|
210
|
+
const plan = blobPlans[i];
|
|
211
|
+
const offset = blobOffsets[i];
|
|
212
|
+
if (offset < cursor) throw new YOMWriterError("blob offsets must be monotonically increasing");
|
|
213
|
+
if (offset > cursor) {
|
|
214
|
+
bodyChunks.push(new Uint8Array(Number(offset - cursor)));
|
|
215
|
+
}
|
|
216
|
+
bodyChunks.push(plan.storedBytes);
|
|
217
|
+
cursor = offset + BigInt(plan.storedBytes.length);
|
|
218
|
+
}
|
|
219
|
+
const body = concatBytes(bodyChunks);
|
|
220
|
+
|
|
221
|
+
const indexHash = await sha256(indexBytes);
|
|
222
|
+
const header: YOMHeader = {
|
|
223
|
+
magic: YOM_MAGIC,
|
|
224
|
+
formatVersion: YOM_FORMAT_VERSION,
|
|
225
|
+
indexOffset: BigInt(YOM_HEADER_SIZE),
|
|
226
|
+
indexCompressedLength: BigInt(indexBytes.length),
|
|
227
|
+
indexUncompressedLength: BigInt(indexBytes.length),
|
|
228
|
+
indexHash,
|
|
229
|
+
fileHash: new Uint8Array(YOM_HASH_LENGTH),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// File hash is computed with both hash fields zeroed (see header.ts doc
|
|
233
|
+
// comment), then patched into the real header/footer afterwards.
|
|
234
|
+
const hashingHeader = zeroingHashes(header);
|
|
235
|
+
const fileForHashing = concatBytes([encodeHeader(hashingHeader), indexBytes, body, encodeFooter(hashingHeader)]);
|
|
236
|
+
const fileHash = await sha256(fileForHashing);
|
|
237
|
+
|
|
238
|
+
const finalHeader: YOMHeader = { ...header, fileHash };
|
|
239
|
+
return concatBytes([encodeHeader(finalHeader), indexBytes, body, encodeFooter(finalHeader)]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function computeBlobOffsets(blobPlans: BlobPlan[], blobAreaStart: bigint): bigint[] {
|
|
243
|
+
const offsets: bigint[] = [];
|
|
244
|
+
let cursor = blobAreaStart;
|
|
245
|
+
for (const plan of blobPlans) {
|
|
246
|
+
const alignment = BigInt(Math.max(plan.alignment, 1));
|
|
247
|
+
const remainder = cursor % alignment;
|
|
248
|
+
if (remainder !== 0n) cursor += alignment - remainder;
|
|
249
|
+
offsets.push(cursor);
|
|
250
|
+
cursor += BigInt(plan.storedBytes.length);
|
|
251
|
+
}
|
|
252
|
+
return offsets;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildIndexBytes(
|
|
256
|
+
input: WriterInput,
|
|
257
|
+
blobPlans: BlobPlan[],
|
|
258
|
+
blobOffsets: bigint[],
|
|
259
|
+
pageBlobIndices: Array<{ image: number; thumbnail: number }>,
|
|
260
|
+
ocrIndex: number,
|
|
261
|
+
normalizedTextIndex: number,
|
|
262
|
+
searchDictionaryIndex: number,
|
|
263
|
+
searchPostingsIndex: number,
|
|
264
|
+
): Uint8Array {
|
|
265
|
+
const builder = new Builder(1024);
|
|
266
|
+
// Without this, the builder omits any scalar field whose value equals its
|
|
267
|
+
// schema default (e.g. a placeholder blob offset of 0), which would make
|
|
268
|
+
// the dry-run index a different size than the real index -- breaking the
|
|
269
|
+
// "index size does not depend on blob offset values" invariant above.
|
|
270
|
+
builder.forceDefaults(true);
|
|
271
|
+
|
|
272
|
+
const pages = input.pages.map((page, index) => {
|
|
273
|
+
const { image: imageIndex, thumbnail: thumbnailIndex } = pageBlobIndices[index];
|
|
274
|
+
return new PageEntryT(
|
|
275
|
+
index,
|
|
276
|
+
page.pageLabel,
|
|
277
|
+
page.pixelWidth,
|
|
278
|
+
page.pixelHeight,
|
|
279
|
+
page.dpi,
|
|
280
|
+
page.colorSpace,
|
|
281
|
+
page.colorProfileRef,
|
|
282
|
+
page.mediaBox,
|
|
283
|
+
page.cropBox,
|
|
284
|
+
page.contentBox,
|
|
285
|
+
page.rotation,
|
|
286
|
+
page.readingDirection,
|
|
287
|
+
imageIndex,
|
|
288
|
+
thumbnailIndex,
|
|
289
|
+
Array.from(blobPlans[imageIndex].hash),
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const blobs = blobPlans.map(
|
|
294
|
+
(plan, i) =>
|
|
295
|
+
new BlobRefT(
|
|
296
|
+
plan.kind,
|
|
297
|
+
blobOffsets[i],
|
|
298
|
+
BigInt(plan.storedBytes.length),
|
|
299
|
+
plan.uncompressedLength,
|
|
300
|
+
plan.compression,
|
|
301
|
+
plan.alignment,
|
|
302
|
+
Array.from(plan.hash),
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const searchTerms = input.searchTerms.map(
|
|
307
|
+
(term) =>
|
|
308
|
+
new SearchTermRefT(term.term, term.dictionaryOffset, term.dictionaryLength, term.postingsOffset, term.postingsLength),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const pdfExport = input.pdfExport
|
|
312
|
+
? new PDFExportMetadataT(input.pdfExport.pageLabels, buildOutline(input.pdfExport.outline))
|
|
313
|
+
: null;
|
|
314
|
+
|
|
315
|
+
const book = new BookMetadataT(input.book.title, input.pages.length, input.book.readingDirection, input.book.createdAtUnix);
|
|
316
|
+
|
|
317
|
+
const mainIndex = new MainIndexT(
|
|
318
|
+
YOM_FORMAT_VERSION,
|
|
319
|
+
book,
|
|
320
|
+
pages,
|
|
321
|
+
blobs,
|
|
322
|
+
ocrIndex,
|
|
323
|
+
normalizedTextIndex,
|
|
324
|
+
searchDictionaryIndex,
|
|
325
|
+
searchPostingsIndex,
|
|
326
|
+
searchTerms,
|
|
327
|
+
pdfExport,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const offset = mainIndex.pack(builder);
|
|
331
|
+
builder.finish(offset);
|
|
332
|
+
return builder.asUint8Array();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildOutline(entries: OutlineInput[]): OutlineEntryT[] {
|
|
336
|
+
return entries.map((entry) => new OutlineEntryT(entry.title, entry.pageId, buildOutline(entry.children)));
|
|
337
|
+
}
|