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,144 @@
1
+ import { RangeNotSupportedError, YOMError } from "./errors.js";
2
+
3
+ /**
4
+ * Gap 1: the byte-source abstraction every reader read goes through, so the
5
+ * same `YOMPackfileReader` can serve a fully in-memory `.yom` or one streamed
6
+ * over HTTP `Range` requests without ever loading the whole file.
7
+ */
8
+ export interface ByteSource {
9
+ /** Total file length in bytes. */
10
+ readonly size: number;
11
+ /** Reads exactly `[offset, offset+length)`; rejects if it can't. */
12
+ read(offset: number, length: number): Promise<Uint8Array>;
13
+ }
14
+
15
+ /** Raised when a `ByteSource.read` cannot return the requested extent. */
16
+ export class ByteSourceError extends YOMError {}
17
+
18
+ /** A `ByteSource` over an already-resident `Uint8Array` (zero-copy slices). */
19
+ export class MemoryByteSource implements ByteSource {
20
+ readonly size: number;
21
+ private readonly bytes: Uint8Array;
22
+
23
+ constructor(bytes: Uint8Array) {
24
+ this.bytes = bytes;
25
+ this.size = bytes.length;
26
+ }
27
+
28
+ read(offset: number, length: number): Promise<Uint8Array> {
29
+ if (offset < 0 || length < 0 || offset + length > this.bytes.length) {
30
+ return Promise.reject(new ByteSourceError(`read [${offset}, ${offset + length}) out of bounds (size ${this.size})`));
31
+ }
32
+ return Promise.resolve(this.bytes.subarray(offset, offset + length));
33
+ }
34
+ }
35
+
36
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
37
+
38
+ export interface HttpRangeOptions {
39
+ /** Override the global `fetch` (for tests / custom transports). */
40
+ fetch?: FetchLike;
41
+ /** Extra request headers (auth, etc.). */
42
+ headers?: Record<string, string>;
43
+ /** Skip the size probe if the caller already knows `Content-Length`. */
44
+ size?: number;
45
+ /** Abort in-flight requests. */
46
+ signal?: AbortSignal;
47
+ }
48
+
49
+ /**
50
+ * A `ByteSource` backed by HTTP `Range` requests. `create()` learns the total
51
+ * size and confirms range support up front (one `Range: bytes=0-` probe, or a
52
+ * caller-supplied `size`); each `read()` issues `GET Range: bytes=a-b`,
53
+ * validates the `206` + `Content-Range`/length, and returns the slice.
54
+ *
55
+ * The server must answer `206 Partial Content` to ranged requests; a plain
56
+ * `200 OK` means ranges are unsupported and `create()` throws
57
+ * `RangeNotSupportedError`. For cross-origin use the server must expose
58
+ * `Content-Range`, `Accept-Ranges`, and `Content-Length` via CORS.
59
+ */
60
+ export class HttpRangeByteSource implements ByteSource {
61
+ readonly size: number;
62
+ private readonly url: string;
63
+ private readonly fetchFn: FetchLike;
64
+ private readonly headers: Record<string, string>;
65
+ private readonly signal?: AbortSignal;
66
+
67
+ private constructor(url: string, size: number, opts: HttpRangeOptions) {
68
+ this.url = url;
69
+ this.size = size;
70
+ this.fetchFn = opts.fetch ?? ((input, init) => fetch(input, init));
71
+ this.headers = opts.headers ?? {};
72
+ this.signal = opts.signal;
73
+ }
74
+
75
+ static async create(url: string, opts: HttpRangeOptions = {}): Promise<HttpRangeByteSource> {
76
+ const fetchFn: FetchLike = opts.fetch ?? ((input, init) => fetch(input, init));
77
+ const headers = opts.headers ?? {};
78
+
79
+ if (typeof opts.size === "number") {
80
+ return new HttpRangeByteSource(url, opts.size, opts);
81
+ }
82
+
83
+ // Probe with an open-ended range: a range-capable server answers 206 with
84
+ // a `Content-Range: bytes 0-.../TOTAL` from which we learn the size.
85
+ const res = await fetchFn(url, {
86
+ method: "GET",
87
+ headers: { ...headers, Range: "bytes=0-0" },
88
+ signal: opts.signal,
89
+ });
90
+ if (res.status === 200) {
91
+ // Server ignored the range and sent the whole body.
92
+ throw new RangeNotSupportedError(`server does not support range requests (status 200) for ${url}`);
93
+ }
94
+ if (res.status !== 206) {
95
+ throw new ByteSourceError(`unexpected status ${res.status} probing ${url}`);
96
+ }
97
+ const total = parseContentRangeTotal(res.headers.get("Content-Range"));
98
+ if (total === null) {
99
+ const len = res.headers.get("Content-Length");
100
+ throw new ByteSourceError(
101
+ `range probe missing Content-Range total for ${url} (Content-Length ${len ?? "?"})`,
102
+ );
103
+ }
104
+ // Drain the tiny probe body to release the connection.
105
+ await res.arrayBuffer().catch(() => undefined);
106
+ return new HttpRangeByteSource(url, total, opts);
107
+ }
108
+
109
+ async read(offset: number, length: number): Promise<Uint8Array> {
110
+ if (offset < 0 || length < 0 || offset + length > this.size) {
111
+ throw new ByteSourceError(`read [${offset}, ${offset + length}) out of bounds (size ${this.size})`);
112
+ }
113
+ if (length === 0) return new Uint8Array(0);
114
+
115
+ const end = offset + length - 1; // HTTP ranges are inclusive
116
+ const res = await this.fetchFn(this.url, {
117
+ method: "GET",
118
+ headers: { ...this.headers, Range: `bytes=${offset}-${end}` },
119
+ signal: this.signal,
120
+ });
121
+ if (res.status === 200) {
122
+ throw new RangeNotSupportedError(`server ignored range request (status 200) for ${this.url}`);
123
+ }
124
+ if (res.status !== 206) {
125
+ throw new ByteSourceError(`unexpected status ${res.status} reading ${this.url}`);
126
+ }
127
+ const body = new Uint8Array(await res.arrayBuffer());
128
+ if (body.length !== length) {
129
+ throw new ByteSourceError(`range read returned ${body.length} bytes, expected ${length}`);
130
+ }
131
+ return body;
132
+ }
133
+ }
134
+
135
+ /** Parses the `TOTAL` out of a `Content-Range: bytes A-B/TOTAL` header. */
136
+ function parseContentRangeTotal(header: string | null): number | null {
137
+ if (!header) return null;
138
+ const slash = header.lastIndexOf("/");
139
+ if (slash < 0) return null;
140
+ const total = header.slice(slash + 1).trim();
141
+ if (total === "" || total === "*") return null;
142
+ const parsed = Number(total);
143
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
144
+ }
@@ -0,0 +1,11 @@
1
+ export function concatBytes(chunks: Uint8Array[]): Uint8Array {
2
+ let total = 0;
3
+ for (const chunk of chunks) total += chunk.length;
4
+ const out = new Uint8Array(total);
5
+ let offset = 0;
6
+ for (const chunk of chunks) {
7
+ out.set(chunk, offset);
8
+ offset += chunk.length;
9
+ }
10
+ return out;
11
+ }
@@ -0,0 +1,89 @@
1
+ import { BlobKind } from "./generated/yomu/yom/blob-kind.js";
2
+ import { Compression } from "./generated/yomu/yom/compression.js";
3
+
4
+ /**
5
+ * YOM v1 format constants shared by the writer, reader, and validator.
6
+ * Mirrors the Swift implementation in `swift/Sources/YOMFormat/YOMConstants.swift`
7
+ * field-for-field so Web- and native-generated `.yom` packages agree on
8
+ * layout. See docs/format-spec.md for rationale.
9
+ */
10
+ export const YOM_FILE_EXTENSION = "yom";
11
+ export const YOM_MIME_TYPE = "application/vnd.yomu.yom";
12
+ export const YOM_MAGIC = new Uint8Array([0x59, 0x4f, 0x4d, 0x31]); // "YOM1"
13
+ export const YOM_HEADER_SIZE = 4096;
14
+ export const YOM_FORMAT_VERSION = 1;
15
+ export const YOM_HASH_LENGTH = 32;
16
+ export const YOM_IMAGE_BLOB_ALIGNMENT = 16;
17
+ export const YOM_DEFAULT_BLOB_ALIGNMENT = 1;
18
+
19
+ export function requiredCompression(kind: BlobKind): Compression {
20
+ switch (kind) {
21
+ case BlobKind.PageImage:
22
+ case BlobKind.ThumbnailImage:
23
+ case BlobKind.SearchPostings:
24
+ return Compression.None;
25
+ case BlobKind.OCRLayout:
26
+ case BlobKind.NormalizedText:
27
+ case BlobKind.SearchDictionary:
28
+ return Compression.Zstd;
29
+ default:
30
+ throw new Error(`unknown blob kind: ${kind}`);
31
+ }
32
+ }
33
+
34
+ export function requiredAlignment(kind: BlobKind): number {
35
+ switch (kind) {
36
+ case BlobKind.PageImage:
37
+ case BlobKind.ThumbnailImage:
38
+ return YOM_IMAGE_BLOB_ALIGNMENT;
39
+ default:
40
+ return YOM_DEFAULT_BLOB_ALIGNMENT;
41
+ }
42
+ }
43
+
44
+ /*
45
+ * -------------------------------------------------------------------------
46
+ * Reader-side safety limits (Gap 2 hardening).
47
+ *
48
+ * IMPORTANT: these are **reader policy, NOT format requirements**. The writer
49
+ * MUST NOT enforce them and the on-disk format places no upper bound on blob
50
+ * sizes -- a conforming `.yom` may legally exceed any of these. They exist
51
+ * only so a *reader* can refuse to allocate unbounded memory for a malformed
52
+ * or hostile file (decompression bombs, forged declared sizes) before it
53
+ * touches the data. Values are chosen comfortably above every real-world and
54
+ * fixture blob size; raise them if a legitimate corpus needs more.
55
+ * -------------------------------------------------------------------------
56
+ */
57
+
58
+ /** Per-kind cap on a blob's declared `uncompressed_length`, in bytes. */
59
+ export const MAX_BLOB_UNCOMPRESSED_IMAGE = 64 * 1024 * 1024; // page/thumbnail AVIF
60
+ export const MAX_BLOB_UNCOMPRESSED_TEXT = 16 * 1024 * 1024; // OCR / normalized text / dictionary
61
+ export const MAX_BLOB_UNCOMPRESSED_POSTINGS = 64 * 1024 * 1024; // search postings scale with book size
62
+
63
+ /** Cap on the sum of every blob's declared `uncompressed_length` (whole-file validation). */
64
+ export const MAX_TOTAL_UNCOMPRESSED = 4 * 1024 * 1024 * 1024;
65
+
66
+ /**
67
+ * Reject a blob whose declared inflation ratio
68
+ * (`uncompressed_length / max(compressed_length, 1)`) exceeds this -- the
69
+ * classic zip/zstd-bomb signature. zstd rarely exceeds ~3-5x on scanned-book
70
+ * text/OCR, so 1000x is a generous safety margin.
71
+ */
72
+ export const MAX_INFLATION_RATIO = 1000;
73
+
74
+ /** The `uncompressed_length` cap that applies to a blob of the given kind. */
75
+ export function maxBlobUncompressed(kind: BlobKind): number {
76
+ switch (kind) {
77
+ case BlobKind.PageImage:
78
+ case BlobKind.ThumbnailImage:
79
+ return MAX_BLOB_UNCOMPRESSED_IMAGE;
80
+ case BlobKind.SearchPostings:
81
+ return MAX_BLOB_UNCOMPRESSED_POSTINGS;
82
+ case BlobKind.OCRLayout:
83
+ case BlobKind.NormalizedText:
84
+ case BlobKind.SearchDictionary:
85
+ return MAX_BLOB_UNCOMPRESSED_TEXT;
86
+ default:
87
+ return MAX_BLOB_UNCOMPRESSED_TEXT;
88
+ }
89
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed error hierarchy for the YOM reader/validator hardening layers.
3
+ *
4
+ * These are all *reader-side* failures raised while opening or reading a
5
+ * possibly-hostile `.yom`. They exist so callers can distinguish "the input
6
+ * is malformed/oversized/corrupt" from ordinary programming errors, and so
7
+ * the size-cap / FlatBuffers-verify / HTTP-Range layers throw a stable,
8
+ * catchable type instead of a bare `Error`.
9
+ */
10
+
11
+ /** Base class for every typed reader error added by the hardening layers. */
12
+ export class YOMError extends Error {}
13
+
14
+ /**
15
+ * A blob's declared `uncompressed_length` exceeds the reader's per-kind size
16
+ * cap, or its declared inflation ratio (`uncompressed / compressed`) exceeds
17
+ * `MAX_INFLATION_RATIO`. Raised *before* any decompression happens, so a
18
+ * decompression-bomb never gets to allocate memory.
19
+ */
20
+ export class BlobTooLargeError extends YOMError {}
21
+
22
+ /**
23
+ * A blob decompressed to a different byte length than its declared
24
+ * `uncompressed_length` (or decompression otherwise failed). Raised before
25
+ * the decompressed bytes are used, ahead of the final SHA-256 gate.
26
+ */
27
+ export class CorruptBlobError extends YOMError {}
28
+
29
+ /**
30
+ * The main FlatBuffers index (or an OCR layout blob) failed bounded
31
+ * structural verification: an offset, vtable, table, string, or vector would
32
+ * dereference outside the known index byte range, or a vector length is
33
+ * absurdly large. Raised instead of returning a booby-trapped `getRootAs*`
34
+ * root that could later read out of bounds.
35
+ */
36
+ export class MalformedIndexError extends YOMError {}
37
+
38
+ /**
39
+ * An HTTP source does not support range requests (it answered a ranged `GET`
40
+ * with `200 OK` / no `Accept-Ranges` instead of `206 Partial Content`).
41
+ * Callers may retry with `openURL(url, { fallbackToFullFetch: true })`.
42
+ */
43
+ export class RangeNotSupportedError extends YOMError {}
@@ -0,0 +1,298 @@
1
+ import { ByteBuffer } from "flatbuffers";
2
+ import { MainIndex } from "./generated/yomu/yom/main-index.js";
3
+ import { MalformedIndexError } from "./errors.js";
4
+
5
+ /**
6
+ * Gap 3: schema-aware bounded verification of the main FlatBuffers index
7
+ * before `getRootAsMainIndex` traversal.
8
+ *
9
+ * `flatc` emits no TypeScript verifier, so this is a hand-written structural
10
+ * walk (spec option B). Given the index occupies exactly `[0, indexLength)`,
11
+ * it bounds-checks every offset the reader could dereference -- the root table
12
+ * offset, each vtable, each field, each string, and each vector -- against
13
+ * that range. Critically, before trusting any vector it asserts
14
+ * `4 + count * elementStride <= remaining bytes`, so a forged huge vector
15
+ * count can never drive an out-of-bounds read or an O(count) allocation/hang.
16
+ *
17
+ * Two depths:
18
+ * - `deep: false` (reader open, hot path): verify the root table plus the
19
+ * header + length bounds of its own vectors/sub-object pointers. Cheap.
20
+ * - `deep: true` (`validateYOM`): additionally recurse into every page, blob,
21
+ * search term, and outline sub-table -- i.e. touch every field the reader
22
+ * or validator will ever touch.
23
+ *
24
+ * On any violation it throws `MalformedIndexError`; it never reads out of
25
+ * bounds, never allocates from an attacker-controlled length, and never hangs.
26
+ */
27
+
28
+ type FieldSpec =
29
+ | { readonly slot: number; readonly kind: "scalar"; readonly size: number }
30
+ | { readonly slot: number; readonly kind: "string" }
31
+ | { readonly slot: number; readonly kind: "table"; readonly table: string }
32
+ | { readonly slot: number; readonly kind: "vectorScalar"; readonly stride: number }
33
+ | { readonly slot: number; readonly kind: "vectorString" }
34
+ | { readonly slot: number; readonly kind: "vectorTable"; readonly table: string };
35
+
36
+ // vtable slot for field id N == 4 + N*2 (see the generated `__offset(_, slot)` calls).
37
+ const SCHEMAS: Record<string, readonly FieldSpec[]> = {
38
+ MainIndex: [
39
+ { slot: 4, kind: "scalar", size: 4 }, // formatVersion
40
+ { slot: 6, kind: "table", table: "BookMetadata" }, // book
41
+ { slot: 8, kind: "vectorTable", table: "PageEntry" }, // pages
42
+ { slot: 10, kind: "vectorTable", table: "BlobRef" }, // blobs
43
+ { slot: 12, kind: "scalar", size: 4 }, // ocrLayoutBlobIndex
44
+ { slot: 14, kind: "scalar", size: 4 }, // normalizedTextBlobIndex
45
+ { slot: 16, kind: "scalar", size: 4 }, // searchDictionaryBlobIndex
46
+ { slot: 18, kind: "scalar", size: 4 }, // searchPostingsBlobIndex
47
+ { slot: 20, kind: "vectorTable", table: "SearchTermRef" }, // searchTerms
48
+ // NOTE: `pdfExport` (slot 22) is intentionally NOT traversed. The TS
49
+ // reader/validator never dereference it, and the writer emits it via
50
+ // FlatBuffers `forceDefaults(true)`, which encodes an *absent* offset
51
+ // field as a present-but-sentinel value that points outside the buffer.
52
+ // Dereferencing it would falsely reject every writer-produced file. Its
53
+ // schema (below) is retained only for reference / future use.
54
+ ],
55
+ BookMetadata: [
56
+ { slot: 4, kind: "string" }, // title
57
+ { slot: 6, kind: "scalar", size: 4 }, // pageCount
58
+ { slot: 8, kind: "scalar", size: 1 }, // readingDirection
59
+ { slot: 10, kind: "scalar", size: 8 }, // createdAtUnix
60
+ ],
61
+ PageEntry: [
62
+ { slot: 4, kind: "scalar", size: 4 }, // pageId
63
+ { slot: 6, kind: "string" }, // pageLabel
64
+ { slot: 8, kind: "scalar", size: 4 }, // pixelWidth
65
+ { slot: 10, kind: "scalar", size: 4 }, // pixelHeight
66
+ { slot: 12, kind: "scalar", size: 4 }, // dpi
67
+ { slot: 14, kind: "string" }, // colorSpace
68
+ { slot: 16, kind: "string" }, // colorProfileRef
69
+ { slot: 18, kind: "vectorScalar", stride: 4 }, // mediaBox
70
+ { slot: 20, kind: "vectorScalar", stride: 4 }, // cropBox
71
+ { slot: 22, kind: "vectorScalar", stride: 4 }, // contentBox
72
+ { slot: 24, kind: "scalar", size: 4 }, // rotation
73
+ { slot: 26, kind: "scalar", size: 1 }, // readingDirection
74
+ { slot: 28, kind: "scalar", size: 4 }, // pageImageBlobIndex
75
+ { slot: 30, kind: "scalar", size: 4 }, // thumbnailBlobIndex
76
+ { slot: 32, kind: "vectorScalar", stride: 1 }, // imageHash
77
+ ],
78
+ BlobRef: [
79
+ { slot: 4, kind: "scalar", size: 1 }, // kind
80
+ { slot: 6, kind: "scalar", size: 8 }, // offset
81
+ { slot: 8, kind: "scalar", size: 8 }, // compressedLength
82
+ { slot: 10, kind: "scalar", size: 8 }, // uncompressedLength
83
+ { slot: 12, kind: "scalar", size: 1 }, // compression
84
+ { slot: 14, kind: "scalar", size: 4 }, // alignment
85
+ { slot: 16, kind: "vectorScalar", stride: 1 }, // hash
86
+ ],
87
+ SearchTermRef: [
88
+ { slot: 4, kind: "string" }, // term
89
+ { slot: 6, kind: "scalar", size: 8 }, // dictionaryOffset
90
+ { slot: 8, kind: "scalar", size: 8 }, // dictionaryLength
91
+ { slot: 10, kind: "scalar", size: 8 }, // postingsOffset
92
+ { slot: 12, kind: "scalar", size: 8 }, // postingsLength
93
+ ],
94
+ PDFExportMetadata: [
95
+ { slot: 4, kind: "vectorString" }, // pageLabels
96
+ { slot: 6, kind: "vectorTable", table: "OutlineEntry" }, // outline
97
+ ],
98
+ OutlineEntry: [
99
+ { slot: 4, kind: "string" }, // title
100
+ { slot: 6, kind: "scalar", size: 4 }, // pageId
101
+ { slot: 8, kind: "vectorTable", table: "OutlineEntry" }, // children (recursive)
102
+ ],
103
+ };
104
+
105
+ const MAX_DEPTH = 64;
106
+ const UOFFSET = 4;
107
+
108
+ class BoundsVerifier {
109
+ private readonly buf: Uint8Array;
110
+ private readonly len: number;
111
+
112
+ constructor(buf: Uint8Array) {
113
+ this.buf = buf;
114
+ this.len = buf.length;
115
+ }
116
+
117
+ private fail(reason: string): never {
118
+ throw new MalformedIndexError(`malformed index: ${reason}`);
119
+ }
120
+
121
+ /** Assert `[pos, pos+size)` lies inside the index. */
122
+ private require(pos: number, size: number, reason: string): void {
123
+ if (!Number.isInteger(pos) || pos < 0 || size < 0 || pos + size > this.len) {
124
+ this.fail(`${reason} (pos=${pos} size=${size} len=${this.len})`);
125
+ }
126
+ }
127
+
128
+ private i32(pos: number): number {
129
+ this.require(pos, 4, "read int32");
130
+ const b = this.buf;
131
+ return b[pos] | (b[pos + 1] << 8) | (b[pos + 2] << 16) | (b[pos + 3] << 24);
132
+ }
133
+
134
+ private u32(pos: number): number {
135
+ return this.i32(pos) >>> 0;
136
+ }
137
+
138
+ private u16(pos: number): number {
139
+ this.require(pos, 2, "read uint16");
140
+ return this.buf[pos] | (this.buf[pos + 1] << 8);
141
+ }
142
+
143
+ /**
144
+ * Bounds-verify a single optional field of the root table. Returns `false`
145
+ * when the field is genuinely absent OR encoded as a forced-default sentinel
146
+ * that points out of bounds (so a caller can safely decide not to read it);
147
+ * returns `true` only when the field is present and structurally sound.
148
+ */
149
+ verifyOptionalRootField(rootTable: string, field: FieldSpec, deep: boolean): boolean {
150
+ const tablePos = this.i32(0);
151
+ this.require(tablePos, 4, "root soffset");
152
+ const soffset = this.i32(tablePos);
153
+ const vtablePos = tablePos - soffset;
154
+ this.require(vtablePos, 4, "root vtable header");
155
+ const vtableSize = this.u16(vtablePos);
156
+ if (vtableSize < 4) this.fail("root vtable too small");
157
+ this.require(vtablePos, vtableSize, "root vtable body");
158
+ if (field.slot + 2 > vtableSize) return false;
159
+ const rel = this.u16(vtablePos + field.slot);
160
+ if (rel === 0) return false;
161
+ this.verifyField(tablePos + rel, field, deep, 0, rootTable);
162
+ return true;
163
+ }
164
+
165
+ /** Verify the root object offset and the table it points at. */
166
+ verifyRoot(rootTable: string, deep: boolean): void {
167
+ // getRootAs* computes rootPos = readInt32(0) + 0 (signed, like every
168
+ // FlatBuffers offset -- child objects can lie at lower addresses).
169
+ const rootPos = this.i32(0);
170
+ this.verifyTable(rootPos, rootTable, deep, 0);
171
+ }
172
+
173
+ private verifyTable(tablePos: number, tableName: string, deep: boolean, depth: number): void {
174
+ if (depth > MAX_DEPTH) this.fail("table nesting too deep");
175
+ this.require(tablePos, 4, `table ${tableName} soffset`);
176
+ const soffset = this.i32(tablePos);
177
+ const vtablePos = tablePos - soffset;
178
+ this.require(vtablePos, 4, `table ${tableName} vtable header`);
179
+ const vtableSize = this.u16(vtablePos);
180
+ if (vtableSize < 4) this.fail(`table ${tableName} vtable too small`);
181
+ this.require(vtablePos, vtableSize, `table ${tableName} vtable body`);
182
+ const tableSize = this.u16(vtablePos + 2);
183
+ this.require(tablePos, tableSize, `table ${tableName} inline data`);
184
+
185
+ const fields = SCHEMAS[tableName];
186
+ if (!fields) this.fail(`unknown table ${tableName}`);
187
+ for (const field of fields) {
188
+ // A field's vtable slot may lie beyond this vtable (field absent).
189
+ if (field.slot + 2 > vtableSize) continue;
190
+ const rel = this.u16(vtablePos + field.slot);
191
+ if (rel === 0) continue; // absent / default
192
+ const fieldPos = tablePos + rel;
193
+ this.verifyField(fieldPos, field, deep, depth, tableName);
194
+ }
195
+ }
196
+
197
+ private verifyField(fieldPos: number, field: FieldSpec, deep: boolean, depth: number, owner: string): void {
198
+ switch (field.kind) {
199
+ case "scalar":
200
+ this.require(fieldPos, field.size, `${owner} scalar field`);
201
+ return;
202
+ case "string":
203
+ this.verifyString(fieldPos, owner);
204
+ return;
205
+ case "table": {
206
+ // Sub-object pointer: always bounds-check the offset; recurse when deep.
207
+ const subPos = fieldPos + this.i32(fieldPos);
208
+ this.require(subPos, 4, `${owner} sub-table pointer`);
209
+ if (deep) this.verifyTable(subPos, field.table, deep, depth + 1);
210
+ return;
211
+ }
212
+ case "vectorScalar": {
213
+ this.verifyVectorHeader(fieldPos, field.stride, owner);
214
+ return;
215
+ }
216
+ case "vectorString": {
217
+ const { dataStart, count } = this.verifyVectorHeader(fieldPos, UOFFSET, owner);
218
+ if (deep) {
219
+ for (let i = 0; i < count; i++) {
220
+ const elemPos = dataStart + i * UOFFSET;
221
+ const strPos = elemPos + this.i32(elemPos);
222
+ this.verifyString(strPos, owner);
223
+ }
224
+ }
225
+ return;
226
+ }
227
+ case "vectorTable": {
228
+ const { dataStart, count } = this.verifyVectorHeader(fieldPos, UOFFSET, owner);
229
+ if (deep) {
230
+ for (let i = 0; i < count; i++) {
231
+ const elemPos = dataStart + i * UOFFSET;
232
+ const subPos = elemPos + this.i32(elemPos);
233
+ this.verifyTable(subPos, field.table, deep, depth + 1);
234
+ }
235
+ }
236
+ return;
237
+ }
238
+ }
239
+ }
240
+
241
+ private verifyString(fieldPos: number, owner: string): void {
242
+ const strPos = fieldPos + this.i32(fieldPos);
243
+ this.require(strPos, 4, `${owner} string length`);
244
+ const strLen = this.u32(strPos);
245
+ // +1 for FlatBuffers' implicit null terminator.
246
+ this.require(strPos + 4, strLen + 1, `${owner} string body`);
247
+ }
248
+
249
+ private verifyVectorHeader(fieldPos: number, stride: number, owner: string): { dataStart: number; count: number } {
250
+ const vecPos = fieldPos + this.i32(fieldPos);
251
+ this.require(vecPos, 4, `${owner} vector length`);
252
+ const count = this.u32(vecPos);
253
+ const dataStart = vecPos + 4;
254
+ // The load-bearing OOM guard: reject an oversized forged count before use.
255
+ if (count > (this.len - dataStart) / stride) {
256
+ this.fail(`${owner} vector count ${count} exceeds buffer (stride ${stride})`);
257
+ }
258
+ this.require(dataStart, count * stride, `${owner} vector body`);
259
+ return { dataStart, count };
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Bounded structural verification of a raw (uncompressed) main-index byte
265
+ * range. `deep` controls whether sub-tables/vectors are recursed into
266
+ * (validator) or only their offsets/lengths bounds-checked (reader open).
267
+ * Throws `MalformedIndexError` on any violation.
268
+ */
269
+ export function verifyMainIndex(indexBytes: Uint8Array, deep: boolean): void {
270
+ new BoundsVerifier(indexBytes).verifyRoot("MainIndex", deep);
271
+ }
272
+
273
+ /**
274
+ * Verify then construct the `MainIndex` root, so a malformed buffer throws
275
+ * `MalformedIndexError` instead of yielding a booby-trapped lazy root.
276
+ */
277
+ export function getVerifiedRootAsMainIndex(indexBytes: Uint8Array, deep: boolean): MainIndex {
278
+ verifyMainIndex(indexBytes, deep);
279
+ return MainIndex.getRootAsMainIndex(new ByteBuffer(indexBytes));
280
+ }
281
+
282
+ /**
283
+ * True only when the index's optional `pdfExport` metadata is genuinely
284
+ * present and safely traversable. Because the writer emits an absent
285
+ * `pdfExport` via `forceDefaults(true)` (a sentinel pointing out of bounds),
286
+ * callers must gate any `pdfExport()`/outline read on this check.
287
+ */
288
+ export function pdfExportPresent(indexBytes: Uint8Array): boolean {
289
+ try {
290
+ return new BoundsVerifier(indexBytes).verifyOptionalRootField(
291
+ "MainIndex",
292
+ { slot: 22, kind: "table", table: "PDFExportMetadata" },
293
+ true,
294
+ );
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
@@ -0,0 +1,12 @@
1
+ // automatically generated by the FlatBuffers compiler, do not modify
2
+
3
+ /* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
4
+
5
+ export enum BlobKind {
6
+ PageImage = 0,
7
+ ThumbnailImage = 1,
8
+ OCRLayout = 2,
9
+ NormalizedText = 3,
10
+ SearchDictionary = 4,
11
+ SearchPostings = 5
12
+ }