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
package/lib/core/pdf.js
ADDED
|
@@ -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
|
+
}
|