-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpdf.ts
More file actions
296 lines (278 loc) · 12.3 KB
/
Copy pathpdf.ts
File metadata and controls
296 lines (278 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/**
* Browser/renderer PDF adapter — pdf.js via canvas. The pdf.js module is
* injected (not imported) so the host (web harness or Obsidian plugin) controls
* the version and worker wiring. Implements the same PageSource the Node adapter
* does, so core/convert.ts runs unchanged.
*
* Runs on a document *or* inside a Web Worker; the two differ enough that the
* differences are handled here rather than by each host (see `docParams`).
*/
import type { PageSource, RenderedPage, TextToken } from "../core/types.js";
/** Render scale; ~2x mirrors the Node path (images_scale=2.0). */
const RENDER_SCALE = 2.0;
type AnyCanvas = OffscreenCanvas | HTMLCanvasElement;
function makeCanvas(w: number, h: number): { canvas: AnyCanvas; ctx: any } {
if (typeof OffscreenCanvas !== "undefined") {
const canvas = new OffscreenCanvas(w, h);
return { canvas, ctx: canvas.getContext("2d") };
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
return { canvas, ctx: canvas.getContext("2d") };
}
/**
* Run `fn` with `requestAnimationFrame` routed to `setTimeout`.
*
* pdf.js renders a page in chunks and schedules each continuation with
* `window.requestAnimationFrame` (`_scheduleNext`, display intent only).
* Chromium never fires rAF while the document is hidden, so in a minimized,
* occluded or background Obsidian window `page.render()` simply never resolves:
* no error, no CPU, no timeout — the conversion parks forever on whatever page
* was in flight when the user switched away. (`RenderTask.onContinue` is not an
* escape hatch: the continuation it hands back schedules through rAF too.)
*
* Nothing here is on screen — we rasterize to an offscreen canvas and read the
* pixels straight back — so frame timing buys us nothing, and `setTimeout`
* keeps rendering progressing regardless of window state. `cancelAnimationFrame`
* is swapped too so pdf.js can still cancel a render mid-flight.
*/
async function withTimerScheduling<T>(fn: () => Promise<T>): Promise<T> {
const g = globalThis as any;
const raf = g.requestAnimationFrame;
// Node has no rAF; pdf.js takes its microtask path there and needs no help.
// A Web Worker is *not* such a case, tempting as it is to assume: Chromium
// exposes `requestAnimationFrame` on DedicatedWorkerGlobalScope (measured in
// Obsidian's worker: present, and it fires). Whether it keeps firing when the
// window is hidden — the condition that caused the original stall — is
// untested, so the shim stays on in workers too rather than resting on it.
if (typeof raf !== "function") return fn();
const caf = g.cancelAnimationFrame;
g.requestAnimationFrame = (cb: (t: number) => void) =>
setTimeout(() => cb(performance.now()), 0) as unknown as number;
g.cancelAnimationFrame = (id: number) => clearTimeout(id);
try {
return await fn();
} finally {
g.requestAnimationFrame = raf;
g.cancelAnimationFrame = caf;
}
}
/**
* pdf.js scratch canvases, made without a `document`.
*
* `page.render()` draws into the context we hand it, but transparency groups,
* soft masks and tiling patterns make their *own* intermediate canvases through
* a CanvasFactory — and the default one is `DOMCanvasFactory`, i.e.
* `document.createElement("canvas")`. In a worker there is no document, so any
* page using those features would throw. The interface pdf.js actually consumes
* is just create/reset/destroy (`BaseCanvasFactory`), so an OffscreenCanvas
* implementation is a drop-in.
*/
class OffscreenCanvasFactory {
create(width: number, height: number) {
if (width <= 0 || height <= 0) throw new Error("Invalid canvas size");
const canvas = new OffscreenCanvas(width, height);
return { canvas, context: canvas.getContext("2d", { willReadFrequently: true }) };
}
reset(entry: { canvas: OffscreenCanvas | null }, width: number, height: number): void {
if (!entry.canvas) throw new Error("Canvas is not specified");
if (width <= 0 || height <= 0) throw new Error("Invalid canvas size");
entry.canvas.width = width;
entry.canvas.height = height;
}
destroy(entry: { canvas: OffscreenCanvas | null; context: unknown }): void {
if (!entry.canvas) throw new Error("Canvas is not specified");
entry.canvas.width = 0;
entry.canvas.height = 0;
entry.canvas = null;
entry.context = null;
}
}
/**
* pdf.js's DOM filter factory builds `<svg><filter>` elements to apply image
* masks and colour transforms. There is nothing to build them in inside a
* worker; "none" is the documented no-op every method may return, and it is
* exactly what pdf.js's own Node factory does — the same path the fixture-
* validated CPU engine runs on.
*/
class NoopFilterFactory {
addFilter(): string {
return "none";
}
addHCMFilter(): string {
return "none";
}
addAlphaFilter(): string {
return "none";
}
addLuminosityFilter(): string {
return "none";
}
addHighlightHCMFilter(): string {
return "none";
}
destroy(): void {}
}
export interface LoadPdfBrowserOptions {
/**
* Where pdf.js fetches the standard 14 font programs (Times, Helvetica,
* Courier…), e.g. `.../pdfjs-dist@4.10.38/standard_fonts/`.
*
* **Required when there is no `document`**, and quietly destructive without
* it. Rendering then runs with `disableFontFace`, so pdf.js rasterizes those
* fonts from their own outlines instead of handing them to the platform font
* stack — and with no source for the outlines it drops the glyphs one by one
* (`getPathGenerator - ignoring character`) and rasterizes a page with holes
* in the text. That is invisible downstream: the VLM simply reads a page
* missing words, and the only symptom is slightly short output.
*/
standardFontDataUrl?: string;
/** CMap pack location — needed for CJK and other encoded fonts. */
cMapUrl?: string;
}
/**
* `getDocument` parameters for this host.
*
* Font handling matters as much as the canvas: with `disableFontFace` left at
* its browser default, pdf.js registers `FontFace`s against `document.fonts`.
* Turning it off makes pdf.js draw glyph outlines onto the canvas instead —
* which is what the Node adapter does, so the rasterization the VLM sees stays
* the one the fixture suite validated.
*/
function docParams(opts: LoadPdfBrowserOptions): Record<string, unknown> {
/**
* Never compile PDF-supplied code.
*
* pdf.js turns Type 4 (PostScript calculator) shading functions into
* JavaScript with `new Function(src, …)` when it can, and falls back to an
* interpreter when it can't. The speed only matters for gradient-heavy
* artwork, and we rasterize once per page; a document conversion tool
* compiling expressions out of the document it was handed is a bad trade at
* any speed, and it is what Obsidian's review flags as dynamic code
* execution. Applies on *both* paths — the renderer fallback runs the same
* pdf.js as the worker.
*/
const shared = { isEvalSupported: false };
if (typeof document !== "undefined") return shared;
return {
...shared,
CanvasFactory: OffscreenCanvasFactory,
FilterFactory: NoopFilterFactory,
disableFontFace: true,
useSystemFonts: false,
standardFontDataUrl: opts.standardFontDataUrl,
cMapUrl: opts.cMapUrl,
cMapPacked: true,
// Not optional here. Left to its default, pdf.js *computes* this flag from
// `isValidFetchUrl(cMapUrl, document.baseURI)` — a bare `document` reference
// that throws ReferenceError off-main-thread, and only once both URLs above
// are supplied, so it hides behind the very fix it accompanies. Fetching the
// sidecars from pdf.js's own worker is the right answer anyway: they are
// absolute URLs and need no base to resolve against.
useWorkerFetch: true,
};
}
async function canvasToPng(canvas: AnyCanvas): Promise<Uint8Array> {
let blob: Blob;
if ("convertToBlob" in canvas) {
blob = await canvas.convertToBlob({ type: "image/png" });
} else {
blob = await new Promise<Blob>((res, rej) =>
canvas.toBlob((b) => (b ? res(b) : rej(new Error("toBlob failed"))), "image/png"),
);
}
return new Uint8Array(await blob.arrayBuffer());
}
function parsePublished(creationDate: string | undefined): string | undefined {
if (!creationDate) return undefined;
const m = /^D:(\d{4})(\d{2})(\d{2})/.exec(creationDate);
return m ? `${m[1]}-${m[2]}-${m[3]}` : undefined;
}
export async function loadPdfBrowser(
pdfjs: any,
data: Uint8Array,
opts: LoadPdfBrowserOptions = {},
): Promise<PageSource> {
// Refuse to render blind rather than render badly. Omitting this URL off-main-
// thread costs glyphs, not an exception — pdf.js warns per dropped character
// and returns a page with holes in it, which reaches the VLM as a page that
// simply says less. Nothing downstream can tell that from a sparse page.
if (typeof document === "undefined" && !opts.standardFontDataUrl) {
throw new Error(
"loadPdfBrowser: standardFontDataUrl is required when running without a document " +
"(pdf.js rasterizes the standard 14 fonts itself here and silently drops glyphs without it)",
);
}
const doc = await pdfjs.getDocument({ data, isEvalSupported: false, ...docParams(opts) }).promise;
let title: string | undefined;
let author: string | undefined;
let published: string | undefined;
let description: string | undefined;
try {
const info = (await doc.getMetadata())?.info ?? {};
title = (info.Title || "").trim() || undefined;
author = (info.Author || "").trim() || undefined;
description = (info.Subject || "").trim() || undefined;
published = parsePublished(info.CreationDate);
} catch {
/* arXiv PDFs often carry no metadata */
}
return {
pageCount: doc.numPages,
meta: { title, author, published, description },
async renderPage(index: number): Promise<RenderedPage> {
const page = await doc.getPage(index);
const viewport = page.getViewport({ scale: RENDER_SCALE });
const width = Math.ceil(viewport.width);
const height = Math.ceil(viewport.height);
const { canvas, ctx } = makeCanvas(width, height);
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
await withTimerScheduling(() => page.render({ canvasContext: ctx, viewport }).promise);
const rgba = ctx.getImageData(0, 0, width, height).data as Uint8ClampedArray;
const content = await page.getTextContent();
const textTokens: TextToken[] = [];
// Normalized against the *unscaled* page, not the raster. A text item's
// transform and its width/height are PDF user space, which does not know
// RENDER_SCALE exists; dividing them by a scale-2 viewport put every token
// at half its true position — the title of a paper, visibly at the top of
// the page, was recorded at t=0.55. Nothing consumed these coordinates
// until the text-layer rescue in core/textlayer.ts, which compares them
// against bounding boxes that *are* in page fractions, so the error stayed
// invisible for as long as it was harmless.
const unscaled = page.getViewport({ scale: 1 });
for (const item of content.items) {
if (!("str" in item) || !item.str) continue;
const tr = item.transform as number[];
const e = tr[4] ?? 0;
const f = tr[5] ?? 0;
const x = e / unscaled.width;
const yBottom = f / unscaled.height;
const th = (item.height || 0) / unscaled.height;
const tw = (item.width || 0) / unscaled.width;
const top = 1 - yBottom - th;
textTokens.push({ str: item.str, bbox: { l: x, t: top, r: x + tw, b: top + th } });
}
return {
index,
width,
height,
rgba,
textTokens,
async crop(bbox): Promise<Uint8Array> {
const cx = Math.max(0, Math.floor(bbox.l * width));
const cy = Math.max(0, Math.floor(bbox.t * height));
const cw = Math.min(width - cx, Math.ceil((bbox.r - bbox.l) * width));
const ch = Math.min(height - cy, Math.ceil((bbox.b - bbox.t) * height));
const { canvas: out, ctx: octx } = makeCanvas(Math.max(1, cw), Math.max(1, ch));
octx.drawImage(canvas as any, cx, cy, cw, ch, 0, 0, cw, ch);
return canvasToPng(out);
},
};
},
async destroy() {
await doc.destroy();
},
};
}