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,351 @@
1
+ /**
2
+ * dsh-pdf-reader core / render.js
3
+ *
4
+ * Render PDF pages to PNG in-process with pdf.js + @napi-rs/canvas. No
5
+ * Swift, no shelling out, no browser. The host hands the returned Buffer to
6
+ * its attachment service and reports width/height to the model.
7
+ *
8
+ * Why a hand-written PNG encoder? @napi-rs/canvas encodes RGBA PNGs (PNG
9
+ * color type 6). The host contract asks for RGB; this encoder reads the
10
+ * canvas pixels once and emits an 8-bit RGB PNG (color type 2) on a white
11
+ * background. It is also a little smaller and just as fast for text pages.
12
+ */
13
+
14
+ import { createCanvas } from '@napi-rs/canvas'
15
+ import { crc32, deflateSync } from 'node:zlib'
16
+ import {
17
+ __acquireDocument,
18
+ __ensureMetadata,
19
+ __normalizePdfPage,
20
+ __releaseDocument,
21
+ } from './pdf.js'
22
+
23
+ const DEFAULT_SCALE = 2
24
+ const MIN_SCALE = 1
25
+ const MAX_SCALE = 3
26
+
27
+ /** Render at most two pages at a time; a full-page canvas is several MB. */
28
+ const MAX_RENDER_CONCURRENCY = 2
29
+
30
+ /** Guard against pathological page sizes at scale 3 (~200 MB RGBA). */
31
+ const MAX_CANVAS_PIXELS = 50_000_000
32
+
33
+ /** Per-page render timeout: a hostile PDF must not hang the host forever. */
34
+ const RENDER_TIMEOUT_MS = 60_000
35
+
36
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
37
+ const IDAT_LEVEL = 6
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Small render semaphore (module-wide, so concurrent single-page requests
41
+ // also respect the memory limit)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ let activeRenders = 0
45
+ /** @type {Array<() => void>} */
46
+ const renderWaiters = []
47
+
48
+ function acquireRenderSlot() {
49
+ if (activeRenders < MAX_RENDER_CONCURRENCY) {
50
+ activeRenders += 1
51
+ return Promise.resolve()
52
+ }
53
+ return new Promise((resolve) => {
54
+ renderWaiters.push(resolve)
55
+ })
56
+ }
57
+
58
+ function releaseRenderSlot() {
59
+ activeRenders -= 1
60
+ const next = renderWaiters.shift()
61
+ if (next) {
62
+ // Hand the slot directly to the waiter, incrementing before it can resume.
63
+ activeRenders += 1
64
+ next()
65
+ }
66
+ }
67
+
68
+ async function withRenderSlot(fn) {
69
+ await acquireRenderSlot()
70
+ try {
71
+ return await fn()
72
+ } finally {
73
+ releaseRenderSlot()
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Option validation
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function normalizeScale(value) {
82
+ const scale = value === undefined ? DEFAULT_SCALE : value
83
+ if (typeof scale !== 'number' || !Number.isFinite(scale) || scale < MIN_SCALE || scale > MAX_SCALE) {
84
+ throw new RangeError(
85
+ `renderPageToPng: scale must be a number between ${MIN_SCALE} and ${MAX_SCALE} (got ${String(
86
+ value,
87
+ )})`,
88
+ )
89
+ }
90
+ return scale
91
+ }
92
+
93
+ function normalizeCrop(value) {
94
+ if (value === undefined || value === null) return null
95
+ if (typeof value !== 'object' || Array.isArray(value)) {
96
+ throw new TypeError('renderPageToPng: crop must be an object {x,y,w,h} of page fractions')
97
+ }
98
+
99
+ const x = value.x === undefined ? 0 : value.x
100
+ const y = value.y === undefined ? 0 : value.y
101
+ const w = value.w === undefined ? 1 - x : value.w
102
+ const h = value.h === undefined ? 1 - y : value.h
103
+
104
+ for (const [name, number] of [
105
+ ['x', x],
106
+ ['y', y],
107
+ ['w', w],
108
+ ['h', h],
109
+ ]) {
110
+ if (typeof number !== 'number' || !Number.isFinite(number)) {
111
+ throw new TypeError(`renderPageToPng: crop.${name} must be a finite number`)
112
+ }
113
+ }
114
+
115
+ const epsilon = 1e-9
116
+ if (x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > 1 + epsilon || y + h > 1 + epsilon) {
117
+ throw new RangeError(
118
+ `renderPageToPng: crop must satisfy x,y >= 0, w,h > 0, x+w <= 1, y+h <= 1 (got ${JSON.stringify(
119
+ { x, y, w, h },
120
+ )})`,
121
+ )
122
+ }
123
+
124
+ return { x, y, w, h }
125
+ }
126
+
127
+ function normalizeRenderOptions(options) {
128
+ const opts = options && typeof options === 'object' ? options : {}
129
+ return {
130
+ scale: normalizeScale(opts.scale),
131
+ crop: normalizeCrop(opts.crop),
132
+ }
133
+ }
134
+
135
+ function cropRectInPixels(crop, fullWidth, fullHeight) {
136
+ if (!crop) return { x: 0, y: 0, width: fullWidth, height: fullHeight }
137
+
138
+ const left = Math.min(fullWidth - 1, Math.max(0, Math.round(fullWidth * crop.x)))
139
+ const top = Math.min(fullHeight - 1, Math.max(0, Math.round(fullHeight * crop.y)))
140
+ const right = Math.min(fullWidth, Math.max(left + 1, Math.round(fullWidth * (crop.x + crop.w))))
141
+ const bottom = Math.min(
142
+ fullHeight,
143
+ Math.max(top + 1, Math.round(fullHeight * (crop.y + crop.h))),
144
+ )
145
+ return { x: left, y: top, width: right - left, height: bottom - top }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // RGB PNG encoding
150
+ // ---------------------------------------------------------------------------
151
+
152
+ function pngChunk(type, data) {
153
+ const chunk = Buffer.allocUnsafe(12 + data.length)
154
+ chunk.writeUInt32BE(data.length, 0)
155
+ chunk.write(type, 4, 'ascii')
156
+ data.copy(chunk, 8)
157
+ const checksum = crc32(chunk.subarray(4, 8 + data.length)) >>> 0
158
+ chunk.writeUInt32BE(checksum, 8 + data.length)
159
+ return chunk
160
+ }
161
+
162
+ /**
163
+ * Encode a canvas sub-rectangle as an 8-bit RGB PNG.
164
+ *
165
+ * @param {import('@napi-rs/canvas').SKRSContext2D} context
166
+ * @returns {Buffer}
167
+ */
168
+ function encodeRgbPng(context, x, y, width, height) {
169
+ const { data } = context.getImageData(x, y, width, height) // RGBA
170
+ const rowStride = width * 3
171
+ const raw = Buffer.allocUnsafe(height * (rowStride + 1))
172
+
173
+ let out = 0
174
+ let src = 0
175
+ for (let row = 0; row < height; row += 1) {
176
+ raw[out] = 0 // PNG filter: None; white text pages compress well anyway
177
+ out += 1
178
+ for (let column = 0; column < width; column += 1) {
179
+ raw[out] = data[src]
180
+ raw[out + 1] = data[src + 1]
181
+ raw[out + 2] = data[src + 2]
182
+ out += 3
183
+ src += 4 // skip alpha; the canvas was filled opaque white
184
+ }
185
+ }
186
+
187
+ const header = Buffer.alloc(13)
188
+ header.writeUInt32BE(width, 0)
189
+ header.writeUInt32BE(height, 4)
190
+ header[8] = 8 // bit depth
191
+ header[9] = 2 // color type: truecolour RGB
192
+ header[10] = 0 // compression
193
+ header[11] = 0 // filter method
194
+ header[12] = 0 // interlace
195
+
196
+ const idat = deflateSync(raw, { level: IDAT_LEVEL })
197
+
198
+ return Buffer.concat([
199
+ PNG_SIGNATURE,
200
+ pngChunk('IHDR', header),
201
+ pngChunk('IDAT', idat),
202
+ pngChunk('IEND', Buffer.alloc(0)),
203
+ ])
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Render one page
208
+ // ---------------------------------------------------------------------------
209
+
210
+ async function renderOnePage(doc, meta, pdfPage, scale, crop) {
211
+ const page = await doc.getPage(pdfPage)
212
+ const viewport = page.getViewport({ scale })
213
+ const fullWidth = Math.ceil(viewport.width)
214
+ const fullHeight = Math.ceil(viewport.height)
215
+
216
+ if (
217
+ !Number.isFinite(fullWidth) ||
218
+ !Number.isFinite(fullHeight) ||
219
+ fullWidth < 1 ||
220
+ fullHeight < 1
221
+ ) {
222
+ throw new Error(`Page ${pdfPage} has an invalid viewport (${viewport.width}x${viewport.height})`)
223
+ }
224
+ if (fullWidth * fullHeight > MAX_CANVAS_PIXELS) {
225
+ throw new RangeError(
226
+ `Page ${pdfPage} at scale ${scale} would be ${fullWidth}x${fullHeight} ` +
227
+ `(${fullWidth * fullHeight} pixels), above the ${MAX_CANVAS_PIXELS} pixel safety cap`,
228
+ )
229
+ }
230
+
231
+ const canvas = createCanvas(fullWidth, fullHeight)
232
+ const context = canvas.getContext('2d')
233
+
234
+ // Opaque white background before pdf.js paints anything.
235
+ context.fillStyle = '#ffffff'
236
+ context.fillRect(0, 0, fullWidth, fullHeight)
237
+
238
+ let renderTimer
239
+ try {
240
+ await Promise.race([
241
+ page.render({
242
+ canvasContext: context,
243
+ viewport,
244
+ background: '#ffffff',
245
+ }).promise,
246
+ new Promise((_resolve, reject) => {
247
+ renderTimer = setTimeout(() => {
248
+ const error = new Error(
249
+ `Page ${pdfPage} took longer than ${RENDER_TIMEOUT_MS} ms to render and was abandoned`,
250
+ )
251
+ error.code = 'PDF_RENDER_TIMEOUT'
252
+ reject(error)
253
+ }, RENDER_TIMEOUT_MS)
254
+ if (typeof renderTimer.unref === 'function') renderTimer.unref()
255
+ }),
256
+ ])
257
+ } finally {
258
+ if (renderTimer !== undefined) clearTimeout(renderTimer)
259
+ }
260
+
261
+ // Drop the page's operator list/font objects now that the pixels exist.
262
+ // Without this, rendering dozens of pages retains tens of MB of pdf.js
263
+ // page state for the lifetime of the cached document. It is safe while
264
+ // another render of the same page is in flight: cleanup() refuses to run
265
+ // until the page's render-task set is empty.
266
+ try {
267
+ page.cleanup()
268
+ } catch {
269
+ // best-effort only
270
+ }
271
+
272
+ const rect = cropRectInPixels(crop, fullWidth, fullHeight)
273
+ const buffer = encodeRgbPng(context, rect.x, rect.y, rect.width, rect.height)
274
+
275
+ return {
276
+ pdf: pdfPage,
277
+ printed: meta.labels[pdfPage - 1]?.printed ?? null,
278
+ buffer,
279
+ width: rect.width,
280
+ height: rect.height,
281
+ }
282
+ }
283
+
284
+ async function mapWithConcurrency(items, limit, fn) {
285
+ const results = new Array(items.length)
286
+ let nextIndex = 0
287
+
288
+ async function worker() {
289
+ while (nextIndex < items.length) {
290
+ const index = nextIndex
291
+ nextIndex += 1
292
+ results[index] = await fn(items[index], index)
293
+ }
294
+ }
295
+
296
+ // allSettled: if one page fails, let the other in-flight renders finish
297
+ // before the document is released and destroyed.
298
+ const settled = await Promise.allSettled(
299
+ Array.from({ length: Math.min(limit, items.length) }, () => worker()),
300
+ )
301
+ const failure = settled.find((result) => result.status === 'rejected')
302
+ if (failure) throw failure.reason
303
+ return results
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Public API
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * Render one 1-based physical PDF page to an RGB PNG Buffer.
312
+ *
313
+ * @param {string} path
314
+ * @param {number} pdfPage
315
+ * @param {{scale?: number, crop?: {x?:number,y?:number,w?:number,h?:number}}} [options]
316
+ * @returns {Promise<{pdf:number, printed:number|string|null, buffer:Buffer,
317
+ * width:number, height:number}>}
318
+ */
319
+ export async function renderPageToPng(path, pdfPage, options) {
320
+ const [result] = await renderPagesToPng(path, [pdfPage], options)
321
+ return result
322
+ }
323
+
324
+ /**
325
+ * Render several pages, preserving input order, with a small concurrency
326
+ * limit. `crop` and `scale` apply to every page.
327
+ *
328
+ * @param {string} path
329
+ * @param {number[]|number} pages 1-based physical PDF pages
330
+ * @param {{scale?: number, crop?: {x?:number,y?:number,w?:number,h?:number}}} [options]
331
+ * @returns {Promise<Array<{pdf:number, printed:number|string|null, buffer:Buffer,
332
+ * width:number, height:number}>>}
333
+ */
334
+ export async function renderPagesToPng(path, pages, options) {
335
+ const { scale, crop } = normalizeRenderOptions(options)
336
+
337
+ const list = Array.isArray(pages) ? pages.slice() : [pages]
338
+ if (list.length === 0) return []
339
+
340
+ const entry = await __acquireDocument(path)
341
+ try {
342
+ const meta = await __ensureMetadata(entry)
343
+ const pageNumbers = list.map((pdfPage) => __normalizePdfPage(pdfPage, entry.doc.numPages))
344
+
345
+ return await mapWithConcurrency(pageNumbers, MAX_RENDER_CONCURRENCY, (pdfPage) =>
346
+ withRenderSlot(() => renderOnePage(entry.doc, meta, pdfPage, scale, crop)),
347
+ )
348
+ } finally {
349
+ __releaseDocument(entry)
350
+ }
351
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * DeepSeek vision token accounting — a verbatim port of the provider's
3
+ * published calculator as implemented by DSH in
4
+ * `@deepseek-ai/dsh-llm-deepseek` (`image-tokens` module, `v41` config).
5
+ *
6
+ * An image is scaled up below 544x544, aligned to a 14px patch grid,
7
+ * downsampled 3:1 per axis into token cells, and capped at 1024 tokens.
8
+ * tokens = gridHeight * (gridWidth + 1) + 2
9
+ */
10
+
11
+ const PATCH_SIZE = 14
12
+ const DOWNSAMPLE_RATIO = 3
13
+ const MAX_IMAGE_TOKENS = 1024
14
+ const MIN_PIXELS = 544 * 544
15
+ const CELL_SIZE = PATCH_SIZE * DOWNSAMPLE_RATIO
16
+
17
+ const intDiv = (value, divisor) => Math.floor(value / divisor)
18
+ const ceilDiv = (value, divisor) => Math.floor((value + divisor - 1) / divisor)
19
+ const gridTokens = (gridHeight, gridWidth) => gridHeight * (gridWidth + 1) + 2
20
+ const gridCells = (paddedLength) => ceilDiv(intDiv(paddedLength, PATCH_SIZE), DOWNSAMPLE_RATIO)
21
+
22
+ function solveResizeRatio(height, width, budget) {
23
+ const aspect = height / width
24
+ const idealGridWidth = Math.sqrt((budget - 2) / aspect + 0.25) - 0.5
25
+ const idealGridHeight = idealGridWidth * aspect
26
+ let bestHeight
27
+ let bestWidth
28
+ if (idealGridWidth < 1) {
29
+ bestWidth = CELL_SIZE
30
+ bestHeight = intDiv(budget - 2, 2) * CELL_SIZE
31
+ } else if (idealGridHeight < 1) {
32
+ bestWidth = (intDiv(budget - 2, 1) - 1) * CELL_SIZE
33
+ bestHeight = CELL_SIZE
34
+ } else {
35
+ const gridWidth = Math.trunc(idealGridWidth)
36
+ const gridHeight = Math.trunc(idealGridHeight)
37
+ const scale = Math.min((gridWidth * CELL_SIZE) / width, (gridHeight * CELL_SIZE) / height)
38
+ bestWidth = Math.trunc((width * scale) / PATCH_SIZE) * PATCH_SIZE
39
+ bestHeight = Math.trunc((height * scale) / PATCH_SIZE) * PATCH_SIZE
40
+ }
41
+ const gh = gridCells(bestHeight)
42
+ const gw = gridCells(bestWidth)
43
+ return { gridHeight: gh, gridWidth: gw, bestHeight, bestWidth, numTokens: gridTokens(gh, gw) }
44
+ }
45
+
46
+ function safeResize(height, width, paddedHeight, paddedWidth) {
47
+ const gridHeight = gridCells(paddedHeight)
48
+ const gridWidth = gridCells(paddedWidth)
49
+ const direct = {
50
+ gridHeight,
51
+ gridWidth,
52
+ bestHeight: paddedHeight,
53
+ bestWidth: paddedWidth,
54
+ numTokens: gridTokens(gridHeight, gridWidth),
55
+ }
56
+ if (direct.numTokens <= MAX_IMAGE_TOKENS) return direct
57
+ return solveResizeRatio(height, width, MAX_IMAGE_TOKENS)
58
+ }
59
+
60
+ function resizeOnce(width, height) {
61
+ let scaledWidth = width
62
+ let scaledHeight = height
63
+ const pixels = scaledWidth * scaledHeight
64
+ if (pixels < MIN_PIXELS && pixels > 0) {
65
+ const scale = Math.sqrt(MIN_PIXELS / pixels)
66
+ scaledWidth = Math.trunc(scaledWidth * scale)
67
+ scaledHeight = Math.trunc(scaledHeight * scale)
68
+ }
69
+ const paddedWidth = ceilDiv(scaledWidth, PATCH_SIZE) * PATCH_SIZE
70
+ const paddedHeight = ceilDiv(scaledHeight, PATCH_SIZE) * PATCH_SIZE
71
+ return safeResize(scaledHeight, scaledWidth, paddedHeight, paddedWidth)
72
+ }
73
+
74
+ const sameResize = (a, b) =>
75
+ a.gridHeight === b.gridHeight &&
76
+ a.gridWidth === b.gridWidth &&
77
+ a.bestHeight === b.bestHeight &&
78
+ a.bestWidth === b.bestWidth &&
79
+ a.numTokens === b.numTokens
80
+
81
+ /**
82
+ * Exact DeepSeek vision-token count for one image of these pixel dimensions.
83
+ * @param {number} width - image width in pixels.
84
+ * @param {number} height - image height in pixels.
85
+ * @returns {{numTokens: number, gridWidth: number, gridHeight: number}}
86
+ */
87
+ export function estimateImageTokens(width, height) {
88
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
89
+ return { numTokens: 0, gridWidth: 0, gridHeight: 0 }
90
+ }
91
+ let current = resizeOnce(width, height)
92
+ for (let i = 0; i < 8; i += 1) {
93
+ const next = resizeOnce(current.bestWidth, current.bestHeight)
94
+ if (sameResize(current, next)) return { numTokens: next.numTokens, gridWidth: next.gridWidth, gridHeight: next.gridHeight }
95
+ current = next
96
+ }
97
+ return { numTokens: current.numTokens, gridWidth: current.gridWidth, gridHeight: current.gridHeight }
98
+ }
99
+
100
+ /**
101
+ * Rough text-token estimate for the cheap tools (~4 chars/token for English
102
+ * prose; CJK is closer to 1-1.5). Deliberately conservative: it is used only
103
+ * for cost reporting, never for billing.
104
+ * @param {string} text - text to estimate.
105
+ * @returns {number} approximate token count.
106
+ */
107
+ export function estimateTextTokens(text) {
108
+ if (typeof text !== 'string' || text.length === 0) return 0
109
+ let ascii = 0
110
+ let wide = 0
111
+ for (const ch of text) {
112
+ if (ch.codePointAt(0) > 0x2e7f) wide += 1
113
+ else ascii += 1
114
+ }
115
+ return Math.ceil(ascii / 4) + wide
116
+ }