dsh-book-reader 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +494 -2
- package/SKILL.md +97 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +13 -0
- package/lib/client.js +1794 -0
- package/lib/core/README.md +299 -0
- package/lib/core/pdf.js +962 -0
- package/lib/core/render.js +351 -0
- package/lib/core/tokens.js +116 -0
- package/lib/index.js +1936 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +83 -4
|
@@ -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.
|