dsh-book-reader 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,299 @@
1
+ # dsh-book-reader core (`lib/core`)
2
+
3
+ Pure-Node core for the AI-side half of `dsh-book-reader`: open a PDF, extract
4
+ text, search it, and render pages to PNG **in-process**. No browser, no Swift,
5
+ no PDFKit, no Python, no shelling out.
6
+
7
+ ```
8
+ lib/core/pdf.js document cache, info/labels/offset/outline, page text, search
9
+ lib/core/render.js page -> RGB PNG Buffer (pdf.js + @napi-rs/canvas)
10
+ test/core.test.mjs plain-node tests against the real LADR books
11
+ ```
12
+
13
+ Tested on macOS arm64 with Node **24.14.0**, `pdfjs-dist@6.4.299`,
14
+ `@napi-rs/canvas@1.0.11`. `pdfjs-dist@6.4.299` requires Node >= 22.13.
15
+
16
+ ## Dependencies
17
+
18
+ Declared in `package.json` (do not duplicate them elsewhere):
19
+
20
+ ```json
21
+ "dependencies": {
22
+ "@napi-rs/canvas": "^1.0.11",
23
+ "pdfjs-dist": "^6.4.299"
24
+ }
25
+ ```
26
+
27
+ The host is expected to import these core modules in a normal Node ESM context
28
+ where those packages resolve. If the host bundles, keep both packages external
29
+ (the native canvas binary cannot be bundled as JavaScript, and pdf.js must be
30
+ able to import its own `pdf.worker.mjs`).
31
+
32
+ ## Public interface
33
+
34
+ ```js
35
+ // lib/core/pdf.js
36
+ export async function getInfo(path)
37
+ // -> { path, pageCount, labels: [{pdf, printed}], printedOffset: number|null,
38
+ // outline: [{ title, pdf, printed, depth }] }
39
+
40
+ export async function getPageText(path, pdfPage) // -> string
41
+ export async function searchPdf(path, query, opts) // opts: {ignoreCase=true, limit=200, contextChars=80}
42
+ // -> { hits: [{ pdf, printed, indexOnPage, snippet }], pages: <number of pages with hits> }
43
+ export async function closePdf(path) // optional cache eviction -> boolean
44
+
45
+ // lib/core/render.js
46
+ export async function renderPageToPng(path, pdfPage, opts) // opts: {scale=2, crop}
47
+ // crop = {x,y,w,h} fractions of the page, e.g. {x:0,y:0,w:1,h:0.55}
48
+ // -> { pdf, printed, buffer, width, height }
49
+
50
+ export async function renderPagesToPng(path, pages, opts) // -> array of the above
51
+ ```
52
+
53
+ ### `getInfo(path)`
54
+
55
+ Returns a fresh (mutable) copy on every call; mutating it cannot poison the
56
+ cache.
57
+
58
+ * **`path`** — absolute filesystem path. Relative paths are resolved against
59
+ `process.cwd()`; `file://` URLs are accepted. The returned `path` is the
60
+ resolved absolute path.
61
+ * **`pageCount`** — `doc.numPages` (410 for the LADR book).
62
+ * **`labels`** — one entry per physical page when the PDF has page labels:
63
+ `[{pdf: 1, printed: 'C1'}, {pdf: 21, printed: 1}, ...]`. If
64
+ `getPageLabels()` returns no labels, `labels` is `[]`.
65
+ * **`printed` type** — `number` for numeric labels (`1`, `324`), the raw
66
+ string for non-numeric labels (`"i"`, `"xvii"`, `"C1"`), `null` for an
67
+ unlabelled page. This keeps front matter meaningful instead of emitting
68
+ negative numbers.
69
+ * **`printedOffset`** — **`physical PDF page - printed page`**:
70
+ **20** for LADR, **1** for `LADR_Solutions.pdf`. Therefore
71
+ `printed = pdf - printedOffset`. It is detected from the mode of the
72
+ per-page deltas among numeric `pageLabels`; `null` when there are no numeric
73
+ labels. The mode is not hardcoded per book.
74
+ * **`outline`** — flattened depth-first. Root entries have `depth: 0`, their
75
+ children `depth: 1`, etc. Every entry has a resolved 1-based physical `pdf`
76
+ page. Entries whose destination cannot be resolved are dropped rather than
77
+ throwing; an unresolved parent does not prevent resolved children from
78
+ appearing. `printed` follows the same number/string rule as labels.
79
+ LADR resolves **169** entries.
80
+
81
+ ### `getPageText(path, pdfPage)`
82
+
83
+ Returns the page's text as **one whitespace-normalised line** (runs joined
84
+ with a single space, all whitespace collapsed, trimmed). This is deliberate: it
85
+ makes phrases such as `8.46 Jordan form` searchable across PDF text runs. It is
86
+ not a layout-preserving extraction; use render for formulas/typography.
87
+
88
+ `pdfPage` is 1-based and must be in `1..pageCount` (numeric strings are
89
+ accepted). Invalid pages throw a `RangeError` with
90
+ `code = 'PDF_PAGE_OUT_OF_RANGE'`.
91
+
92
+ ### `searchPdf(path, query, opts)`
93
+
94
+ Literal, substring search (the query is not a regex). Both the query and each
95
+ page's text are whitespace-normalised; `ignoreCase` defaults to `true`.
96
+ Matching is non-overlapping, page by page, with an `await` between pages so the
97
+ event loop is not held for the whole book.
98
+
99
+ * `hits` are ordered by physical page, then position on the page.
100
+ * `printed` is the printed label for that page (`273` for PDF page 293), or a
101
+ front-matter string / `null`.
102
+ * `indexOnPage` is a UTF-16 offset into the **same normalised text** that
103
+ `getPageText()` returns for that page.
104
+ * `snippet` is at most about `2 * contextChars + query.length` characters, with
105
+ leading/trailing `…` when the hit is mid-page.
106
+ * `limit` caps the number of collected hits (default 200), but the scan
107
+ continues to the last page so `pages` always counts **all** pages with at
108
+ least one hit. LADR's `"singular value decomposition"`: 56 hits on 28 pages,
109
+ including PDF 293 / printed 273.
110
+ * `contextChars` defaults to 80.
111
+
112
+ An empty/whitespace-only query throws `TypeError`. Invalid `limit`/`contextChars`
113
+ throw `RangeError`.
114
+
115
+ ### `closePdf(path)`
116
+
117
+ Evicts the cached document for `path` and destroys its pdf.js loading task.
118
+ Returns `true` when an entry existed, `false` otherwise (idempotent). If a call
119
+ is still using the document, it stays alive until that call releases it; this
120
+ function does not wait for in-flight renders. Memory is reused by later opens;
121
+ RSS may not immediately shrink because the native allocator retains arenas.
122
+
123
+ ### `renderPageToPng(path, pdfPage, opts)` / `renderPagesToPng(path, pages, opts)`
124
+
125
+ * **Output** — `{pdf, printed, buffer, width, height}` where `buffer` is a Node
126
+ `Buffer` containing an **8-bit RGB PNG (PNG color type 2)** on an opaque
127
+ white background. It is not RGBA. Dimensions are the exact PNG dimensions.
128
+ * **`scale`** — a number in **1..3**; default `2`. Out-of-range scales throw a
129
+ `RangeError` (no silent clamping). At scale 2 the LADR page is 750x1272.
130
+ * **`crop`** — `{x, y, w, h}` fractions of the rendered page; omitted values
131
+ default to `x=0, y=0, w=1-x, h=1-y`. Must satisfy `x,y >= 0`, `w,h > 0`,
132
+ `x+w <= 1`, `y+h <= 1`. The PNG is cropped after a full-page render, so crop
133
+ does not reduce render time; it reduces the encoded image and reported
134
+ dimensions. `{x:0,y:0,w:1,h:0.55}` at scale 2 is 750x700.
135
+ * **`renderPagesToPng`** accepts an array of pages (also a single number) and
136
+ preserves order. Empty array -> `[]` without opening the PDF. Rendering is
137
+ limited to **2 pages at a time** by a module-wide semaphore, including
138
+ concurrent single-page calls, so memory spikes stay bounded.
139
+ * Each page's pdf.js operator list is cleaned up after the pixels are captured.
140
+ This keeps long render sessions bounded (70 pages: ~243 MB RSS instead of
141
+ ~283 MB without cleanup), at the cost of a re-parse (~8 ms) if the same page
142
+ is rendered again.
143
+ * Pages are rendered with `@napi-rs/canvas`; the PNG is encoded by a small
144
+ hand-written RGB encoder because the native canvas encoder emits RGBA.
145
+
146
+ ## Errors
147
+
148
+ All open failures are normal `Error`s with a machine-readable `code`:
149
+
150
+ | code | meaning |
151
+ | --- | --- |
152
+ | `PDF_NOT_FOUND` | path does not exist (or is not a file) |
153
+ | `PDF_NOT_READABLE` | permission denied |
154
+ | `PDF_NOT_FILE` | path exists but is not a regular file |
155
+ | `PDF_ENCRYPTED` | encrypted/password-protected; no password support by design |
156
+ | `PDF_CORRUPT` | invalid PDF structure |
157
+ | `PDF_UNREADABLE` | pdf.js could not retrieve the data (usually a bad path) |
158
+ | `PDF_LOAD_TIMEOUT` | whole-document load exceeded 60 s |
159
+ | `PDF_LOAD_FAILED` | other open/parse failure (original error in `cause`) |
160
+ | `PDF_PAGE_OUT_OF_RANGE` | bad 1-based page number |
161
+ | `PDF_TEXT_EXTRACTION_FAILED` | a page's text layer failed to extract |
162
+
163
+ Encrypted and corrupt PDFs reject promptly; the load guard is 60 s
164
+ (`PDF_LOAD_TIMEOUT_MS`). There is no timeout on individual text extraction or
165
+ rendering; pdf.js either completes or rejects those.
166
+
167
+ ## Cache
168
+
169
+ * Cache key: resolved path, validated on every call against `mtimeMs + size`.
170
+ If the file is replaced, the old document is retired and a new one opened.
171
+ * Capacity: **4 open documents**. Least-recently-used documents are retired and
172
+ their loading tasks destroyed. Documents with an active call are skipped
173
+ until they are released; the cache may briefly exceed 4 under heavy parallel
174
+ use and trims as calls finish.
175
+ * Cached per document: pdf.js document, labels/offset metadata, resolved info
176
+ (outline), and pdf.js's own page cache. Page text and PNGs are **not**
177
+ cached; each search/render re-extracts, so memory stays predictable.
178
+ * `closePdf()` is the explicit eviction hook. The cache is per Node process
179
+ (per worker thread); it is not shared across processes or hosts.
180
+
181
+ ## Measured numbers
182
+
183
+ macOS arm64 (Apple Silicon), Node 24.14.0, `pdfjs-dist@6.4.299`,
184
+ `@napi-rs/canvas@1.0.11`. LADR = 410-page
185
+ `Linear Algebra Done Right-Springer (2024).pdf`. "Cold" is the first
186
+ `getInfo` in a fresh process; "warm" is a repeat call while the document is
187
+ cached. Medians over repeated runs; timings move with machine load (search in
188
+ particular ranged from ~0.67 s to ~1.18 s across runs).
189
+
190
+ | Operation | Measured |
191
+ | --- | --- |
192
+ | Cold `getInfo` (open + labels + 169-entry outline) | **~85-105 ms** |
193
+ | First PDF call if only `pdf.js` was imported and neither canvas nor pdf.js was loaded yet | **~156 ms** |
194
+ | Warm `getInfo` | **~0.05 ms** median |
195
+ | `getPageText(344)` | **~28-50 ms** |
196
+ | `searchPdf` over all 410 pages (`"singular value decomposition"`) | **~0.8 s** median; 0.67-1.18 s observed |
197
+ | `renderPageToPng(344, scale 2)` | **~26 ms** median warm (22-43 ms) |
198
+ | `renderPagesToPng([344,345,346], scale 2)` | **~66 ms** median (63-77 ms) |
199
+ | Cropped render (`0,0,1,0.55`, scale 2) | **~18-27 ms** |
200
+ | PNG size, page 344 scale 2 | 210,422 bytes, 750x1272, RGB |
201
+ | Encrypted / corrupt PDF rejection | 1-125 ms (first parse may be slower) |
202
+
203
+ Memory (RSS, `--expose-gc`, after forced GC):
204
+
205
+ | State | RSS |
206
+ | --- | --- |
207
+ | Both core modules imported, nothing opened | ~82 MB |
208
+ | 410-page book opened + info | ~130 MB |
209
+ | Small 39-page PDF additionally open | ~134 MB |
210
+ | 4 documents open | ~140 MB |
211
+ | 5th document opened (LRU evicts) | ~141 MB (no jump) |
212
+ | After rendering 70 pages | ~243 MB |
213
+ | After `closePdf` (heap drops; RSS arena retained) | ~243 MB |
214
+
215
+ Practical planning number: **~15-35 MB per open document**, plus ~80 MB native
216
+ canvas/pdf.js baseline, plus page-render arena growth if you render many pages
217
+ in one session. Four cached books plus a long render session should stay in the
218
+ 200-300 MB RSS range on this machine.
219
+
220
+ ## Pitfalls for the host author
221
+
222
+ 1. **`printedOffset` sign.** It is `physical - printed` (20, not -20). Compute
223
+ `printed = pdf - printedOffset`. This is the only place the contract was
224
+ ambiguous; it is documented here and in the module.
225
+ 2. **Paths with spaces are fine, wrong paths are not.** Pass the plain absolute
226
+ filesystem path to pdf.js (the core does). A missing/relative-wrong path
227
+ produces the confusing pdf.js error
228
+ `Unexpected server response (0) while retrieving PDF`; the core pre-stats
229
+ the file and maps this to `PDF_NOT_FOUND` / `PDF_UNREADABLE`.
230
+ 3. **Polyfill order matters.** `DOMMatrix`, `Path2D`, and `ImageData` from
231
+ `@napi-rs/canvas` must be installed on `globalThis` **before**
232
+ `pdfjs-dist/legacy/build/pdf.mjs` evaluates. `pdf.js` exports
233
+ `__loadPdfjs()` that does this lazily; do not import the pdf.js legacy build
234
+ yourself before calling core (or install the polyfills first).
235
+ 4. **Use the legacy build.** Import
236
+ `pdfjs-dist/legacy/build/pdf.mjs`, not `build/pdf.mjs`. The legacy build is
237
+ what works under Node here.
238
+ 5. **Fake worker, no worker thread.** pdf.js sets `GlobalWorkerOptions.workerSrc`
239
+ to its own `pdf.worker.mjs` and runs the "fake worker" in-process on Node.
240
+ There is no `Worker` thread to terminate yourself; `closePdf()` destroys the
241
+ loading task. Do not set a browser `workerSrc` for this core.
242
+ 6. **Fonts/CMaps/WASM.** The core points `standardFontDataUrl`, `cMapUrl`
243
+ (packed), and `wasmUrl` at the installed `pdfjs-dist` package, with the
244
+ trailing slash pdf.js requires. This keeps rendering working without network
245
+ access. If you relocate/bundle `pdfjs-dist`, update those paths or rendering
246
+ will fall back/warn or fail on non-embedded fonts and CJK CMaps.
247
+ 7. **Scale and crop are validated, not clamped.** `scale` outside 1..3 throws;
248
+ crop fractions outside the page throw. Validate in the tool schema too so
249
+ the model gets a useful message.
250
+ 8. **Search is literal, not regex, and does not unfold hyphenation.** A word
251
+ split as `decom-\nposition` will not match `decomposition`. Whitespace and
252
+ case are normalized only. Search scans every page even after `limit` is hit
253
+ so `pages` is accurate; expect ~0.8 s for a 410-page book.
254
+ 9. **Page labels come from the PDF, not from visible text.** Some books have
255
+ duplicate/odd roman labels (LADR's PDF page 4 is labelled `ii` by pdf.js).
256
+ Only numeric labels drive `printedOffset`; numeric deltas are taken by mode.
257
+ If a PDF has no page labels, `labels: []` and `printedOffset: null` — do not
258
+ invent an offset from the outline.
259
+ 10. **Outline resolution is best-effort.** Bad/missing destinations are dropped
260
+ instead of failing the whole `getInfo`; the returned outline can be shorter
261
+ than the raw PDF outline. Depth is preserved for what remains.
262
+ 11. **PNG is RGB, color type 2.** If the attachment service assumes RGBA, it
263
+ must not; `Buffer` is a complete PNG, dimensions are also returned. White
264
+ background is baked in.
265
+ 12. **Concurrency is capped at 2 renders.** Queued renders wait; do not add a
266
+ second unbounded render queue in the host. A render holds the document
267
+ active so the LRU cannot destroy it mid-render.
268
+ 13. **`closePdf` during an in-flight call does not abort that call.** The
269
+ document is destroyed after the call releases it. If the host needs
270
+ cancellation, it must be added above this core.
271
+ 14. **No global per-request state.** The only module-level state is the
272
+ document cache, the lazy pdf.js promise, and the render semaphore. Page
273
+ text/PNG buffers are per-call. Separate Node worker threads get separate
274
+ caches.
275
+ 15. **`package.json` `files`/`exports` are owned by the host half.** This
276
+ module deliberately only added `dependencies`; the final package must
277
+ include `lib/core/*.js` in its published file list (and expose it to the
278
+ host code by import path, e.g. `./lib/core/pdf.js`).
279
+
280
+ ## Internal exports (not part of the host contract)
281
+
282
+ `pdf.js` also exports `__loadPdfjs`, `__acquireDocument`, `__releaseDocument`,
283
+ `__ensureMetadata`, `__normalizePdfPage`, and `PDF_LOAD_TIMEOUT_MS`. They exist
284
+ so `render.js` can share the cache and avoid re-acquiring per page. Treat names
285
+ starting with `__` as private.
286
+
287
+ ## Running the tests
288
+
289
+ ```sh
290
+ cd dsh-book-reader
291
+ node test/core.test.mjs
292
+ ```
293
+
294
+ The test imports both real PDFs from the workspace root, asserts the LADR
295
+ contract (410 pages, offset 20, a resolved 169-entry outline, Chapter 1 -> PDF
296
+ 21 / printed 1, search hit at PDF 293 / printed 273, 750x1272 RGB PNG > 100 KB),
297
+ the second PDF's offset, crop/batch rendering, cache eviction, and generated
298
+ corrupt/encrypted fixtures. It writes only to a temporary directory and cleans
299
+ up after itself.