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,962 @@
1
+ /**
2
+ * dsh-pdf-reader core / pdf.js
3
+ *
4
+ * Pure-Node PDF access for the host half of dsh-pdf-reader:
5
+ *
6
+ * - a small LRU cache of parsed PDF.js documents (path + mtime + size),
7
+ * - metadata (page labels, detected printed-page offset, resolved outline),
8
+ * - whitespace-normalised page text,
9
+ * - literal full-text search with physical *and* printed page numbers.
10
+ *
11
+ * Design constraints that shaped this file:
12
+ *
13
+ * - pdfjs-dist is loaded lazily, and only after the three DOM globals that
14
+ * its canvas path touches (DOMMatrix / Path2D / ImageData) are installed
15
+ * from @napi-rs/canvas. Loading pdf.js before those globals breaks
16
+ * rendering in Node with a "DOMMatrix is not defined" error.
17
+ * - PDF.js runs its "fake worker" in-process in Node (no worker thread).
18
+ * That is what makes in-process rendering possible, but it also means the
19
+ * cached document owns real memory until it is destroyed.
20
+ * - Nothing here shells out; no Swift/PDFKit, no Python.
21
+ *
22
+ * The public interface is documented in ./README.md. Names starting with
23
+ * `__` are internal exports used by render.js and are not part of the host
24
+ * contract.
25
+ */
26
+
27
+ import { createRequire } from 'node:module'
28
+ import { stat } from 'node:fs/promises'
29
+ import { dirname, join, resolve as resolvePath } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const require = createRequire(import.meta.url)
33
+
34
+ /** Maximum number of parsed documents kept open at once. */
35
+ const MAX_OPEN_DOCUMENTS = 4
36
+
37
+ /** Whole-document load guard (requirement: ~60 s; corrupt files may hang). */
38
+ export const PDF_LOAD_TIMEOUT_MS = 60_000
39
+
40
+ /** Resolved once, lazily: pdfjs-dist package root (fonts/cmaps/wasm live here). */
41
+ let pdfjsPackageDir = null
42
+ function getPdfjsPackageDir() {
43
+ if (!pdfjsPackageDir) pdfjsPackageDir = dirname(require.resolve('pdfjs-dist/package.json'))
44
+ return pdfjsPackageDir
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // PDF.js loader + DOM polyfills
49
+ // ---------------------------------------------------------------------------
50
+
51
+ let pdfjsPromise = null
52
+
53
+ /**
54
+ * Load pdfjs-dist/legacy/build/pdf.mjs after installing the DOM globals its
55
+ * Node canvas path needs. Exported only for render.js; the host should not
56
+ * call this directly.
57
+ *
58
+ * @returns {Promise<typeof import('pdfjs-dist/legacy/build/pdf.mjs')>}
59
+ */
60
+ export async function __loadPdfjs() {
61
+ if (!pdfjsPromise) {
62
+ pdfjsPromise = (async () => {
63
+ // Deliberately a dynamic import: the polyfills must exist *before* the
64
+ // pdf.js module evaluates its canvas-dependent code.
65
+ const canvas = await import('@napi-rs/canvas')
66
+ // Assign unconditionally: another plugin in the same host process may
67
+ // already have installed a *different* Path2D/DOMMatrix (or pdf.js's own
68
+ // JS shim) on globalThis. A `??=` here would then keep the foreign one,
69
+ // and pdf.js's `ctx.fill(path)` fails inside @napi-rs/canvas with
70
+ // "Value is none of these types String, Path" because the native
71
+ // binding does not recognise a foreign Path2D instance.
72
+ globalThis.DOMMatrix = canvas.DOMMatrix
73
+ globalThis.Path2D = canvas.Path2D
74
+ globalThis.ImageData = canvas.ImageData
75
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
76
+ // The pdf.js module may have been imported earlier by someone else; if it
77
+ // warned "Cannot polyfill Path2D", re-assert after import as well.
78
+ globalThis.DOMMatrix = canvas.DOMMatrix
79
+ globalThis.Path2D = canvas.Path2D
80
+ globalThis.ImageData = canvas.ImageData
81
+ return pdfjs
82
+ })().catch((error) => {
83
+ pdfjsPromise = null // allow a later retry after a transient failure
84
+ throw error
85
+ })
86
+ }
87
+ return pdfjsPromise
88
+ }
89
+
90
+ /** Options shared by every getDocument() call. */
91
+ function documentOptions(absolutePath) {
92
+ const packageDir = getPdfjsPackageDir()
93
+ return {
94
+ // In Node, a plain absolute filesystem path is correct here. A file://
95
+ // URL works too, but a *wrong* path yields the misleading pdf.js error
96
+ // "Unexpected server response (0) while retrieving PDF".
97
+ url: absolutePath,
98
+ useSystemFonts: true,
99
+ isEvalSupported: false,
100
+ // Keep font/CMap/wasm asset loading inside the installed package. The
101
+ // trailing slash is required by pdf.js's factory URL validation.
102
+ standardFontDataUrl: join(packageDir, 'standard_fonts') + '/',
103
+ cMapUrl: join(packageDir, 'cmaps') + '/',
104
+ cMapPacked: true,
105
+ wasmUrl: join(packageDir, 'wasm') + '/',
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Paths, file stats, errors
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function normalizePdfPath(pathLike) {
114
+ if (typeof pathLike !== 'string' || pathLike.trim() === '') {
115
+ throw new TypeError('PDF path must be a non-empty string')
116
+ }
117
+ if (pathLike.startsWith('file://')) {
118
+ try {
119
+ return fileURLToPath(pathLike)
120
+ } catch (error) {
121
+ throw new Error(`Invalid file:// PDF URL: ${pathLike}`, { cause: error })
122
+ }
123
+ }
124
+ return resolvePath(pathLike)
125
+ }
126
+
127
+ async function statPdf(absolutePath) {
128
+ let stats
129
+ try {
130
+ stats = await stat(absolutePath)
131
+ } catch (error) {
132
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
133
+ const wrapped = new Error(`PDF not found: ${absolutePath}`, { cause: error })
134
+ wrapped.code = 'PDF_NOT_FOUND'
135
+ throw wrapped
136
+ }
137
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') {
138
+ const wrapped = new Error(`PDF is not readable: ${absolutePath}`, { cause: error })
139
+ wrapped.code = 'PDF_NOT_READABLE'
140
+ throw wrapped
141
+ }
142
+ throw new Error(`Cannot stat PDF ${absolutePath}: ${error?.message || error}`, { cause: error })
143
+ }
144
+ if (!stats.isFile()) {
145
+ const error = new Error(`PDF path is not a regular file: ${absolutePath}`)
146
+ error.code = 'PDF_NOT_FILE'
147
+ throw error
148
+ }
149
+ return stats
150
+ }
151
+
152
+ function wrapLoadError(error, absolutePath) {
153
+ if (error?.code === 'PDF_LOAD_TIMEOUT') return error
154
+
155
+ const name = error?.name || ''
156
+ const message = error?.message || String(error)
157
+
158
+ if (name === 'PasswordException' || /password/i.test(message)) {
159
+ const wrapped = new Error(
160
+ `PDF is encrypted or password-protected and cannot be opened: ${absolutePath}`,
161
+ { cause: error },
162
+ )
163
+ wrapped.code = 'PDF_ENCRYPTED'
164
+ return wrapped
165
+ }
166
+ if (name === 'InvalidPDFException') {
167
+ const wrapped = new Error(
168
+ `PDF is corrupt or not a valid PDF: ${absolutePath} (${message})`,
169
+ { cause: error },
170
+ )
171
+ wrapped.code = 'PDF_CORRUPT'
172
+ return wrapped
173
+ }
174
+ if (name === 'MissingPDFException') {
175
+ const wrapped = new Error(`PDF not found: ${absolutePath}`, { cause: error })
176
+ wrapped.code = 'PDF_NOT_FOUND'
177
+ return wrapped
178
+ }
179
+ if (name === 'AbortException') {
180
+ const wrapped = new Error(`PDF load was aborted: ${absolutePath} (${message})`, { cause: error })
181
+ wrapped.code = 'PDF_ABORTED'
182
+ return wrapped
183
+ }
184
+ if (name === 'ResponseException' || /Unexpected server response/i.test(message)) {
185
+ const wrapped = new Error(
186
+ `Unable to read PDF data from ${absolutePath} (${message}). ` +
187
+ 'Check that the path exists and is a regular file, not a directory.',
188
+ { cause: error },
189
+ )
190
+ wrapped.code = 'PDF_UNREADABLE'
191
+ return wrapped
192
+ }
193
+
194
+ const wrapped = new Error(`Failed to open PDF ${absolutePath}: ${message}`, { cause: error })
195
+ wrapped.code = 'PDF_LOAD_FAILED'
196
+ return wrapped
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Document loading + LRU cache
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * @typedef {Object} CacheEntry
205
+ * @property {string} path
206
+ * @property {number} mtimeMs
207
+ * @property {number} size
208
+ * @property {import('pdfjs-dist/types/src/display/api').PDFDocumentProxy|null} doc
209
+ * @property {import('pdfjs-dist/types/src/display/api').PDFDocumentLoadingTask|null} task
210
+ * @property {Promise<CacheEntry>|null} loadPromise
211
+ * @property {Promise<Meta>|null} metaPromise
212
+ * @property {Meta|null} meta
213
+ * @property {Promise<Info>|null} infoPromise
214
+ * @property {Info|null} info
215
+ * @property {number} active
216
+ * @property {number} lastUsed
217
+ * @property {boolean} stale
218
+ * @property {boolean} destroyed
219
+ */
220
+
221
+ /** @type {Map<string, CacheEntry>} */
222
+ const cache = new Map()
223
+ let accessClock = 0
224
+
225
+ async function loadDocument(absolutePath) {
226
+ const pdfjs = await __loadPdfjs()
227
+ const task = pdfjs.getDocument(documentOptions(absolutePath))
228
+
229
+ let timer = null
230
+ const timeout = new Promise((_, reject) => {
231
+ timer = setTimeout(() => {
232
+ const error = new Error(
233
+ `Timed out after ${PDF_LOAD_TIMEOUT_MS / 1000}s loading PDF: ${absolutePath}`,
234
+ )
235
+ error.code = 'PDF_LOAD_TIMEOUT'
236
+ // The rejection below is handled by the catch in the try block, which
237
+ // performs the actual best-effort task.destroy(). Keep this timer
238
+ // referenced so a stalled load cannot let the process exit silently.
239
+ reject(error)
240
+ }, PDF_LOAD_TIMEOUT_MS)
241
+ })
242
+
243
+ // If the timeout wins, the task's later rejection must not become an
244
+ // unhandled rejection.
245
+ task.promise.catch(() => {})
246
+
247
+ try {
248
+ const doc = await Promise.race([task.promise, timeout])
249
+ return { doc, task }
250
+ } catch (error) {
251
+ Promise.resolve(task.destroy()).catch(() => {})
252
+ throw wrapLoadError(error, absolutePath)
253
+ } finally {
254
+ clearTimeout(timer)
255
+ }
256
+ }
257
+
258
+ /** @param {CacheEntry} entry */
259
+ function destroyEntry(entry) {
260
+ if (entry.destroyed) return Promise.resolve()
261
+ entry.destroyed = true
262
+ const task = entry.task
263
+ entry.task = null
264
+ entry.doc = null
265
+ return Promise.resolve(task?.destroy?.()).catch(() => {})
266
+ }
267
+
268
+ /** Remove an entry from the map and destroy it once no caller is using it. */
269
+ function retireEntry(entry) {
270
+ if (cache.get(entry.path) === entry) cache.delete(entry.path)
271
+ entry.stale = true
272
+ if (entry.active === 0) void destroyEntry(entry)
273
+ }
274
+
275
+ function trimCache() {
276
+ while (cache.size > MAX_OPEN_DOCUMENTS) {
277
+ let victim = null
278
+ for (const entry of cache.values()) {
279
+ if (entry.active !== 0) continue
280
+ if (!victim || entry.lastUsed < victim.lastUsed) victim = entry
281
+ }
282
+ if (!victim) break // every open document is in use; shrink after release
283
+ retireEntry(victim)
284
+ }
285
+ }
286
+
287
+ function createEntry(absolutePath, stats) {
288
+ /** @type {CacheEntry} */
289
+ const entry = {
290
+ path: absolutePath,
291
+ mtimeMs: stats.mtimeMs,
292
+ size: stats.size,
293
+ doc: null,
294
+ task: null,
295
+ loadPromise: null,
296
+ metaPromise: null,
297
+ meta: null,
298
+ infoPromise: null,
299
+ info: null,
300
+ active: 0,
301
+ lastUsed: 0,
302
+ stale: false,
303
+ destroyed: false,
304
+ }
305
+
306
+ entry.loadPromise = loadDocument(absolutePath)
307
+ .then(({ doc, task }) => {
308
+ const current = cache.get(absolutePath)
309
+ if (entry.stale || (current && current !== entry)) {
310
+ // The cache entry was closed or replaced while loading. No caller can
311
+ // obtain this document (this promise rejects), so destroy it now.
312
+ void Promise.resolve(task.destroy()).catch(() => {})
313
+ throw wrapLoadError(new Error('PDF cache entry was closed while loading'), absolutePath)
314
+ }
315
+ entry.doc = doc
316
+ entry.task = task
317
+ // Every entry is added before its load completes, so `current` is this
318
+ // entry in the normal path.
319
+ return entry
320
+ })
321
+ .catch((error) => {
322
+ if (cache.get(absolutePath) === entry) cache.delete(absolutePath)
323
+ entry.stale = true
324
+ if (entry.active === 0) void destroyEntry(entry)
325
+ throw error
326
+ })
327
+
328
+ // If a caller abandons the load (e.g. closePdf races it), don't leave an
329
+ // unhandled rejection behind; real awaiters still receive the rejection.
330
+ entry.loadPromise.catch(() => {})
331
+ return entry
332
+ }
333
+
334
+ /**
335
+ * Acquire a cached document. Checks mtime+size on every call so a replaced
336
+ * file is reopened instead of serving stale pages.
337
+ *
338
+ * @param {string} pathLike
339
+ * @returns {Promise<CacheEntry>}
340
+ */
341
+ async function acquire(absoluteOrRelativePath) {
342
+ const absolutePath = normalizePdfPath(absoluteOrRelativePath)
343
+ const stats = await statPdf(absolutePath)
344
+
345
+ let entry = cache.get(absolutePath)
346
+ if (entry && (entry.mtimeMs !== stats.mtimeMs || entry.size !== stats.size)) {
347
+ retireEntry(entry)
348
+ entry = null
349
+ }
350
+
351
+ if (!entry) {
352
+ entry = createEntry(absolutePath, stats)
353
+ cache.set(absolutePath, entry)
354
+ }
355
+
356
+ // Mark as active *before* trimming so the just-created entry cannot be
357
+ // chosen as the LRU victim.
358
+ entry.active += 1
359
+ entry.lastUsed = ++accessClock
360
+ trimCache()
361
+
362
+ try {
363
+ await entry.loadPromise
364
+ } catch (error) {
365
+ releaseEntry(entry)
366
+ throw error
367
+ }
368
+ return entry
369
+ }
370
+
371
+ /** @param {CacheEntry} entry */
372
+ function releaseEntry(entry) {
373
+ entry.active = Math.max(0, entry.active - 1)
374
+ entry.lastUsed = ++accessClock
375
+ if (entry.stale && entry.active === 0) {
376
+ void destroyEntry(entry)
377
+ } else {
378
+ trimCache()
379
+ }
380
+ }
381
+
382
+ async function withEntry(pathLike, fn) {
383
+ const entry = await acquire(pathLike)
384
+ try {
385
+ return await fn(entry)
386
+ } finally {
387
+ releaseEntry(entry)
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Close and evict the cached document for a path.
393
+ *
394
+ * If a call is still using the document, it stays alive until that call
395
+ * releases it; this function does not wait for in-flight renders.
396
+ *
397
+ * @param {string} path
398
+ * @returns {Promise<boolean>} true if a cached entry was removed
399
+ */
400
+ export async function closePdf(path) {
401
+ let absolutePath
402
+ try {
403
+ absolutePath = normalizePdfPath(path)
404
+ } catch {
405
+ return false
406
+ }
407
+ const entry = cache.get(absolutePath)
408
+ if (!entry) return false
409
+ retireEntry(entry)
410
+ if (entry.active === 0) await destroyEntry(entry)
411
+ return true
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Page labels / printed-page offset
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /** @typedef {{pdf:number, printed:number|string|null}} PageLabel */
419
+ /** @typedef {{title:string, pdf:number, printed:number|string|null, depth:number}} OutlineEntry */
420
+ /** @typedef {{pageCount:number, labels:PageLabel[], printedOffset:number|null, outline:OutlineEntry[]}} Info */
421
+ /** @typedef {{pageCount:number, labels:PageLabel[], printedOffset:number|null}} Meta */
422
+
423
+ function parseNumericLabel(value) {
424
+ if (typeof value !== 'string') return null
425
+ const trimmed = value.trim()
426
+ if (!/^\d+$/.test(trimmed)) return null
427
+ const number = Number(trimmed)
428
+ return Number.isSafeInteger(number) ? number : null
429
+ }
430
+
431
+ /**
432
+ * A "printed page" is the label printed on the page: a number for numeric
433
+ * labels, the raw label string for front matter ("xvii", "C1"), or null when
434
+ * the page has no label.
435
+ */
436
+ function toPrintedLabel(value) {
437
+ if (typeof value !== 'string') return null
438
+ const trimmed = value.trim()
439
+ if (trimmed === '') return null
440
+ const numeric = parseNumericLabel(trimmed)
441
+ return numeric === null ? trimmed : numeric
442
+ }
443
+
444
+ /**
445
+ * Detect the physical→printed offset from PDF page labels.
446
+ *
447
+ * `printedOffset` is defined as `physicalPdfPage - printedPage` (LADR: 20;
448
+ * LADR_Solutions: 1), so callers compute `printed = pdf - printedOffset`.
449
+ * The mode of the per-page deltas is used; ties prefer the delta closest to
450
+ * zero. Returns null when the document has no numeric page labels.
451
+ *
452
+ * @param {string[]|null} rawLabels
453
+ * @returns {number|null}
454
+ */
455
+ function detectPrintedOffset(rawLabels) {
456
+ if (!Array.isArray(rawLabels) || rawLabels.length === 0) return null
457
+
458
+ /** @type {Map<number, number>} */
459
+ const counts = new Map()
460
+ for (let index = 0; index < rawLabels.length; index += 1) {
461
+ const printed = parseNumericLabel(rawLabels[index])
462
+ if (printed === null) continue
463
+ const delta = index + 1 - printed
464
+ counts.set(delta, (counts.get(delta) || 0) + 1)
465
+ }
466
+
467
+ let best = null
468
+ let bestCount = 0
469
+ for (const [delta, count] of counts) {
470
+ if (
471
+ count > bestCount ||
472
+ (count === bestCount && (best === null || Math.abs(delta) < Math.abs(best)))
473
+ ) {
474
+ best = delta
475
+ bestCount = count
476
+ }
477
+ }
478
+ return best
479
+ }
480
+
481
+ /**
482
+ * @param {CacheEntry} entry
483
+ * @returns {Promise<Meta>}
484
+ */
485
+ async function ensureMetadata(entry) {
486
+ if (entry.meta) return entry.meta
487
+ if (!entry.metaPromise) {
488
+ entry.metaPromise = (async () => {
489
+ const pageCount = entry.doc.numPages
490
+
491
+ let rawLabels = null
492
+ try {
493
+ rawLabels = await entry.doc.getPageLabels()
494
+ } catch {
495
+ rawLabels = null // encrypted/odd catalog: degrade to no labels
496
+ }
497
+ if (!Array.isArray(rawLabels)) rawLabels = null
498
+
499
+ const labels = rawLabels
500
+ ? Array.from({ length: pageCount }, (_, index) => ({
501
+ pdf: index + 1,
502
+ printed: toPrintedLabel(rawLabels[index]),
503
+ }))
504
+ : []
505
+
506
+ return {
507
+ pageCount,
508
+ labels,
509
+ printedOffset: detectPrintedOffset(rawLabels),
510
+ }
511
+ })()
512
+ .then((meta) => {
513
+ entry.meta = meta
514
+ return meta
515
+ })
516
+ .catch((error) => {
517
+ entry.metaPromise = null
518
+ throw error
519
+ })
520
+ }
521
+ return entry.metaPromise
522
+ }
523
+
524
+ /** @param {Meta} meta @param {number} pdf */
525
+ function printedForPdf(meta, pdf) {
526
+ const label = meta.labels[pdf - 1]
527
+ return label ? label.printed : null
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Outline
532
+ // ---------------------------------------------------------------------------
533
+
534
+ async function resolveDestination(doc, dest, namedCache) {
535
+ if (typeof dest === 'string') {
536
+ if (namedCache.has(dest)) return namedCache.get(dest)
537
+ let resolved = null
538
+ try {
539
+ resolved = await doc.getDestination(dest)
540
+ } catch {
541
+ resolved = null
542
+ }
543
+ namedCache.set(dest, resolved)
544
+ return resolved
545
+ }
546
+ if (Array.isArray(dest)) return dest
547
+ return null
548
+ }
549
+
550
+ async function resolvePdfPage(doc, destination, pageCache) {
551
+ if (!Array.isArray(destination) || destination.length === 0) return null
552
+ const ref = destination[0]
553
+
554
+ let key
555
+ if (ref && typeof ref === 'object' && Number.isInteger(ref.num)) {
556
+ key = `${ref.num}R${ref.gen ?? 0}`
557
+ } else if (Number.isInteger(ref)) {
558
+ key = `page:${ref}`
559
+ } else {
560
+ return null
561
+ }
562
+
563
+ if (pageCache.has(key)) return pageCache.get(key)
564
+
565
+ let pdfPage = null
566
+ try {
567
+ if (ref && typeof ref === 'object') {
568
+ // PDF.js getPageIndex() requires a RefProxy ({num, gen}).
569
+ const zeroBased = await doc.getPageIndex(ref)
570
+ if (Number.isInteger(zeroBased)) pdfPage = zeroBased + 1
571
+ } else {
572
+ // Rare malformed explicit destination with a bare page number:
573
+ // treat it as a 1-based physical page if it is in range.
574
+ pdfPage = ref
575
+ }
576
+ } catch {
577
+ pdfPage = null
578
+ }
579
+
580
+ if (!Number.isInteger(pdfPage) || pdfPage < 1 || pdfPage > doc.numPages) pdfPage = null
581
+ pageCache.set(key, pdfPage)
582
+ return pdfPage
583
+ }
584
+
585
+ /**
586
+ * Flatten the outline depth-first. Entries whose destination cannot be
587
+ * resolved are dropped; unresolved children are still attempted.
588
+ *
589
+ * @returns {Promise<OutlineEntry[]>}
590
+ */
591
+ async function flattenOutline(doc, items, meta) {
592
+ const result = []
593
+ const namedCache = new Map()
594
+ const pageCache = new Map()
595
+
596
+ async function walk(nodes, depth) {
597
+ for (const node of nodes) {
598
+ if (!node || typeof node !== 'object') continue
599
+
600
+ const title =
601
+ typeof node.title === 'string' ? node.title.replace(/\s+/g, ' ').trim() : ''
602
+ if (title !== '') {
603
+ const destination = await resolveDestination(doc, node.dest, namedCache)
604
+ const pdfPage = await resolvePdfPage(doc, destination, pageCache)
605
+ if (pdfPage !== null) {
606
+ result.push({
607
+ title,
608
+ pdf: pdfPage,
609
+ printed: printedForPdf(meta, pdfPage),
610
+ depth,
611
+ })
612
+ }
613
+ }
614
+
615
+ if (Array.isArray(node.items) && node.items.length > 0) {
616
+ await walk(node.items, depth + 1)
617
+ }
618
+ }
619
+ }
620
+
621
+ await walk(items, 0)
622
+ return result
623
+ }
624
+
625
+ /** @param {CacheEntry} entry @returns {Promise<Info>} */
626
+
627
+ /**
628
+ * Document metadata worth surfacing to the model (title/author). Best-effort:
629
+ * a malformed Info dictionary must never fail `getInfo`.
630
+ * @param doc - pdf.js document proxy.
631
+ * @returns `{title, author, subject, creator, producer}` with nulls for absent fields.
632
+ */
633
+ async function readDocumentMetadata(doc) {
634
+ const empty = { title: null, author: null, subject: null, creator: null, producer: null }
635
+ try {
636
+ const result = await doc.getMetadata()
637
+ const info = result?.info ?? {}
638
+ const str = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
639
+ return {
640
+ title: str(info.Title),
641
+ author: str(info.Author),
642
+ subject: str(info.Subject),
643
+ creator: str(info.Creator),
644
+ producer: str(info.Producer),
645
+ }
646
+ } catch {
647
+ return empty
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Physical size of page 1 in PDF units (1/72 inch), which is enough to tell a
653
+ * book page from a slide or a two-up scan.
654
+ * @param doc - pdf.js document proxy.
655
+ * @param pageCount - total pages.
656
+ * @returns `{width, height}` in points, or null.
657
+ */
658
+ async function readFirstPageSize(doc, pageCount) {
659
+ if (!Number.isInteger(pageCount) || pageCount < 1) return null
660
+ try {
661
+ const page = await doc.getPage(1)
662
+ const viewport = page.getViewport({ scale: 1 })
663
+ return { width: Math.round(viewport.width), height: Math.round(viewport.height) }
664
+ } catch {
665
+ return null
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Estimate whether the document has a usable text layer, so the model can tell
671
+ * a normal PDF from a scan before spending image tokens. Samples a spread of
672
+ * pages rather than the whole document.
673
+ * @param doc - pdf.js document proxy.
674
+ * @param pageCount - total pages.
675
+ * @returns `{sampledPages, pagesWithText, ratio, isLikelyScanned}`.
676
+ */
677
+ async function sampleTextCoverage(doc, pageCount) {
678
+ const empty = { sampledPages: 0, pagesWithText: 0, ratio: null, isLikelyScanned: null }
679
+ if (!Number.isInteger(pageCount) || pageCount < 1) return empty
680
+ const sampleSize = Math.min(8, pageCount)
681
+ const pages = new Set()
682
+ for (let i = 0; i < sampleSize; i += 1) {
683
+ const page = sampleSize === 1 ? 1 : 1 + Math.round((i * (pageCount - 1)) / (sampleSize - 1))
684
+ pages.add(Math.min(pageCount, Math.max(1, page)))
685
+ }
686
+ let withText = 0
687
+ let sampled = 0
688
+ for (const pdfPage of pages) {
689
+ try {
690
+ const page = await doc.getPage(pdfPage)
691
+ const content = await page.getTextContent()
692
+ const chars = content.items.reduce(
693
+ (total, item) => total + (typeof item?.str === 'string' ? item.str.length : 0),
694
+ 0,
695
+ )
696
+ sampled += 1
697
+ if (chars > 20) withText += 1
698
+ } catch {
699
+ // An unreadable sampled page simply does not count; the estimate stays.
700
+ }
701
+ }
702
+ if (sampled === 0) return empty
703
+ const ratio = withText / sampled
704
+ return {
705
+ sampledPages: sampled,
706
+ pagesWithText: withText,
707
+ ratio: Math.round(ratio * 100) / 100,
708
+ isLikelyScanned: ratio < 0.2,
709
+ }
710
+ }
711
+
712
+ async function ensureInfo(entry) {
713
+ if (entry.info) return entry.info
714
+ if (!entry.infoPromise) {
715
+ entry.infoPromise = (async () => {
716
+ const meta = await ensureMetadata(entry)
717
+
718
+ let rawOutline = null
719
+ try {
720
+ rawOutline = await entry.doc.getOutline()
721
+ } catch {
722
+ rawOutline = null
723
+ }
724
+
725
+ const outline =
726
+ Array.isArray(rawOutline) && rawOutline.length > 0
727
+ ? await flattenOutline(entry.doc, rawOutline, meta)
728
+ : []
729
+
730
+ const [documentMetadata, pageSize, textCoverage] = await Promise.all([
731
+ readDocumentMetadata(entry.doc),
732
+ readFirstPageSize(entry.doc, meta.pageCount),
733
+ sampleTextCoverage(entry.doc, meta.pageCount),
734
+ ])
735
+
736
+ return {
737
+ path: entry.path,
738
+ pageCount: meta.pageCount,
739
+ labels: meta.labels,
740
+ printedOffset: meta.printedOffset,
741
+ outline,
742
+ documentMetadata,
743
+ pageSize,
744
+ textCoverage,
745
+ }
746
+ })()
747
+ .then((info) => {
748
+ entry.info = info
749
+ return info
750
+ })
751
+ .catch((error) => {
752
+ entry.infoPromise = null
753
+ throw error
754
+ })
755
+ }
756
+ return entry.infoPromise
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // Public metadata API
761
+ // ---------------------------------------------------------------------------
762
+
763
+ /**
764
+ * Document info: page count, page labels with printed values, detected
765
+ * printed-page offset, and a flattened, fully resolved outline.
766
+ *
767
+ * @param {string} path
768
+ * @returns {Promise<Info>}
769
+ */
770
+ export async function getInfo(path) {
771
+ return withEntry(path, async (entry) => {
772
+ const info = await ensureInfo(entry)
773
+ // Clone arrays/objects so a caller mutating the result cannot poison the
774
+ // cache for the next caller.
775
+ return {
776
+ path: info.path,
777
+ pageCount: info.pageCount,
778
+ labels: info.labels.map((label) => ({ pdf: label.pdf, printed: label.printed })),
779
+ printedOffset: info.printedOffset,
780
+ outline: info.outline.map((entryInfo) => ({ ...entryInfo })),
781
+ documentMetadata: { ...info.documentMetadata },
782
+ pageSize: info.pageSize === null ? null : { ...info.pageSize },
783
+ textCoverage: { ...info.textCoverage },
784
+ }
785
+ })
786
+ }
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // Text extraction + search
790
+ // ---------------------------------------------------------------------------
791
+
792
+ function normalizePageText(raw) {
793
+ return raw.replace(/\s+/g, ' ').trim()
794
+ }
795
+
796
+ function normalizePdfPageNumber(value, pageCount) {
797
+ const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
798
+ if (!Number.isInteger(number) || number < 1 || number > pageCount) {
799
+ const error = new RangeError(
800
+ `PDF page must be an integer between 1 and ${pageCount} (got ${JSON.stringify(value)})`,
801
+ )
802
+ error.code = 'PDF_PAGE_OUT_OF_RANGE'
803
+ throw error
804
+ }
805
+ return number
806
+ }
807
+
808
+ /** @param {CacheEntry} entry @param {number} pdfPage */
809
+ async function extractPageText(entry, pdfPage) {
810
+ const page = await entry.doc.getPage(pdfPage)
811
+ try {
812
+ const content = await page.getTextContent()
813
+ const raw = content.items
814
+ .map((item) => (typeof item?.str === 'string' ? item.str : ''))
815
+ .join(' ')
816
+ return normalizePageText(raw)
817
+ } finally {
818
+ try {
819
+ page.cleanup()
820
+ } catch {
821
+ // cleanup is best-effort; it must never mask the extraction result
822
+ }
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Extract one page's text, whitespace-normalised to a single line. Joining
828
+ * text items with a space keeps words from different runs separate while
829
+ * making phrases such as "8.46 Jordan form" searchable.
830
+ *
831
+ * @param {string} path
832
+ * @param {number} pdfPage 1-based physical PDF page
833
+ * @returns {Promise<string>}
834
+ */
835
+ export async function getPageText(path, pdfPage) {
836
+ return withEntry(path, async (entry) => {
837
+ const pageNumber = normalizePdfPageNumber(pdfPage, entry.doc.numPages)
838
+ try {
839
+ return await extractPageText(entry, pageNumber)
840
+ } catch (error) {
841
+ const wrapped = new Error(
842
+ `Failed to extract text from PDF page ${pageNumber} of ${entry.path}: ${
843
+ error?.message || error
844
+ }`,
845
+ { cause: error },
846
+ )
847
+ wrapped.code = 'PDF_TEXT_EXTRACTION_FAILED'
848
+ throw wrapped
849
+ }
850
+ })
851
+ }
852
+
853
+ function escapeRegExp(value) {
854
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
855
+ }
856
+
857
+ function makeSnippet(text, index, matchLength, contextChars) {
858
+ const start = Math.max(0, index - contextChars)
859
+ const end = Math.min(text.length, index + matchLength + contextChars)
860
+ let snippet = text.slice(start, end).trim()
861
+ if (start > 0) snippet = `…${snippet}`
862
+ if (end < text.length) snippet = `${snippet}…`
863
+ return snippet
864
+ }
865
+
866
+ function normalizeSearchOptions(options) {
867
+ const opts = options && typeof options === 'object' ? options : {}
868
+ const ignoreCase = opts.ignoreCase !== false
869
+
870
+ const limit = opts.limit === undefined ? 200 : opts.limit
871
+ if (!Number.isInteger(limit) || limit < 1) {
872
+ throw new RangeError(`searchPdf: limit must be a positive integer (got ${String(opts.limit)})`)
873
+ }
874
+
875
+ const contextChars = opts.contextChars === undefined ? 80 : opts.contextChars
876
+ if (!Number.isInteger(contextChars) || contextChars < 0) {
877
+ throw new RangeError(
878
+ `searchPdf: contextChars must be a non-negative integer (got ${String(opts.contextChars)})`,
879
+ )
880
+ }
881
+
882
+ return { ignoreCase, limit, contextChars }
883
+ }
884
+
885
+ /**
886
+ * Literal full-text search, one page at a time so the event loop is not
887
+ * blocked for the whole book. Returns physical and printed page numbers.
888
+ *
889
+ * `indexOnPage` is a UTF-16 offset into the same whitespace-normalised text
890
+ * that getPageText() returns.
891
+ *
892
+ * @param {string} path
893
+ * @param {string} query
894
+ * @param {{ignoreCase?: boolean, limit?: number, contextChars?: number}} [options]
895
+ * @returns {Promise<{hits: Array<{pdf:number, printed:number|string|null,
896
+ * indexOnPage:number, snippet:string}>, pages:number}>}
897
+ */
898
+ export async function searchPdf(path, query, options) {
899
+ if (typeof query !== 'string' || query.trim() === '') {
900
+ throw new TypeError('searchPdf: query must be a non-empty string')
901
+ }
902
+
903
+ const needle = normalizePageText(query)
904
+ const { ignoreCase, limit, contextChars } = normalizeSearchOptions(options)
905
+ const pattern = new RegExp(escapeRegExp(needle), ignoreCase ? 'gi' : 'g')
906
+
907
+ return withEntry(path, async (entry) => {
908
+ const meta = await ensureMetadata(entry)
909
+ const hits = []
910
+ let pages = 0
911
+
912
+ for (let pdfPage = 1; pdfPage <= entry.doc.numPages; pdfPage += 1) {
913
+ let text
914
+ try {
915
+ text = await extractPageText(entry, pdfPage)
916
+ } catch (error) {
917
+ const wrapped = new Error(
918
+ `searchPdf: failed to extract text from page ${pdfPage} of ${entry.path}: ${
919
+ error?.message || error
920
+ }`,
921
+ { cause: error },
922
+ )
923
+ wrapped.code = 'PDF_TEXT_EXTRACTION_FAILED'
924
+ throw wrapped
925
+ }
926
+
927
+ pattern.lastIndex = 0
928
+ let match
929
+ let pageCounted = false
930
+ while ((match = pattern.exec(text)) !== null) {
931
+ if (!pageCounted) {
932
+ pages += 1
933
+ pageCounted = true
934
+ }
935
+ if (hits.length < limit) {
936
+ hits.push({
937
+ pdf: pdfPage,
938
+ printed: printedForPdf(meta, pdfPage),
939
+ indexOnPage: match.index,
940
+ snippet: makeSnippet(text, match.index, match[0].length, contextChars),
941
+ })
942
+ }
943
+ // A zero-length match is impossible (needle is non-empty), but guard
944
+ // anyway so a future change cannot spin forever.
945
+ if (match[0].length === 0) pattern.lastIndex += 1
946
+ }
947
+ }
948
+
949
+ return { hits, pages }
950
+ })
951
+ }
952
+
953
+ // ---------------------------------------------------------------------------
954
+ // Internal exports for render.js (not part of the host-facing interface)
955
+ // ---------------------------------------------------------------------------
956
+
957
+ export {
958
+ acquire as __acquireDocument,
959
+ releaseEntry as __releaseDocument,
960
+ ensureMetadata as __ensureMetadata,
961
+ normalizePdfPageNumber as __normalizePdfPage,
962
+ }