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
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Public API for reading, writing, and validating YOM v1 packfiles.
3
+ *
4
+ * This entry point is platform-agnostic (no DOM/pdf.js dependency). For the
5
+ * browser-only PDF-to-YOM conversion pipeline, import from
6
+ * `yom-format/pipeline` instead.
7
+ */
8
+
9
+ export { YOMPackfileReader, YOMReaderError } from "./yom/reader.js";
10
+
11
+ export {
12
+ type ByteSource,
13
+ MemoryByteSource,
14
+ HttpRangeByteSource,
15
+ ByteSourceError,
16
+ type HttpRangeOptions,
17
+ } from "./yom/byteSource.js";
18
+
19
+ export {
20
+ YOMError,
21
+ BlobTooLargeError,
22
+ CorruptBlobError,
23
+ MalformedIndexError,
24
+ RangeNotSupportedError,
25
+ } from "./yom/errors.js";
26
+
27
+ export { verifyMainIndex } from "./yom/flatbufferVerify.js";
28
+
29
+ export {
30
+ MAX_BLOB_UNCOMPRESSED_IMAGE,
31
+ MAX_BLOB_UNCOMPRESSED_TEXT,
32
+ MAX_BLOB_UNCOMPRESSED_POSTINGS,
33
+ MAX_TOTAL_UNCOMPRESSED,
34
+ MAX_INFLATION_RATIO,
35
+ maxBlobUncompressed,
36
+ } from "./yom/constants.js";
37
+
38
+ export {
39
+ writeYOM,
40
+ YOMWriterError,
41
+ type WriterInput,
42
+ type BookInput,
43
+ type PageInput,
44
+ type BlobInput,
45
+ type SearchTermInput,
46
+ type OutlineInput,
47
+ type PDFExportInput,
48
+ } from "./yom/writer.js";
49
+
50
+ export { validateYOM, YOMValidationError } from "./yom/validator.js";
51
+
52
+ export {
53
+ YOM_FILE_EXTENSION,
54
+ YOM_MIME_TYPE,
55
+ YOM_MAGIC,
56
+ YOM_HEADER_SIZE,
57
+ YOM_FORMAT_VERSION,
58
+ YOM_HASH_LENGTH,
59
+ YOM_IMAGE_BLOB_ALIGNMENT,
60
+ YOM_DEFAULT_BLOB_ALIGNMENT,
61
+ requiredCompression,
62
+ requiredAlignment,
63
+ } from "./yom/constants.js";
64
+
65
+ export {
66
+ HEADER_FIELDS_SIZE,
67
+ type YOMHeader,
68
+ YOMHeaderParseError,
69
+ encodeHeaderFields,
70
+ encodeHeader,
71
+ encodeFooter,
72
+ parseHeaderFields,
73
+ zeroingHashes,
74
+ headersEqual,
75
+ } from "./yom/header.js";
76
+
77
+ export {
78
+ buildSearchIndex,
79
+ decodePostings,
80
+ type SearchTerm,
81
+ type SearchIndexResult,
82
+ type Hit,
83
+ } from "./yom/searchIndex.js";
84
+
85
+ export {
86
+ normalizeText,
87
+ buildNormalizedTextBlob,
88
+ type NormalizedTextBlob,
89
+ } from "./yom/textNormalizer.js";
90
+
91
+ export { sha256, bytesEqual } from "./yom/hash.js";
92
+ export { concatBytes } from "./yom/bytes.js";
93
+
94
+ // FlatBuffers-generated enums and record types used by the API above.
95
+ export { BlobKind } from "./yom/generated/yomu/yom/blob-kind.js";
96
+ export { Compression } from "./yom/generated/yomu/yom/compression.js";
97
+ export { ReadingDirection } from "./yom/generated/yomu/yom/reading-direction.js";
98
+ export type { MainIndex } from "./yom/generated/yomu/yom/main-index.js";
99
+ export type { PageEntry } from "./yom/generated/yomu/yom/page-entry.js";
100
+ export type { BlobRef } from "./yom/generated/yomu/yom/blob-ref.js";
101
+ export type { BookMetadata } from "./yom/generated/yomu/yom/book-metadata.js";
102
+ export type { OutlineEntry } from "./yom/generated/yomu/yom/outline-entry.js";
103
+ export type { SearchTermRef } from "./yom/generated/yomu/yom/search-term-ref.js";
104
+ export type { PDFExportMetadata } from "./yom/generated/yomu/yom/pdfexport-metadata.js";
105
+ export type { OCRLayout } from "./yom/generated/yomu/yom/ocrlayout.js";
106
+ export type { OCRPage } from "./yom/generated/yomu/yom/ocrpage.js";
107
+ export type { OCRBlock } from "./yom/generated/yomu/yom/ocrblock.js";
108
+ export type { OCRLine } from "./yom/generated/yomu/yom/ocrline.js";
109
+ export type { OCRToken } from "./yom/generated/yomu/yom/ocrtoken.js";
@@ -0,0 +1,33 @@
1
+ import encode from "@jsquash/avif/encode";
2
+
3
+ /**
4
+ * AVIF is the only page/thumbnail image codec YOM v1 supports (see
5
+ * docs/format-spec.md §5.1-5.2). `@jsquash/avif`'s `quality` is a 0-100
6
+ * cq-level scale, independent of the
7
+ * native encoder's ImageIO `0..1` quality knob (`AVIFCodec.swift`) -- these
8
+ * two encoders are not required to numerically match, only to both emit
9
+ * valid, reasonably-sized AVIF per the format's page/thumbnail split.
10
+ */
11
+ const PAGE_QUALITY = 60;
12
+ const THUMBNAIL_QUALITY = 40;
13
+
14
+ /**
15
+ * libavif's `speed` is 0 (slowest/best compression) to 10 (fastest/worst
16
+ * compression); `@jsquash/avif`'s default is 6 ("balanced"). This app's own
17
+ * product direction is an "extremely fast, focused" reader over archival
18
+ * image quality (see `pivot-fast-reader-remove-ai-ocr`), and these pages are
19
+ * themselves lossy re-renders of the source PDF rather than original scans,
20
+ * so trading a little compression efficiency for much faster encoding is
21
+ * the right default for a browser-only, no-hardware-encoder pipeline.
22
+ */
23
+ const ENCODE_SPEED = 9;
24
+
25
+ export async function encodePageAvif(image: ImageData): Promise<Uint8Array> {
26
+ const buffer = await encode(image, { quality: PAGE_QUALITY, speed: ENCODE_SPEED });
27
+ return new Uint8Array(buffer);
28
+ }
29
+
30
+ export async function encodeThumbnailAvif(image: ImageData): Promise<Uint8Array> {
31
+ const buffer = await encode(image, { quality: THUMBNAIL_QUALITY, speed: ENCODE_SPEED });
32
+ return new Uint8Array(buffer);
33
+ }
@@ -0,0 +1,148 @@
1
+ import { BlobKind } from "../yom/generated/yomu/yom/blob-kind.js";
2
+ import { Compression } from "../yom/generated/yomu/yom/compression.js";
3
+ import { ReadingDirection } from "../yom/generated/yomu/yom/reading-direction.js";
4
+ import { writeYOM, type WriterInput, type SearchTermInput } from "../yom/writer.js";
5
+ import { validateYOM } from "../yom/validator.js";
6
+ import { buildNormalizedTextBlob } from "../yom/textNormalizer.js";
7
+ import { buildSearchIndex } from "../yom/searchIndex.js";
8
+ import { loadPdfDocument, extractOutline, extractPageLabels } from "./pdfDocument.js";
9
+ import { renderPage } from "./renderPage.js";
10
+ import { extractPageText, type PageText } from "./textExtract.js";
11
+ import { encodePageAvif, encodeThumbnailAvif } from "./avif.js";
12
+ import { buildOcrLayoutBlob } from "./ocrLayoutBuilder.js";
13
+
14
+ /**
15
+ * Mirrors the Swift implementation's `YOMConverter.Stage`
16
+ * (`swift/Sources/YOMConverter/YOMConverter.swift`) so Web and native
17
+ * progress reporting read the same way. `rendering`, `encoding`, and
18
+ * `extractingText` all advance together per-page here since this pipeline
19
+ * does all three in one pass per page (see `convertPdfToYom`).
20
+ */
21
+ export type ConversionStage =
22
+ | "rendering"
23
+ | "encoding"
24
+ | "extractingText"
25
+ | "buildingSearchIndex"
26
+ | "writingPackfile"
27
+ | "validating";
28
+
29
+ export type ProgressCallback = (stage: ConversionStage, fraction: number) => void;
30
+
31
+ export class ConversionCancelledError extends Error {
32
+ constructor() {
33
+ super("conversion cancelled");
34
+ }
35
+ }
36
+
37
+ export interface ConvertOptions {
38
+ title: string;
39
+ onProgress?: ProgressCallback;
40
+ isCancelled?: () => boolean;
41
+ }
42
+
43
+ /**
44
+ * Converts a source PDF's bytes into a validated `.yom` package, entirely in
45
+ * the browser. Ports the shape of the Swift implementation's
46
+ * `YOMConverter.convert` + `YOMConversionPipeline.buildWriterInput`
47
+ * (`swift/Sources/YOMConverter/`), with two known scope differences:
48
+ * image-only pages get no OCR text yet (a vendored OCR engine for that is
49
+ * not yet implemented), and PDF export metadata is generated but not
50
+ * consumed by an export feature yet.
51
+ */
52
+ export async function convertPdfToYom(pdfBytes: Uint8Array, options: ConvertOptions): Promise<Uint8Array> {
53
+ const { title, onProgress = () => {}, isCancelled = () => false } = options;
54
+
55
+ const doc = await loadPdfDocument(pdfBytes);
56
+ const pageCount = doc.numPages;
57
+ if (pageCount === 0) throw new Error("PDF has no pages");
58
+
59
+ const [outline, pageLabels] = await Promise.all([extractOutline(doc), extractPageLabels(doc)]);
60
+
61
+ const pageTexts: PageText[] = [];
62
+ const pageInputs: WriterInput["pages"] = [];
63
+
64
+ for (let i = 0; i < pageCount; i++) {
65
+ if (isCancelled()) throw new ConversionCancelledError();
66
+
67
+ const page = await doc.getPage(i + 1);
68
+ const rendered = await renderPage(page);
69
+ onProgress("rendering", (i + 1) / pageCount);
70
+
71
+ const [pageAvif, thumbAvif] = await Promise.all([
72
+ encodePageAvif(rendered.image),
73
+ encodeThumbnailAvif(rendered.thumbnail),
74
+ ]);
75
+ onProgress("encoding", (i + 1) / pageCount);
76
+
77
+ const pageText = await extractPageText(page, rendered.viewport);
78
+ pageTexts.push(pageText);
79
+ onProgress("extractingText", (i + 1) / pageCount);
80
+
81
+ // Geometry uses PDF-space `page.view` (pdf.js does not expose distinct
82
+ // mediaBox/cropBox), matching the native converter's own simplification
83
+ // of collapsing contentBox into cropBox (`YOMPDFExportMetadata.swift`).
84
+ const pdfBox = page.view.slice();
85
+
86
+ pageInputs.push({
87
+ pageLabel: pageLabels[i] ?? `${i + 1}`,
88
+ pixelWidth: rendered.image.width,
89
+ pixelHeight: rendered.image.height,
90
+ dpi: 150,
91
+ colorSpace: "sRGB",
92
+ colorProfileRef: "",
93
+ mediaBox: pdfBox,
94
+ cropBox: pdfBox,
95
+ contentBox: pdfBox,
96
+ rotation: page.rotate,
97
+ readingDirection: ReadingDirection.LeftToRight,
98
+ pageImage: { kind: BlobKind.PageImage, data: pageAvif, compression: Compression.None },
99
+ thumbnail: { kind: BlobKind.ThumbnailImage, data: thumbAvif, compression: Compression.None },
100
+ });
101
+
102
+ page.cleanup();
103
+ }
104
+
105
+ if (isCancelled()) throw new ConversionCancelledError();
106
+
107
+ onProgress("buildingSearchIndex", 0);
108
+ const ocrLayoutBytes = buildOcrLayoutBlob(pageTexts);
109
+ const normalized = buildNormalizedTextBlob(pageTexts.map((p) => p.text));
110
+ const searchIndex = buildSearchIndex(normalized.normalizedPageTexts);
111
+ const searchTerms: SearchTermInput[] = searchIndex.searchTerms.map((t) => ({
112
+ term: t.term,
113
+ dictionaryOffset: BigInt(t.dictionaryOffset),
114
+ dictionaryLength: BigInt(t.dictionaryLength),
115
+ postingsOffset: BigInt(t.postingsOffset),
116
+ postingsLength: BigInt(t.postingsLength),
117
+ }));
118
+ onProgress("buildingSearchIndex", 1);
119
+
120
+ const writerInput: WriterInput = {
121
+ book: {
122
+ title,
123
+ readingDirection: ReadingDirection.LeftToRight,
124
+ createdAtUnix: BigInt(Math.floor(Date.now() / 1000)),
125
+ },
126
+ pages: pageInputs,
127
+ ocrLayout: { kind: BlobKind.OCRLayout, data: ocrLayoutBytes, compression: Compression.Zstd },
128
+ normalizedText: { kind: BlobKind.NormalizedText, data: normalized.data, compression: Compression.Zstd },
129
+ searchDictionary: { kind: BlobKind.SearchDictionary, data: searchIndex.dictionaryBlob, compression: Compression.Zstd },
130
+ searchPostings: { kind: BlobKind.SearchPostings, data: searchIndex.postingsBlob, compression: Compression.None },
131
+ searchTerms,
132
+ pdfExport: { pageLabels, outline },
133
+ };
134
+
135
+ if (isCancelled()) throw new ConversionCancelledError();
136
+
137
+ onProgress("writingPackfile", 0);
138
+ const packfileBytes = await writeYOM(writerInput);
139
+ onProgress("writingPackfile", 1);
140
+
141
+ if (isCancelled()) throw new ConversionCancelledError();
142
+
143
+ onProgress("validating", 0);
144
+ await validateYOM(packfileBytes);
145
+ onProgress("validating", 1);
146
+
147
+ return packfileBytes;
148
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Browser-only PDF-to-YOM conversion pipeline: renders a PDF with pdf.js,
3
+ * encodes pages/thumbnails as AVIF, extracts embedded text and outline, and
4
+ * writes a validated `.yom` package. Requires a DOM (Canvas/OffscreenCanvas)
5
+ * environment; it will not run under plain Node.
6
+ *
7
+ * Kept as a separate subpath (`yom-format/pipeline`) so the core read/write
8
+ * API in `yom-format` stays free of the pdf.js and image-codec dependencies
9
+ * this pulls in.
10
+ */
11
+
12
+ export {
13
+ convertPdfToYom,
14
+ ConversionCancelledError,
15
+ type ConversionStage,
16
+ type ProgressCallback,
17
+ type ConvertOptions,
18
+ } from "./convert.js";
19
+
20
+ export { loadPdfDocument, extractOutline, extractPageLabels, type OutlineEntryData } from "./pdfDocument.js";
21
+
22
+ export { renderPage, THUMBNAIL_MAX_DIMENSION, type RenderedPage } from "./renderPage.js";
23
+
24
+ export { extractPageText, type PageText, type PageTextToken } from "./textExtract.js";
25
+
26
+ export { encodePageAvif, encodeThumbnailAvif } from "./avif.js";
27
+
28
+ export { buildOcrLayoutBlob } from "./ocrLayoutBuilder.js";
@@ -0,0 +1,73 @@
1
+ import { Builder } from "flatbuffers";
2
+ import { OCRLayoutT } from "../yom/generated/yomu/yom/ocrlayout.js";
3
+ import { OCRPageT } from "../yom/generated/yomu/yom/ocrpage.js";
4
+ import { OCRBlockT } from "../yom/generated/yomu/yom/ocrblock.js";
5
+ import { OCRLineT } from "../yom/generated/yomu/yom/ocrline.js";
6
+ import { OCRTokenT } from "../yom/generated/yomu/yom/ocrtoken.js";
7
+ import { concatBytes } from "../yom/bytes.js";
8
+ import type { PageText, PageTextToken } from "./textExtract.js";
9
+
10
+ type Bbox = [number, number, number, number];
11
+
12
+ /**
13
+ * Builds the pre-compression OCR FlatBuffers blob (`writer.ts` zstd-
14
+ * compresses it per the fixed policy) from per-page token layout. Each
15
+ * page's `text` field is the concatenation of its own tokens' text, in
16
+ * order, with no separators -- `textStart`/`textLength` index directly into
17
+ * that buffer, not into the original PDF text run structure.
18
+ */
19
+ export function buildOcrLayoutBlob(pages: PageText[]): Uint8Array {
20
+ const encoder = new TextEncoder();
21
+ const builder = new Builder(1024);
22
+ builder.forceDefaults(true);
23
+
24
+ const ocrPages = pages.map((page, pageIndex) => {
25
+ const tokenBytesList = page.tokens.map((t) => encoder.encode(t.text));
26
+ let cursor = 0;
27
+ const tokens = page.tokens.map((token, i) => {
28
+ const bytes = tokenBytesList[i];
29
+ const textStart = cursor;
30
+ cursor += bytes.length;
31
+ return new OCRTokenT(textStart, bytes.length, token.bbox, 1.0, token.lineId, token.blockId);
32
+ });
33
+
34
+ const pageText = new TextDecoder().decode(concatBytes(tokenBytesList));
35
+
36
+ const lineBboxes = new Map<number, Bbox>();
37
+ const blockBboxes = new Map<number, Bbox>();
38
+ for (const token of page.tokens) {
39
+ unionBbox(lineBboxes, token.lineId, token.bbox);
40
+ unionBbox(blockBboxes, token.blockId, token.bbox);
41
+ }
42
+
43
+ const lines = Array.from(lineBboxes.keys())
44
+ .sort((a, b) => a - b)
45
+ .map((id) => new OCRLineT(id, blockIdForLine(page.tokens, id), lineBboxes.get(id)!));
46
+ const blocks = Array.from(blockBboxes.keys())
47
+ .sort((a, b) => a - b)
48
+ .map((id) => new OCRBlockT(id, blockBboxes.get(id)!));
49
+
50
+ return new OCRPageT(pageIndex, pageText, blocks, lines, tokens);
51
+ });
52
+
53
+ const layout = new OCRLayoutT(ocrPages);
54
+ const offset = layout.pack(builder);
55
+ builder.finish(offset);
56
+ return builder.asUint8Array();
57
+ }
58
+
59
+ function blockIdForLine(tokens: PageTextToken[], lineId: number): number {
60
+ return tokens.find((t) => t.lineId === lineId)?.blockId ?? 0;
61
+ }
62
+
63
+ function unionBbox(map: Map<number, Bbox>, id: number, bbox: Bbox): void {
64
+ const existing = map.get(id);
65
+ if (!existing) {
66
+ map.set(id, [...bbox]);
67
+ return;
68
+ }
69
+ existing[0] = Math.min(existing[0], bbox[0]);
70
+ existing[1] = Math.min(existing[1], bbox[1]);
71
+ existing[2] = Math.max(existing[2], bbox[2]);
72
+ existing[3] = Math.max(existing[3], bbox[3]);
73
+ }
@@ -0,0 +1,86 @@
1
+ import { GlobalWorkerOptions, getDocument } from "pdfjs-dist";
2
+ import type { PDFDocumentProxy } from "pdfjs-dist";
3
+
4
+ // Runs inside `convert.worker.ts`; pdf.js spawns (or fakes, if the browser
5
+ // disallows a nested worker) its own core worker from this URL.
6
+ GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url).href;
7
+
8
+ /**
9
+ * Minimal `CanvasFactory` so pdf.js's *internal* auxiliary canvases (soft
10
+ * masks, tiling patterns) work in a Worker, where `document` does not exist.
11
+ * Our own top-level per-page render target is a separate `OffscreenCanvas`
12
+ * created directly in `renderPage.ts`.
13
+ */
14
+ class OffscreenCanvasFactory {
15
+ create(width: number, height: number) {
16
+ const canvas = new OffscreenCanvas(width, height);
17
+ const context = canvas.getContext("2d");
18
+ return { canvas, context };
19
+ }
20
+ reset(canvasAndContext: { canvas: OffscreenCanvas }, width: number, height: number) {
21
+ canvasAndContext.canvas.width = width;
22
+ canvasAndContext.canvas.height = height;
23
+ }
24
+ destroy(canvasAndContext: { canvas: OffscreenCanvas | null; context: unknown }) {
25
+ canvasAndContext.canvas = null;
26
+ canvasAndContext.context = null;
27
+ }
28
+ }
29
+
30
+ export async function loadPdfDocument(data: Uint8Array): Promise<PDFDocumentProxy> {
31
+ // pdf.js instantiates this itself (`new CanvasFactory(...)`), so the
32
+ // option must be the class, not an instance of it.
33
+ const task = getDocument({ data, CanvasFactory: OffscreenCanvasFactory as unknown as never });
34
+ return task.promise;
35
+ }
36
+
37
+ export interface OutlineEntryData {
38
+ title: string;
39
+ pageId: number;
40
+ children: OutlineEntryData[];
41
+ }
42
+
43
+ /** Extracts the PDF outline/TOC, resolving each destination to a page index. */
44
+ export async function extractOutline(doc: PDFDocumentProxy): Promise<OutlineEntryData[]> {
45
+ const raw = await doc.getOutline();
46
+ if (!raw) return [];
47
+ return resolveOutlineNodes(doc, raw);
48
+ }
49
+
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ async function resolveOutlineNodes(doc: PDFDocumentProxy, nodes: any[]): Promise<OutlineEntryData[]> {
52
+ const result: OutlineEntryData[] = [];
53
+ for (const node of nodes) {
54
+ const pageId = await resolvePageId(doc, node.dest);
55
+ const children = node.items?.length ? await resolveOutlineNodes(doc, node.items) : [];
56
+ result.push({ title: typeof node.title === "string" ? node.title : "", pageId, children });
57
+ }
58
+ return result;
59
+ }
60
+
61
+ async function resolvePageId(
62
+ doc: PDFDocumentProxy,
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ dest: string | any[] | null | undefined,
65
+ ): Promise<number> {
66
+ try {
67
+ let resolved = dest;
68
+ if (typeof resolved === "string") {
69
+ resolved = await doc.getDestination(resolved);
70
+ }
71
+ if (!Array.isArray(resolved) || resolved.length === 0) return 0;
72
+ const ref = resolved[0];
73
+ if (typeof ref === "number") return ref;
74
+ const index = await doc.getPageIndex(ref);
75
+ return index >= 0 ? index : 0;
76
+ } catch {
77
+ return 0;
78
+ }
79
+ }
80
+
81
+ /** Page labels, falling back to 1-based numbering when the PDF has none. */
82
+ export async function extractPageLabels(doc: PDFDocumentProxy): Promise<string[]> {
83
+ const labels = await doc.getPageLabels();
84
+ if (labels) return labels;
85
+ return Array.from({ length: doc.numPages }, (_, i) => `${i + 1}`);
86
+ }
@@ -0,0 +1,48 @@
1
+ import type { PDFPageProxy } from "pdfjs-dist";
2
+ import type { PageViewport } from "pdfjs-dist";
3
+
4
+ /**
5
+ * Render resolution and thumbnail sizing. `RENDER_DPI` favors legibility for
6
+ * the primary reading surface (mirrors the intent behind
7
+ * `AVIFCodec.pageQuality` on the native side, expressed here as resolution
8
+ * rather than a platform-specific encoder quality knob).
9
+ */
10
+ const RENDER_DPI = 150;
11
+ export const THUMBNAIL_MAX_DIMENSION = 320;
12
+
13
+ export interface RenderedPage {
14
+ image: ImageData;
15
+ thumbnail: ImageData;
16
+ viewport: PageViewport;
17
+ }
18
+
19
+ export async function renderPage(page: PDFPageProxy): Promise<RenderedPage> {
20
+ const scale = RENDER_DPI / 72;
21
+ const viewport = page.getViewport({ scale });
22
+ const width = Math.max(1, Math.round(viewport.width));
23
+ const height = Math.max(1, Math.round(viewport.height));
24
+
25
+ const canvas = new OffscreenCanvas(width, height);
26
+ const ctx = canvas.getContext("2d");
27
+ if (!ctx) throw new Error("failed to acquire 2D rendering context");
28
+
29
+ await page.render({
30
+ canvas: null,
31
+ canvasContext: ctx as unknown as CanvasRenderingContext2D,
32
+ viewport,
33
+ }).promise;
34
+ const image = ctx.getImageData(0, 0, width, height);
35
+
36
+ const longestSide = Math.max(width, height);
37
+ const thumbScale = longestSide > THUMBNAIL_MAX_DIMENSION ? THUMBNAIL_MAX_DIMENSION / longestSide : 1;
38
+ const thumbWidth = Math.max(1, Math.round(width * thumbScale));
39
+ const thumbHeight = Math.max(1, Math.round(height * thumbScale));
40
+
41
+ const thumbCanvas = new OffscreenCanvas(thumbWidth, thumbHeight);
42
+ const thumbCtx = thumbCanvas.getContext("2d");
43
+ if (!thumbCtx) throw new Error("failed to acquire 2D rendering context");
44
+ thumbCtx.drawImage(canvas, 0, 0, thumbWidth, thumbHeight);
45
+ const thumbnail = thumbCtx.getImageData(0, 0, thumbWidth, thumbHeight);
46
+
47
+ return { image, thumbnail, viewport };
48
+ }
@@ -0,0 +1,64 @@
1
+ import type { PDFPageProxy, PageViewport } from "pdfjs-dist";
2
+
3
+ export interface PageTextToken {
4
+ text: string;
5
+ /** Normalized `[x0, y0, x1, y1]`, 0..1 relative to the page. */
6
+ bbox: [number, number, number, number];
7
+ lineId: number;
8
+ blockId: number;
9
+ }
10
+
11
+ export interface PageText {
12
+ text: string;
13
+ tokens: PageTextToken[];
14
+ }
15
+
16
+ /**
17
+ * Extracts the PDF's embedded text layer as page text plus a token layout
18
+ * suitable for the OCR FlatBuffers schema. This is not OCR -- it reuses the
19
+ * PDF's own text layer positions, which is exactly the "prefer existing text
20
+ * layer" half of the format's OCR/text rule (see docs/format-spec.md §6,
21
+ * "Search/OCR quality variance"). Image-only pages (no embedded text) get an
22
+ * empty token list here; filling that gap with a vendored OCR engine is not
23
+ * yet implemented.
24
+ *
25
+ * Block/line/bbox derivation is intentionally simple (one block per page,
26
+ * lines split on pdf.js's `hasEOL`, an axis-aligned box approximated from
27
+ * each item's transform and width) since the schema only requires a valid
28
+ * byte range, not layout-analysis-grade precision -- true OCR is
29
+ * responsible for real layout quality.
30
+ */
31
+ export async function extractPageText(page: PDFPageProxy, viewport: PageViewport): Promise<PageText> {
32
+ const content = await page.getTextContent();
33
+ const tokens: PageTextToken[] = [];
34
+ const textParts: string[] = [];
35
+ let lineId = 0;
36
+ const blockId = 0;
37
+
38
+ for (const item of content.items) {
39
+ if (!("str" in item)) continue; // skip TextMarkedContent entries
40
+ textParts.push(item.str);
41
+
42
+ if (item.str.length > 0) {
43
+ const [a, b, c, d, e, f] = item.transform;
44
+ const fontHeight = Math.hypot(c, d) || Math.hypot(a, b) || 1;
45
+ const x0 = e;
46
+ const y0 = f;
47
+ const x1 = e + a * item.width;
48
+ const y1 = f + fontHeight;
49
+ const [vx0, vy0] = viewport.convertToViewportPoint(x0, y0) as [number, number];
50
+ const [vx1, vy1] = viewport.convertToViewportPoint(x1, y1) as [number, number];
51
+ const bbox: [number, number, number, number] = [
52
+ Math.min(vx0, vx1) / viewport.width,
53
+ Math.min(vy0, vy1) / viewport.height,
54
+ Math.max(vx0, vx1) / viewport.width,
55
+ Math.max(vy0, vy1) / viewport.height,
56
+ ];
57
+ tokens.push({ text: item.str, bbox, lineId, blockId });
58
+ }
59
+
60
+ if (item.hasEOL) lineId += 1;
61
+ }
62
+
63
+ return { text: textParts.join(""), tokens };
64
+ }
@@ -0,0 +1,54 @@
1
+ import { convertPdfToYom, ConversionCancelledError, type ConversionStage } from "../pipeline/convert.js";
2
+
3
+ /**
4
+ * Runs the whole PDF-to-YOM pipeline (pdf.js rendering via `OffscreenCanvas`,
5
+ * AVIF/zstd encoding, FlatBuffers writing, validation) off the main thread,
6
+ * so the drag-and-drop UI stays responsive during a multi-hundred-page
7
+ * conversion. `self` is cast to `Worker` rather than
8
+ * adding the `webworker` lib to `tsconfig.json`, which would conflict with
9
+ * the `dom` lib the rest of the app needs (both declare a global `self`).
10
+ */
11
+ const ctx = self as unknown as Worker;
12
+
13
+ type InboundMessage = { type: "convert"; pdfBytes: ArrayBuffer; title: string } | { type: "cancel" };
14
+
15
+ type OutboundMessage =
16
+ | { type: "progress"; stage: ConversionStage; fraction: number }
17
+ | { type: "done"; bytes: ArrayBuffer }
18
+ | { type: "cancelled" }
19
+ | { type: "error"; message: string };
20
+
21
+ let cancelled = false;
22
+
23
+ ctx.addEventListener("message", (event: MessageEvent<InboundMessage>) => {
24
+ const message = event.data;
25
+ if (message.type === "cancel") {
26
+ cancelled = true;
27
+ return;
28
+ }
29
+ if (message.type === "convert") {
30
+ cancelled = false;
31
+ void run(message.pdfBytes, message.title);
32
+ }
33
+ });
34
+
35
+ function post(message: OutboundMessage, transfer: Transferable[] = []): void {
36
+ ctx.postMessage(message, transfer);
37
+ }
38
+
39
+ async function run(pdfBuffer: ArrayBuffer, title: string): Promise<void> {
40
+ try {
41
+ const bytes = await convertPdfToYom(new Uint8Array(pdfBuffer), {
42
+ title,
43
+ isCancelled: () => cancelled,
44
+ onProgress: (stage, fraction) => post({ type: "progress", stage, fraction }),
45
+ });
46
+ post({ type: "done", bytes: bytes.buffer as ArrayBuffer }, [bytes.buffer as ArrayBuffer]);
47
+ } catch (error) {
48
+ if (error instanceof ConversionCancelledError) {
49
+ post({ type: "cancelled" });
50
+ return;
51
+ }
52
+ post({ type: "error", message: error instanceof Error ? error.message : String(error) });
53
+ }
54
+ }
@@ -0,0 +1,30 @@
1
+ import type { BlobKind } from "./generated/yomu/yom/blob-kind.js";
2
+ import { MAX_INFLATION_RATIO, maxBlobUncompressed } from "./constants.js";
3
+ import { BlobTooLargeError } from "./errors.js";
4
+
5
+ /**
6
+ * Gap 2 reader-side size caps. Enforces, from a blob's *declared* sizes
7
+ * (before any bytes are read or decompressed):
8
+ *
9
+ * - `uncompressed_length <= MAX_BLOB_UNCOMPRESSED[kind]`
10
+ * - inflation ratio `uncompressed / max(compressed, 1) <= MAX_INFLATION_RATIO`
11
+ *
12
+ * so a hostile file can't drive an unbounded allocation. Offset/length
13
+ * against-the-source bounds are checked by the caller (reader/validator),
14
+ * which know the exact source extent. These are reader limits, not format
15
+ * requirements -- see `constants.ts`.
16
+ */
17
+ export function enforceBlobSizePolicy(kind: BlobKind, compressedLength: number, uncompressedLength: number): void {
18
+ const cap = maxBlobUncompressed(kind);
19
+ if (uncompressedLength > cap) {
20
+ throw new BlobTooLargeError(
21
+ `blob kind ${kind} declares uncompressed length ${uncompressedLength} > cap ${cap}`,
22
+ );
23
+ }
24
+ const ratio = uncompressedLength / Math.max(compressedLength, 1);
25
+ if (ratio > MAX_INFLATION_RATIO) {
26
+ throw new BlobTooLargeError(
27
+ `blob kind ${kind} inflation ratio ${ratio.toFixed(1)} > cap ${MAX_INFLATION_RATIO}`,
28
+ );
29
+ }
30
+ }