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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/SKILL.md +96 -0
- package/cordis.patch.yml +30 -0
- package/icon.svg +13 -0
- package/lib/client.js +1773 -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 +1850 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +83 -4
|
@@ -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
|
+
}
|