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.
Files changed (199) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/cli.d.ts +30 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +344 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/index.d.ts +36 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +25 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/pipeline/avif.d.ts +3 -0
  12. package/dist/pipeline/avif.d.ts.map +1 -0
  13. package/dist/pipeline/avif.js +30 -0
  14. package/dist/pipeline/avif.js.map +1 -0
  15. package/dist/pipeline/convert.d.ts +28 -0
  16. package/dist/pipeline/convert.d.ts.map +1 -0
  17. package/dist/pipeline/convert.js +111 -0
  18. package/dist/pipeline/convert.js.map +1 -0
  19. package/dist/pipeline/index.d.ts +17 -0
  20. package/dist/pipeline/index.d.ts.map +1 -0
  21. package/dist/pipeline/index.js +17 -0
  22. package/dist/pipeline/index.js.map +1 -0
  23. package/dist/pipeline/ocrLayoutBuilder.d.ts +10 -0
  24. package/dist/pipeline/ocrLayoutBuilder.d.ts.map +1 -0
  25. package/dist/pipeline/ocrLayoutBuilder.js +62 -0
  26. package/dist/pipeline/ocrLayoutBuilder.js.map +1 -0
  27. package/dist/pipeline/pdfDocument.d.ts +12 -0
  28. package/dist/pipeline/pdfDocument.d.ts.map +1 -0
  29. package/dist/pipeline/pdfDocument.js +76 -0
  30. package/dist/pipeline/pdfDocument.js.map +1 -0
  31. package/dist/pipeline/renderPage.d.ts +10 -0
  32. package/dist/pipeline/renderPage.d.ts.map +1 -0
  33. package/dist/pipeline/renderPage.js +36 -0
  34. package/dist/pipeline/renderPage.js.map +1 -0
  35. package/dist/pipeline/textExtract.d.ts +29 -0
  36. package/dist/pipeline/textExtract.d.ts.map +1 -0
  37. package/dist/pipeline/textExtract.js +48 -0
  38. package/dist/pipeline/textExtract.js.map +1 -0
  39. package/dist/yom/blobLimits.d.ts +15 -0
  40. package/dist/yom/blobLimits.d.ts.map +1 -0
  41. package/dist/yom/blobLimits.js +25 -0
  42. package/dist/yom/blobLimits.js.map +1 -0
  43. package/dist/yom/byteSource.d.ts +56 -0
  44. package/dist/yom/byteSource.d.ts.map +1 -0
  45. package/dist/yom/byteSource.js +111 -0
  46. package/dist/yom/byteSource.js.map +1 -0
  47. package/dist/yom/bytes.d.ts +2 -0
  48. package/dist/yom/bytes.d.ts.map +1 -0
  49. package/dist/yom/bytes.js +13 -0
  50. package/dist/yom/bytes.js.map +1 -0
  51. package/dist/yom/constants.d.ts +34 -0
  52. package/dist/yom/constants.d.ts.map +1 -0
  53. package/dist/yom/constants.js +82 -0
  54. package/dist/yom/constants.js.map +1 -0
  55. package/dist/yom/errors.d.ts +44 -0
  56. package/dist/yom/errors.d.ts.map +1 -0
  57. package/dist/yom/errors.js +44 -0
  58. package/dist/yom/errors.js.map +1 -0
  59. package/dist/yom/flatbufferVerify.d.ts +21 -0
  60. package/dist/yom/flatbufferVerify.d.ts.map +1 -0
  61. package/dist/yom/flatbufferVerify.js +255 -0
  62. package/dist/yom/flatbufferVerify.js.map +1 -0
  63. package/dist/yom/generated/yomu/yom/blob-kind.d.ts +9 -0
  64. package/dist/yom/generated/yomu/yom/blob-kind.d.ts.map +1 -0
  65. package/dist/yom/generated/yomu/yom/blob-kind.js +12 -0
  66. package/dist/yom/generated/yomu/yom/blob-kind.js.map +1 -0
  67. package/dist/yom/generated/yomu/yom/blob-ref.d.ts +45 -0
  68. package/dist/yom/generated/yomu/yom/blob-ref.d.ts.map +1 -0
  69. package/dist/yom/generated/yomu/yom/blob-ref.js +141 -0
  70. package/dist/yom/generated/yomu/yom/blob-ref.js.map +1 -0
  71. package/dist/yom/generated/yomu/yom/book-metadata.d.ts +32 -0
  72. package/dist/yom/generated/yomu/yom/book-metadata.d.ts.map +1 -0
  73. package/dist/yom/generated/yomu/yom/book-metadata.js +89 -0
  74. package/dist/yom/generated/yomu/yom/book-metadata.js.map +1 -0
  75. package/dist/yom/generated/yomu/yom/compression.d.ts +5 -0
  76. package/dist/yom/generated/yomu/yom/compression.d.ts.map +1 -0
  77. package/dist/yom/generated/yomu/yom/compression.js +8 -0
  78. package/dist/yom/generated/yomu/yom/compression.js.map +1 -0
  79. package/dist/yom/generated/yomu/yom/main-index.d.ts +63 -0
  80. package/dist/yom/generated/yomu/yom/main-index.d.ts.map +1 -0
  81. package/dist/yom/generated/yomu/yom/main-index.js +208 -0
  82. package/dist/yom/generated/yomu/yom/main-index.js.map +1 -0
  83. package/dist/yom/generated/yomu/yom/ocrblock.d.ts +32 -0
  84. package/dist/yom/generated/yomu/yom/ocrblock.d.ts.map +1 -0
  85. package/dist/yom/generated/yomu/yom/ocrblock.js +84 -0
  86. package/dist/yom/generated/yomu/yom/ocrblock.js.map +1 -0
  87. package/dist/yom/generated/yomu/yom/ocrlayout.d.ts +27 -0
  88. package/dist/yom/generated/yomu/yom/ocrlayout.d.ts.map +1 -0
  89. package/dist/yom/generated/yomu/yom/ocrlayout.js +76 -0
  90. package/dist/yom/generated/yomu/yom/ocrlayout.js.map +1 -0
  91. package/dist/yom/generated/yomu/yom/ocrline.d.ts +35 -0
  92. package/dist/yom/generated/yomu/yom/ocrline.d.ts.map +1 -0
  93. package/dist/yom/generated/yomu/yom/ocrline.js +95 -0
  94. package/dist/yom/generated/yomu/yom/ocrline.js.map +1 -0
  95. package/dist/yom/generated/yomu/yom/ocrpage.d.ts +46 -0
  96. package/dist/yom/generated/yomu/yom/ocrpage.d.ts.map +1 -0
  97. package/dist/yom/generated/yomu/yom/ocrpage.js +147 -0
  98. package/dist/yom/generated/yomu/yom/ocrpage.js.map +1 -0
  99. package/dist/yom/generated/yomu/yom/ocrtoken.d.ts +44 -0
  100. package/dist/yom/generated/yomu/yom/ocrtoken.d.ts.map +1 -0
  101. package/dist/yom/generated/yomu/yom/ocrtoken.js +128 -0
  102. package/dist/yom/generated/yomu/yom/ocrtoken.js.map +1 -0
  103. package/dist/yom/generated/yomu/yom/outline-entry.d.ts +31 -0
  104. package/dist/yom/generated/yomu/yom/outline-entry.d.ts.map +1 -0
  105. package/dist/yom/generated/yomu/yom/outline-entry.js +92 -0
  106. package/dist/yom/generated/yomu/yom/outline-entry.js.map +1 -0
  107. package/dist/yom/generated/yomu/yom/page-entry.d.ts +95 -0
  108. package/dist/yom/generated/yomu/yom/page-entry.d.ts.map +1 -0
  109. package/dist/yom/generated/yomu/yom/page-entry.js +288 -0
  110. package/dist/yom/generated/yomu/yom/page-entry.js.map +1 -0
  111. package/dist/yom/generated/yomu/yom/pdfexport-metadata.d.ts +32 -0
  112. package/dist/yom/generated/yomu/yom/pdfexport-metadata.d.ts.map +1 -0
  113. package/dist/yom/generated/yomu/yom/pdfexport-metadata.js +96 -0
  114. package/dist/yom/generated/yomu/yom/pdfexport-metadata.js.map +1 -0
  115. package/dist/yom/generated/yomu/yom/reading-direction.d.ts +5 -0
  116. package/dist/yom/generated/yomu/yom/reading-direction.d.ts.map +1 -0
  117. package/dist/yom/generated/yomu/yom/reading-direction.js +8 -0
  118. package/dist/yom/generated/yomu/yom/reading-direction.js.map +1 -0
  119. package/dist/yom/generated/yomu/yom/search-term-ref.d.ts +34 -0
  120. package/dist/yom/generated/yomu/yom/search-term-ref.d.ts.map +1 -0
  121. package/dist/yom/generated/yomu/yom/search-term-ref.js +99 -0
  122. package/dist/yom/generated/yomu/yom/search-term-ref.js.map +1 -0
  123. package/dist/yom/generated/yomu/yom.d.ts +6 -0
  124. package/dist/yom/generated/yomu/yom.d.ts.map +1 -0
  125. package/dist/yom/generated/yomu/yom.js +8 -0
  126. package/dist/yom/generated/yomu/yom.js.map +1 -0
  127. package/dist/yom/hash.d.ts +4 -0
  128. package/dist/yom/hash.d.ts.map +1 -0
  129. package/dist/yom/hash.js +21 -0
  130. package/dist/yom/hash.js.map +1 -0
  131. package/dist/yom/header.d.ts +44 -0
  132. package/dist/yom/header.d.ts.map +1 -0
  133. package/dist/yom/header.js +115 -0
  134. package/dist/yom/header.js.map +1 -0
  135. package/dist/yom/reader.d.ts +57 -0
  136. package/dist/yom/reader.d.ts.map +1 -0
  137. package/dist/yom/reader.js +141 -0
  138. package/dist/yom/reader.js.map +1 -0
  139. package/dist/yom/searchIndex.d.ts +41 -0
  140. package/dist/yom/searchIndex.d.ts.map +1 -0
  141. package/dist/yom/searchIndex.js +133 -0
  142. package/dist/yom/searchIndex.js.map +1 -0
  143. package/dist/yom/textNormalizer.d.ts +19 -0
  144. package/dist/yom/textNormalizer.d.ts.map +1 -0
  145. package/dist/yom/textNormalizer.js +77 -0
  146. package/dist/yom/textNormalizer.js.map +1 -0
  147. package/dist/yom/validator.d.ts +14 -0
  148. package/dist/yom/validator.d.ts.map +1 -0
  149. package/dist/yom/validator.js +197 -0
  150. package/dist/yom/validator.js.map +1 -0
  151. package/dist/yom/writer.d.ts +76 -0
  152. package/dist/yom/writer.d.ts.map +1 -0
  153. package/dist/yom/writer.js +154 -0
  154. package/dist/yom/writer.js.map +1 -0
  155. package/dist/yom/zstd.d.ts +14 -0
  156. package/dist/yom/zstd.d.ts.map +1 -0
  157. package/dist/yom/zstd.js +43 -0
  158. package/dist/yom/zstd.js.map +1 -0
  159. package/package.json +65 -0
  160. package/src/cli.ts +372 -0
  161. package/src/index.ts +109 -0
  162. package/src/pipeline/avif.ts +33 -0
  163. package/src/pipeline/convert.ts +148 -0
  164. package/src/pipeline/index.ts +28 -0
  165. package/src/pipeline/ocrLayoutBuilder.ts +73 -0
  166. package/src/pipeline/pdfDocument.ts +86 -0
  167. package/src/pipeline/renderPage.ts +48 -0
  168. package/src/pipeline/textExtract.ts +64 -0
  169. package/src/worker/convert.worker.ts +54 -0
  170. package/src/yom/blobLimits.ts +30 -0
  171. package/src/yom/byteSource.ts +144 -0
  172. package/src/yom/bytes.ts +11 -0
  173. package/src/yom/constants.ts +89 -0
  174. package/src/yom/errors.ts +43 -0
  175. package/src/yom/flatbufferVerify.ts +298 -0
  176. package/src/yom/generated/yomu/yom/blob-kind.ts +12 -0
  177. package/src/yom/generated/yomu/yom/blob-ref.ts +184 -0
  178. package/src/yom/generated/yomu/yom/book-metadata.ts +121 -0
  179. package/src/yom/generated/yomu/yom/compression.ts +8 -0
  180. package/src/yom/generated/yomu/yom/main-index.ts +257 -0
  181. package/src/yom/generated/yomu/yom/ocrblock.ts +117 -0
  182. package/src/yom/generated/yomu/yom/ocrlayout.ts +102 -0
  183. package/src/yom/generated/yomu/yom/ocrline.ts +131 -0
  184. package/src/yom/generated/yomu/yom/ocrpage.ts +191 -0
  185. package/src/yom/generated/yomu/yom/ocrtoken.ts +173 -0
  186. package/src/yom/generated/yomu/yom/outline-entry.ts +124 -0
  187. package/src/yom/generated/yomu/yom/page-entry.ts +388 -0
  188. package/src/yom/generated/yomu/yom/pdfexport-metadata.ts +128 -0
  189. package/src/yom/generated/yomu/yom/reading-direction.ts +8 -0
  190. package/src/yom/generated/yomu/yom/search-term-ref.ts +134 -0
  191. package/src/yom/generated/yomu/yom.ts +9 -0
  192. package/src/yom/hash.ts +20 -0
  193. package/src/yom/header.ts +132 -0
  194. package/src/yom/reader.ts +163 -0
  195. package/src/yom/searchIndex.ts +178 -0
  196. package/src/yom/textNormalizer.ts +92 -0
  197. package/src/yom/validator.ts +245 -0
  198. package/src/yom/writer.ts +337 -0
  199. 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
+ }