pretext-pdfjs 0.1.0 → 0.2.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/README.md CHANGED
@@ -86,6 +86,53 @@ await TextLayer.enableReflow(container, fullText, {
86
86
  });
87
87
  ```
88
88
 
89
+ ### Per-block reflow (images + text)
90
+
91
+ The reflow module bridges PDF mode (images preserved, no reflow) and reader modes (text reflows, images stripped). Text blocks reflow with Pretext at the target font size while images and vector graphics render as scaled bitmaps in their original positions.
92
+
93
+ ```js
94
+ import { createReflowRenderer } from "pretext-pdfjs/reflow";
95
+
96
+ const renderer = createReflowRenderer(container, {
97
+ fontSize: 16,
98
+ fontFamily: '"Literata", Georgia, serif',
99
+ lineHeight: 1.6,
100
+ padding: 24,
101
+ background: "#f4f1eb",
102
+ textColor: "#252320",
103
+ imageFit: "proportional", // "proportional" | "original" | "full-width"
104
+ enablePinchZoom: true,
105
+ enableMomentumScroll: true,
106
+ onZoom: (fontSize) => console.log("Font size:", fontSize),
107
+ onPageReady: ({ pageNum, textBlocks, graphicRegions }) => {
108
+ console.log(`Page ${pageNum}: ${textBlocks.length} text blocks, ${graphicRegions.length} graphics`);
109
+ },
110
+ });
111
+
112
+ await renderer.open("document.pdf");
113
+ await renderer.showPage(1); // single page
114
+ // or: await renderer.showAll(); // all pages concatenated
115
+
116
+ renderer.nextPage();
117
+ renderer.prevPage();
118
+
119
+ // Read-only properties
120
+ renderer.currentPage; // number
121
+ renderer.numPages; // number
122
+ renderer.canvas; // HTMLCanvasElement
123
+ renderer.regions; // { text: [...], graphic: [...] }
124
+
125
+ renderer.destroy();
126
+ ```
127
+
128
+ **How it works:**
129
+
130
+ 1. **Analyze** — extracts text blocks (grouped by proximity) and graphic regions (images, vector paths) from the PDF page via `getTextContent()` and `getOperatorList()`. Renders the full page to an offscreen canvas and captures bitmap snippets for each graphic region.
131
+ 2. **Reflow** — each text block is reflowed with Pretext's `prepareWithSegments()` + `layoutWithLines()` at the current font size. Graphic bitmaps are scaled proportionally.
132
+ 3. **Composite** — walks the region map in reading order, drawing reflowed text lines and graphic bitmaps onto a single output canvas.
133
+
134
+ Steps 1 runs once per page (cached). Steps 2-3 re-run on font size change, which is what makes pinch-to-zoom fast.
135
+
89
136
  ## Architecture
90
137
 
91
138
  ```
@@ -95,7 +142,8 @@ pretext-pdfjs/
95
142
  │ ├── pretext-text-layer.js # PretextTextLayer (drop-in replacement)
96
143
  │ ├── measurement-cache.js # Pretext-style Canvas measurement cache
97
144
  │ ├── viewer.js # PretextPDFViewer helper
98
- └── pinch.js # Pinch-type PDF reader integration
145
+ ├── pinch.js # Pinch-type PDF reader integration
146
+ │ └── reflow.js # Per-block reflow (text + images)
99
147
  ├── demo.html # Self-contained demo page
100
148
  ├── package.json
101
149
  └── README.md
@@ -105,7 +153,7 @@ pretext-pdfjs/
105
153
 
106
154
  **Replaced**: `TextLayer` class — measurement cache, ascent detection, width scaling.
107
155
 
108
- **Added**: `pretextMetrics`, `enableReflow()`, pinch/morph/combined reading modes.
156
+ **Added**: `pretextMetrics`, `enableReflow()`, pinch/morph/combined reading modes, per-block reflow with image preservation.
109
157
 
110
158
  ## Built on
111
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pretext-pdfjs",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Fork of PDF.js with @chenglou/pretext-native text layer — zero DOM reflows for text measurement",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,7 +9,8 @@
9
9
  "./text-layer": "./src/pretext-text-layer.js",
10
10
  "./measurement-cache": "./src/measurement-cache.js",
11
11
  "./viewer": "./src/viewer.js",
12
- "./pinch": "./src/pinch.js"
12
+ "./pinch": "./src/pinch.js",
13
+ "./reflow": "./src/reflow.js"
13
14
  },
14
15
  "files": [
15
16
  "src/",
package/src/index.js CHANGED
@@ -65,3 +65,6 @@ export {
65
65
  PretextMeasurementCache,
66
66
  pretextCache,
67
67
  } from "./pretext-text-layer.js";
68
+
69
+ // Reflow renderer
70
+ export { createReflowRenderer } from "./reflow.js";
package/src/reflow.js ADDED
@@ -0,0 +1,890 @@
1
+ /**
2
+ * pretext-pdfjs/reflow
3
+ *
4
+ * Per-block reflow renderer for PDF pages. Text blocks reflow with Pretext
5
+ * preserving relative font sizes, weight, and style. Non-text regions
6
+ * (images, vector graphics) render as scaled bitmaps.
7
+ */
8
+
9
+ import { prepareWithSegments, layoutWithLines } from "@chenglou/pretext";
10
+
11
+ // ─── Helpers ──────────────────────────────────────────────────────────────
12
+
13
+ function clamp(v, min, max) {
14
+ return Math.max(min, Math.min(max, v));
15
+ }
16
+
17
+ function bboxOverlap(a, b) {
18
+ const x1 = Math.max(a.x, b.x);
19
+ const y1 = Math.max(a.y, b.y);
20
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
21
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
22
+ if (x2 <= x1 || y2 <= y1) return 0;
23
+ const intersection = (x2 - x1) * (y2 - y1);
24
+ const smaller = Math.min(a.w * a.h, b.w * b.h);
25
+ return smaller > 0 ? intersection / smaller : 0;
26
+ }
27
+
28
+ // ─── Page analysis ────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Group adjacent text items into text blocks by proximity.
32
+ * Also extracts font metadata: average size, italic, bold.
33
+ */
34
+ function groupTextBlocks(textItems, pageHeight, styles) {
35
+ const sorted = [...textItems].filter(i => i.str?.trim()).sort((a, b) => {
36
+ const ay = pageHeight - a.transform[5];
37
+ const by = pageHeight - b.transform[5];
38
+ if (Math.abs(ay - by) > 2) return ay - by;
39
+ return a.transform[4] - b.transform[4];
40
+ });
41
+
42
+ const blocks = [];
43
+ let current = null;
44
+
45
+ for (const item of sorted) {
46
+ const x = item.transform[4];
47
+ const y = pageHeight - item.transform[5];
48
+ const fontHeight = Math.hypot(item.transform[2], item.transform[3]);
49
+
50
+ if (!current) {
51
+ current = {
52
+ items: [item],
53
+ bbox: { x, y, w: item.width || 0, h: fontHeight },
54
+ };
55
+ continue;
56
+ }
57
+
58
+ const lastItem = current.items[current.items.length - 1];
59
+ const lastY = pageHeight - lastItem.transform[5];
60
+ const lastFH = Math.hypot(lastItem.transform[2], lastItem.transform[3]);
61
+ const verticalGap = Math.abs(y - lastY);
62
+
63
+ // Split block on significant font size change (headings vs body)
64
+ // But don't split for superscripts/markers that are horizontally adjacent
65
+ const sizeRatio = fontHeight > 0 && lastFH > 0
66
+ ? Math.max(fontHeight, lastFH) / Math.min(fontHeight, lastFH)
67
+ : 1;
68
+ const lastX = lastItem.transform[4];
69
+ const lastW = lastItem.width || lastFH;
70
+ const hGap = x - (lastX + lastW);
71
+ const isHorizAdjacent = hGap < lastFH * 0.5 && hGap > -lastFH;
72
+ const isShortItem = (item.str || "").trim().length <= 2;
73
+ const isSuperscript = isShortItem && isHorizAdjacent && sizeRatio > 1.3;
74
+ const sizeOk = sizeRatio < 1.3 || isSuperscript;
75
+
76
+ if (
77
+ sizeOk &&
78
+ verticalGap < lastFH * 2.5 &&
79
+ x < current.bbox.x + current.bbox.w + lastFH * 2
80
+ ) {
81
+ current.items.push(item);
82
+ current.bbox.x = Math.min(current.bbox.x, x);
83
+ current.bbox.w =
84
+ Math.max(current.bbox.x + current.bbox.w, x + (item.width || 0)) -
85
+ current.bbox.x;
86
+ current.bbox.h = y + fontHeight - current.bbox.y;
87
+ } else {
88
+ blocks.push(current);
89
+ current = {
90
+ items: [item],
91
+ bbox: { x, y, w: item.width || 0, h: fontHeight },
92
+ };
93
+ }
94
+ }
95
+ if (current) blocks.push(current);
96
+
97
+ // Compute font metadata per block
98
+ for (const block of blocks) {
99
+ const sizes = [];
100
+ let italicCount = 0;
101
+ let boldCount = 0;
102
+
103
+ for (const item of block.items) {
104
+ const fh = Math.hypot(item.transform[2], item.transform[3]);
105
+ if (fh > 0) sizes.push(fh);
106
+
107
+ // Detect italic/bold from fontName and style
108
+ const name = (item.fontName || "").toLowerCase();
109
+ const style = styles?.[item.fontName];
110
+ const family = (style?.fontFamily || "").toLowerCase();
111
+ const combined = name + " " + family;
112
+
113
+ if (combined.includes("italic") || combined.includes("oblique")) italicCount++;
114
+ if (combined.includes("bold") || combined.includes("black") || combined.includes("heavy")) boldCount++;
115
+
116
+ // Also detect italic from transform skew
117
+ if (Math.abs(item.transform[2]) > 0.1 && Math.abs(item.transform[1]) < 0.1) {
118
+ italicCount++;
119
+ }
120
+ }
121
+
122
+ block.avgFontSize = sizes.length
123
+ ? sizes.reduce((a, b) => a + b, 0) / sizes.length
124
+ : 12;
125
+ block.isItalic = italicCount > block.items.length * 0.4;
126
+ block.isBold = boldCount > block.items.length * 0.4;
127
+
128
+ // Detect font family from the PDF's style metadata
129
+ const sampleStyle = styles?.[block.items[0]?.fontName];
130
+ block.pdfFontFamily = sampleStyle?.fontFamily || null;
131
+ }
132
+
133
+ return blocks;
134
+ }
135
+
136
+ /**
137
+ * Extract graphic regions from the page operator list.
138
+ * Only captures image operators (paintImageXObject etc).
139
+ * Skips path/fill/stroke to avoid false positives from text decorations.
140
+ */
141
+ async function extractGraphicRegions(page, OPS) {
142
+ const ops = await page.getOperatorList();
143
+ const regions = [];
144
+ const ctmStack = [];
145
+ let ctm = [1, 0, 0, 1, 0, 0];
146
+
147
+ const imageOps = new Set([
148
+ OPS.paintImageXObject,
149
+ OPS.paintJpegXObject,
150
+ OPS.paintImageXObjectRepeat,
151
+ ]);
152
+
153
+ function multiplyMatrix(a, b) {
154
+ return [
155
+ a[0] * b[0] + a[2] * b[1],
156
+ a[1] * b[0] + a[3] * b[1],
157
+ a[0] * b[2] + a[2] * b[3],
158
+ a[1] * b[2] + a[3] * b[3],
159
+ a[0] * b[4] + a[2] * b[5] + a[4],
160
+ a[1] * b[4] + a[3] * b[5] + a[5],
161
+ ];
162
+ }
163
+
164
+ function transformPoint(x, y) {
165
+ return [ctm[0] * x + ctm[2] * y + ctm[4], ctm[1] * x + ctm[3] * y + ctm[5]];
166
+ }
167
+
168
+ for (let i = 0; i < ops.fnArray.length; i++) {
169
+ const fn = ops.fnArray[i];
170
+ const args = ops.argsArray[i];
171
+
172
+ if (fn === OPS.save) {
173
+ ctmStack.push(ctm.slice());
174
+ } else if (fn === OPS.restore) {
175
+ if (ctmStack.length > 0) ctm = ctmStack.pop();
176
+ } else if (fn === OPS.transform) {
177
+ ctm = multiplyMatrix(ctm, args);
178
+ } else if (imageOps.has(fn)) {
179
+ const corners = [
180
+ transformPoint(0, 0),
181
+ transformPoint(1, 0),
182
+ transformPoint(0, 1),
183
+ transformPoint(1, 1),
184
+ ];
185
+ const xs = corners.map(c => c[0]);
186
+ const ys = corners.map(c => c[1]);
187
+ const minX = Math.min(...xs);
188
+ const maxX = Math.max(...xs);
189
+ const minY = Math.min(...ys);
190
+ const maxY = Math.max(...ys);
191
+ if (maxX - minX > 10 && maxY - minY > 10) {
192
+ regions.push({
193
+ type: "graphic",
194
+ bbox: { x: minX, y: minY, w: maxX - minX, h: maxY - minY },
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ return regions;
201
+ }
202
+
203
+ /**
204
+ * Build text content for a block, preserving paragraph breaks.
205
+ */
206
+ function blockToText(block, pageHeight) {
207
+ let result = "";
208
+ let lastY = null;
209
+ let lastFontSize = 12;
210
+
211
+ for (const item of block.items) {
212
+ if (!item.str) continue;
213
+ const currentY = pageHeight - item.transform[5];
214
+ const fontHeight = Math.hypot(item.transform[2], item.transform[3]);
215
+ if (fontHeight > 0) lastFontSize = fontHeight;
216
+
217
+ if (lastY !== null) {
218
+ const gap = Math.abs(currentY - lastY);
219
+ if (gap > lastFontSize * 1.8) {
220
+ result += "\n\n";
221
+ } else if (gap > lastFontSize * 0.3) {
222
+ if (!result.endsWith(" ") && !result.endsWith("\n")) {
223
+ result += " ";
224
+ }
225
+ }
226
+ }
227
+ lastY = currentY;
228
+ result += item.str;
229
+ }
230
+ return result.trim();
231
+ }
232
+
233
+ /**
234
+ * Build a region map: text blocks + graphic regions, sorted in reading order.
235
+ * Filters out graphic regions that overlap with text blocks.
236
+ */
237
+ function buildRegionMap(textBlocks, graphicRegions, pageHeight) {
238
+ const regions = [];
239
+ const textBboxes = [];
240
+
241
+ for (const block of textBlocks) {
242
+ const bbox = { ...block.bbox };
243
+ regions.push({ type: "text", block, bbox });
244
+ textBboxes.push(bbox);
245
+ }
246
+
247
+ for (const gr of graphicRegions) {
248
+ // PDF coords: y is from bottom → convert to top-down
249
+ const topY = pageHeight - gr.bbox.y - gr.bbox.h;
250
+ const bbox = { x: gr.bbox.x, y: topY, w: gr.bbox.w, h: gr.bbox.h };
251
+
252
+ // Skip if this graphic region overlaps significantly with any text block
253
+ const overlapsText = textBboxes.some(tb => bboxOverlap(bbox, tb) > 0.3);
254
+ if (!overlapsText) {
255
+ regions.push({ type: "graphic", bbox });
256
+ }
257
+ }
258
+
259
+ // Detect columns: find the midpoint X where blocks cluster on left vs right
260
+ const pageWidth = Math.max(...regions.map(r => r.bbox.x + r.bbox.w), 1);
261
+ const midX = pageWidth / 2;
262
+ const leftBlocks = regions.filter(r => r.bbox.x + r.bbox.w / 2 < midX);
263
+ const rightBlocks = regions.filter(r => r.bbox.x + r.bbox.w / 2 >= midX);
264
+ const hasColumns = leftBlocks.length > 2 && rightBlocks.length > 2 &&
265
+ rightBlocks.some(r => leftBlocks.some(l => Math.abs(l.bbox.y - r.bbox.y) < 20));
266
+
267
+ if (hasColumns) {
268
+ // Two-column: sort each column top-to-bottom, then concatenate
269
+ // Full-width blocks (spanning > 60% of page) go first, sorted by Y
270
+ const fullWidth = regions.filter(r => r.bbox.w > pageWidth * 0.6);
271
+ const leftCol = regions.filter(r => r.bbox.w <= pageWidth * 0.6 && r.bbox.x + r.bbox.w / 2 < midX);
272
+ const rightCol = regions.filter(r => r.bbox.w <= pageWidth * 0.6 && r.bbox.x + r.bbox.w / 2 >= midX);
273
+ const byY = (a, b) => a.bbox.y - b.bbox.y;
274
+ fullWidth.sort(byY);
275
+ leftCol.sort(byY);
276
+ rightCol.sort(byY);
277
+ regions.length = 0;
278
+ regions.push(...fullWidth, ...leftCol, ...rightCol);
279
+ } else {
280
+ // Single column: sort by Y then X
281
+ regions.sort((a, b) => {
282
+ if (Math.abs(a.bbox.y - b.bbox.y) > 10) return a.bbox.y - b.bbox.y;
283
+ return a.bbox.x - b.bbox.x;
284
+ });
285
+ }
286
+
287
+ return regions;
288
+ }
289
+
290
+ // ─── Page analysis cache ──────────────────────────────────────────────────
291
+
292
+ async function analyzePage(page, OPS) {
293
+ const viewport = page.getViewport({ scale: 1 });
294
+ const pageWidth = viewport.width;
295
+ const pageHeight = viewport.height;
296
+
297
+ // Get text content with styles
298
+ const textContent = await page.getTextContent();
299
+ const textBlocks = groupTextBlocks(textContent.items, pageHeight, textContent.styles);
300
+
301
+ // Compute body font size (most common size = body text)
302
+ const allSizes = textBlocks.map(b => Math.round(b.avgFontSize * 10) / 10);
303
+ const freq = {};
304
+ for (const s of allSizes) freq[s] = (freq[s] || 0) + 1;
305
+ let bodyFontSize = 12;
306
+ let maxFreq = 0;
307
+ for (const [s, f] of Object.entries(freq)) {
308
+ if (f > maxFreq) { maxFreq = f; bodyFontSize = parseFloat(s); }
309
+ }
310
+ // Compute fontScale per block
311
+ for (const block of textBlocks) {
312
+ block.fontScale = block.avgFontSize / bodyFontSize;
313
+ }
314
+
315
+ // Get graphic regions (images only, no paths)
316
+ const graphicRegions = await extractGraphicRegions(page, OPS);
317
+
318
+ // Render full page to offscreen canvas for bitmap extraction
319
+ const renderScale = 2;
320
+ const offCanvas = document.createElement("canvas");
321
+ offCanvas.width = Math.floor(pageWidth * renderScale);
322
+ offCanvas.height = Math.floor(pageHeight * renderScale);
323
+ const offCtx = offCanvas.getContext("2d");
324
+
325
+ const renderViewport = page.getViewport({ scale: renderScale });
326
+ await page.render({
327
+ canvasContext: offCtx,
328
+ viewport: renderViewport,
329
+ }).promise;
330
+
331
+ // Build region map (filters overlapping graphics)
332
+ const regionMap = buildRegionMap(textBlocks, graphicRegions, pageHeight);
333
+
334
+ // Extract bitmap snippets for graphic regions only
335
+ const bitmaps = new Map();
336
+ for (const region of regionMap) {
337
+ if (region.type !== "graphic") continue;
338
+ const b = region.bbox;
339
+ const sx = Math.max(0, Math.floor(b.x * renderScale));
340
+ const sy = Math.max(0, Math.floor(b.y * renderScale));
341
+ const sw = Math.min(Math.floor(b.w * renderScale), offCanvas.width - sx);
342
+ const sh = Math.min(Math.floor(b.h * renderScale), offCanvas.height - sy);
343
+ if (sw > 0 && sh > 0) {
344
+ const imgData = offCtx.getImageData(sx, sy, sw, sh);
345
+ bitmaps.set(region, { data: imgData, sourceW: b.w, sourceH: b.h });
346
+ }
347
+ }
348
+
349
+ return {
350
+ pageWidth,
351
+ pageHeight,
352
+ regionMap,
353
+ bitmaps,
354
+ textBlocks,
355
+ graphicRegions,
356
+ offCanvas,
357
+ };
358
+ }
359
+
360
+ // ─── Reflow + composite engine ────────────────────────────────────────────
361
+
362
+ function reflowAndComposite(analysis, opts) {
363
+ const { regionMap, bitmaps, pageWidth, pageHeight } = analysis;
364
+ const {
365
+ fontSize, fontFamily, lineHeight, padding, background,
366
+ textColor, imageFit, canvasW,
367
+ } = opts;
368
+
369
+ const availableWidth = canvasW - padding * 2;
370
+
371
+ // No regions → render full page bitmap
372
+ if (regionMap.length === 0 || !regionMap.some(r => r.type === "text")) {
373
+ const scale = Math.min(availableWidth / pageWidth, 1);
374
+ return {
375
+ totalHeight: pageHeight * scale + padding * 2,
376
+ reflowedRegions: [],
377
+ fullPageFallback: true,
378
+ };
379
+ }
380
+
381
+ const reflowedRegions = [];
382
+
383
+ for (const region of regionMap) {
384
+ if (region.type === "text") {
385
+ const block = region.block;
386
+ const text = blockToText(block, pageHeight);
387
+ if (!text) {
388
+ reflowedRegions.push({ type: "text", lines: [], height: 0, region });
389
+ continue;
390
+ }
391
+
392
+ // Per-block font properties
393
+ const blockFontSize = Math.round(fontSize * (block.fontScale || 1));
394
+ const blockLH = blockFontSize * lineHeight;
395
+ const style = block.isItalic ? "italic" : "normal";
396
+ const weight = block.isBold ? 700 : 400;
397
+ // Use PDF's detected font family if available, otherwise fall back to configured
398
+ const blockFamily = block.pdfFontFamily
399
+ ? `${block.pdfFontFamily}, ${fontFamily}`
400
+ : fontFamily;
401
+ const font = `${style} ${weight} ${blockFontSize}px ${blockFamily}`;
402
+
403
+ const prepared = prepareWithSegments(text, font);
404
+ const result = layoutWithLines(prepared, availableWidth, blockLH);
405
+ const blockHeight = result.lines.length * blockLH;
406
+
407
+ reflowedRegions.push({
408
+ type: "text",
409
+ lines: result.lines,
410
+ height: blockHeight,
411
+ fontSize: blockFontSize,
412
+ lineHeight: blockLH,
413
+ fontStyle: style,
414
+ fontWeight: weight,
415
+ fontFamily: blockFamily,
416
+ region,
417
+ });
418
+ } else {
419
+ // Graphic
420
+ const bitmap = bitmaps.get(region);
421
+ if (!bitmap) {
422
+ reflowedRegions.push({ type: "graphic", height: 0, region });
423
+ continue;
424
+ }
425
+ let drawW = bitmap.sourceW;
426
+ let drawH = bitmap.sourceH;
427
+ if (imageFit === "full-width") {
428
+ const s = availableWidth / drawW;
429
+ drawW = availableWidth;
430
+ drawH = bitmap.sourceH * s;
431
+ } else if (drawW > availableWidth) {
432
+ const s = availableWidth / drawW;
433
+ drawW *= s;
434
+ drawH *= s;
435
+ }
436
+ reflowedRegions.push({
437
+ type: "graphic",
438
+ height: drawH,
439
+ drawW,
440
+ drawH,
441
+ bitmap,
442
+ region,
443
+ });
444
+ }
445
+ }
446
+
447
+ // Total height
448
+ const baseLH = fontSize * lineHeight;
449
+ let totalHeight = padding;
450
+ for (const r of reflowedRegions) {
451
+ totalHeight += r.height;
452
+ totalHeight += baseLH * 0.4;
453
+ }
454
+ totalHeight += padding;
455
+
456
+ return { totalHeight, reflowedRegions, fullPageFallback: false };
457
+ }
458
+
459
+ /**
460
+ * Draw the reflowed content to canvas.
461
+ */
462
+ function drawComposite(ctx, reflowedRegions, analysis, opts, scrollY) {
463
+ const {
464
+ fontSize, fontFamily, lineHeight, padding,
465
+ background, textColor, canvasW, canvasH, dpr,
466
+ } = opts;
467
+
468
+ const d = dpr;
469
+ const baseLH = fontSize * lineHeight;
470
+
471
+ ctx.fillStyle = background;
472
+ ctx.fillRect(0, 0, canvasW * d, canvasH * d);
473
+
474
+ // Full page fallback
475
+ if (reflowedRegions.length === 0 && analysis.offCanvas) {
476
+ const availableWidth = canvasW - padding * 2;
477
+ const scale = Math.min(availableWidth / analysis.pageWidth, 1);
478
+ ctx.drawImage(
479
+ analysis.offCanvas,
480
+ padding * d, padding * d,
481
+ analysis.pageWidth * scale * d,
482
+ analysis.pageHeight * scale * d
483
+ );
484
+ return;
485
+ }
486
+
487
+ let cursorY = padding;
488
+ ctx.textBaseline = "top";
489
+
490
+ for (const r of reflowedRegions) {
491
+ if (r.type === "text" && r.lines) {
492
+ const fs = r.fontSize || fontSize;
493
+ const lh = r.lineHeight || baseLH;
494
+ const style = r.fontStyle || "normal";
495
+ const weight = r.fontWeight || 400;
496
+
497
+ ctx.fillStyle = textColor;
498
+ ctx.font = `${style} ${weight} ${fs * d}px ${fontFamily}`;
499
+
500
+ for (const line of r.lines) {
501
+ const screenY = cursorY - scrollY;
502
+ if (screenY > -lh && screenY < canvasH + lh) {
503
+ ctx.fillText(line.text, padding * d, screenY * d);
504
+ }
505
+ cursorY += lh;
506
+ }
507
+ } else if (r.type === "graphic" && r.bitmap) {
508
+ const screenY = cursorY - scrollY;
509
+ if (screenY > -r.drawH && screenY < canvasH + r.drawH) {
510
+ const tmpCanvas = document.createElement("canvas");
511
+ tmpCanvas.width = r.bitmap.data.width;
512
+ tmpCanvas.height = r.bitmap.data.height;
513
+ tmpCanvas.getContext("2d").putImageData(r.bitmap.data, 0, 0);
514
+ ctx.drawImage(
515
+ tmpCanvas,
516
+ padding * d, screenY * d,
517
+ r.drawW * d, r.drawH * d
518
+ );
519
+ }
520
+ cursorY += r.drawH;
521
+ }
522
+ cursorY += baseLH * 0.4;
523
+ }
524
+ }
525
+
526
+ // ─── Main API ─────────────────────────────────────────────────────────────
527
+
528
+ export function createReflowRenderer(container, options = {}) {
529
+ const minFont = options.minFontSize ?? 8;
530
+ const maxFont = options.maxFontSize ?? 48;
531
+ const fontFamily = options.fontFamily ?? '"Literata", Georgia, serif';
532
+ const lhRatio = options.lineHeight ?? 1.6;
533
+ const padding = options.padding ?? 24;
534
+ const bg = options.background ?? "#f4f1eb";
535
+ const textColor = options.textColor ?? "#252320";
536
+ const imageFit = options.imageFit ?? "proportional";
537
+ const enablePinchZoom = options.enablePinchZoom ?? true;
538
+ const enableMomentumScroll = options.enableMomentumScroll ?? true;
539
+ const friction = options.friction ?? 0.95;
540
+ const onZoom = options.onZoom;
541
+ const onPageReady = options.onPageReady;
542
+
543
+ let pdfjs = null;
544
+ let pdfDoc = null;
545
+ let currentPage = 0;
546
+ let fontSize = options.fontSize ?? 16;
547
+ let destroyed = false;
548
+
549
+ const canvas = document.createElement("canvas");
550
+ canvas.style.display = "block";
551
+ canvas.style.touchAction = "none";
552
+ container.appendChild(canvas);
553
+ const ctx = canvas.getContext("2d");
554
+
555
+ let dpr = Math.min(devicePixelRatio || 1, 3);
556
+ let W = 0, H = 0;
557
+
558
+ const analysisCache = new Map();
559
+ let currentAnalysis = null;
560
+ let reflowedRegions = [];
561
+ let totalHeight = 0;
562
+
563
+ let scrollY = 0, scrollVelocity = 0, maxScroll = 0;
564
+ let touchLastY = 0, touchLastTime = 0, isTouching = false;
565
+ let pinchActive = false, pinchStartDist = 0, pinchStartSize = 0;
566
+ let raf = 0;
567
+
568
+ // Cached tmp canvases for graphic bitmaps (avoid creating per frame)
569
+ const tmpCanvasCache = new WeakMap();
570
+ function getTmpCanvas(bitmap) {
571
+ let c = tmpCanvasCache.get(bitmap);
572
+ if (!c) {
573
+ c = document.createElement("canvas");
574
+ c.width = bitmap.data.width;
575
+ c.height = bitmap.data.height;
576
+ c.getContext("2d").putImageData(bitmap.data, 0, 0);
577
+ tmpCanvasCache.set(bitmap, c);
578
+ }
579
+ return c;
580
+ }
581
+
582
+ async function ensurePdfjs() {
583
+ if (pdfjs) return;
584
+ pdfjs = await import("pdfjs-dist");
585
+ if (options.workerSrc) {
586
+ pdfjs.GlobalWorkerOptions.workerSrc = options.workerSrc;
587
+ }
588
+ }
589
+
590
+ function reflow() {
591
+ if (!currentAnalysis || W === 0) return;
592
+ const result = reflowAndComposite(currentAnalysis, {
593
+ fontSize, fontFamily, lineHeight: lhRatio, padding,
594
+ background: bg, textColor, imageFit, canvasW: W, canvasH: H, dpr,
595
+ });
596
+ reflowedRegions = result.reflowedRegions;
597
+ totalHeight = result.totalHeight;
598
+ maxScroll = Math.max(0, totalHeight - H);
599
+ scrollY = clamp(scrollY, 0, maxScroll);
600
+ }
601
+
602
+ function render() {
603
+ if (!currentAnalysis) {
604
+ ctx.fillStyle = bg;
605
+ ctx.fillRect(0, 0, W * dpr, H * dpr);
606
+ return;
607
+ }
608
+ // Inline draw for performance (avoid function call overhead in rAF)
609
+ const d = dpr;
610
+ const baseLH = fontSize * lhRatio;
611
+
612
+ ctx.fillStyle = bg;
613
+ ctx.fillRect(0, 0, W * d, H * d);
614
+
615
+ if (reflowedRegions.length === 0 && currentAnalysis.offCanvas) {
616
+ const availW = W - padding * 2;
617
+ const scale = Math.min(availW / currentAnalysis.pageWidth, 1);
618
+ ctx.drawImage(
619
+ currentAnalysis.offCanvas,
620
+ padding * d, padding * d,
621
+ currentAnalysis.pageWidth * scale * d,
622
+ currentAnalysis.pageHeight * scale * d
623
+ );
624
+ return;
625
+ }
626
+
627
+ let cursorY = padding;
628
+ ctx.textBaseline = "top";
629
+
630
+ for (const r of reflowedRegions) {
631
+ if (r.type === "text" && r.lines) {
632
+ const fs = r.fontSize || fontSize;
633
+ const lh = r.lineHeight || baseLH;
634
+ const rFamily = r.fontFamily || fontFamily;
635
+ ctx.fillStyle = textColor;
636
+ ctx.font = `${r.fontStyle || "normal"} ${r.fontWeight || 400} ${fs * d}px ${rFamily}`;
637
+
638
+ for (const line of r.lines) {
639
+ const screenY = cursorY - scrollY;
640
+ if (screenY > -lh && screenY < H + lh) {
641
+ ctx.fillText(line.text, padding * d, screenY * d);
642
+ }
643
+ cursorY += lh;
644
+ }
645
+ } else if (r.type === "graphic" && r.bitmap) {
646
+ const screenY = cursorY - scrollY;
647
+ if (screenY > -r.drawH && screenY < H + r.drawH) {
648
+ const tmp = getTmpCanvas(r.bitmap);
649
+ ctx.drawImage(tmp, padding * d, screenY * d, r.drawW * d, r.drawH * d);
650
+ }
651
+ cursorY += r.drawH;
652
+ }
653
+ cursorY += baseLH * 0.4;
654
+ }
655
+ }
656
+
657
+ function loop() {
658
+ if (destroyed) return;
659
+ if (!isTouching && enableMomentumScroll) {
660
+ scrollY += scrollVelocity;
661
+ scrollVelocity *= friction;
662
+ if (scrollY < 0) { scrollY *= 0.85; scrollVelocity *= 0.5; }
663
+ else if (scrollY > maxScroll) {
664
+ scrollY = maxScroll + (scrollY - maxScroll) * 0.85;
665
+ scrollVelocity *= 0.5;
666
+ }
667
+ if (Math.abs(scrollVelocity) < 0.1) scrollVelocity = 0;
668
+ }
669
+ render();
670
+ raf = requestAnimationFrame(loop);
671
+ }
672
+
673
+ // ── Gestures ──
674
+
675
+ function pDist(e) {
676
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
677
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
678
+ return Math.hypot(dx, dy);
679
+ }
680
+
681
+ function onTouchStart(e) {
682
+ if (e.touches.length === 2 && enablePinchZoom) {
683
+ pinchActive = true;
684
+ pinchStartDist = pDist(e);
685
+ pinchStartSize = fontSize;
686
+ scrollVelocity = 0;
687
+ isTouching = false;
688
+ } else if (e.touches.length === 1 && !pinchActive) {
689
+ isTouching = true;
690
+ scrollVelocity = 0;
691
+ touchLastY = e.touches[0].clientY;
692
+ touchLastTime = performance.now();
693
+ }
694
+ e.preventDefault();
695
+ }
696
+
697
+ function onTouchMove(e) {
698
+ if (pinchActive && e.touches.length === 2 && enablePinchZoom) {
699
+ const scale = pDist(e) / pinchStartDist;
700
+ const newSize = clamp(Math.round(pinchStartSize * scale), minFont, maxFont);
701
+ if (newSize !== fontSize) {
702
+ fontSize = newSize;
703
+ reflow();
704
+ onZoom?.(fontSize);
705
+ }
706
+ e.preventDefault();
707
+ return;
708
+ }
709
+ if (!isTouching || e.touches.length !== 1) return;
710
+ const y = e.touches[0].clientY;
711
+ const dy = touchLastY - y;
712
+ const now = performance.now();
713
+ const dt = now - touchLastTime;
714
+ scrollY += dy;
715
+ scrollY = clamp(scrollY, -50, maxScroll + 50);
716
+ if (dt > 0) scrollVelocity = (dy / dt) * 16;
717
+ touchLastY = y;
718
+ touchLastTime = now;
719
+ e.preventDefault();
720
+ }
721
+
722
+ function onTouchEnd(e) {
723
+ if (e.touches.length < 2) pinchActive = false;
724
+ if (e.touches.length === 0) isTouching = false;
725
+ }
726
+
727
+ function onWheel(e) {
728
+ e.preventDefault();
729
+ if ((e.ctrlKey || e.metaKey) && enablePinchZoom) {
730
+ const delta = e.deltaY > 0 ? -1 : 1;
731
+ const newSize = clamp(fontSize + delta, minFont, maxFont);
732
+ if (newSize !== fontSize) {
733
+ fontSize = newSize;
734
+ reflow();
735
+ onZoom?.(fontSize);
736
+ }
737
+ } else {
738
+ scrollY += e.deltaY;
739
+ scrollY = clamp(scrollY, -50, maxScroll + 50);
740
+ }
741
+ }
742
+
743
+ function handleResize() {
744
+ dpr = Math.min(devicePixelRatio || 1, 3);
745
+ W = Math.min(container.clientWidth, 680);
746
+ H = container.clientHeight;
747
+ canvas.width = W * dpr;
748
+ canvas.height = H * dpr;
749
+ canvas.style.width = W + "px";
750
+ canvas.style.height = H + "px";
751
+ reflow();
752
+ }
753
+
754
+ canvas.addEventListener("touchstart", onTouchStart, { passive: false });
755
+ canvas.addEventListener("touchmove", onTouchMove, { passive: false });
756
+ canvas.addEventListener("touchend", onTouchEnd);
757
+ canvas.addEventListener("wheel", onWheel, { passive: false });
758
+ window.addEventListener("resize", handleResize);
759
+ handleResize();
760
+ raf = requestAnimationFrame(loop);
761
+
762
+ return {
763
+ async open(source) {
764
+ await ensurePdfjs();
765
+ const loadParams =
766
+ source instanceof Uint8Array || source instanceof ArrayBuffer
767
+ ? { data: source }
768
+ : typeof source === "string"
769
+ ? { url: source }
770
+ : source;
771
+ pdfDoc = await pdfjs.getDocument(loadParams).promise;
772
+ analysisCache.clear();
773
+ return { numPages: pdfDoc.numPages };
774
+ },
775
+
776
+ async showPage(pageNum) {
777
+ if (!pdfDoc) throw new Error("Call open() first");
778
+ if (pageNum < 1 || pageNum > pdfDoc.numPages) {
779
+ throw new RangeError(`Page ${pageNum} out of range`);
780
+ }
781
+
782
+ if (!analysisCache.has(pageNum)) {
783
+ const page = await pdfDoc.getPage(pageNum);
784
+ analysisCache.set(pageNum, await analyzePage(page, pdfjs.OPS));
785
+ }
786
+
787
+ currentAnalysis = analysisCache.get(pageNum);
788
+ currentPage = pageNum;
789
+ scrollY = 0;
790
+ scrollVelocity = 0;
791
+ reflow();
792
+
793
+ onPageReady?.({
794
+ pageNum,
795
+ textBlocks: currentAnalysis.textBlocks,
796
+ graphicRegions: currentAnalysis.graphicRegions,
797
+ });
798
+ },
799
+
800
+ async showAll() {
801
+ if (!pdfDoc) throw new Error("Call open() first");
802
+
803
+ const allRegionMaps = [];
804
+ const allBitmaps = new Map();
805
+ let combinedPageHeight = 0;
806
+
807
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
808
+ if (!analysisCache.has(i)) {
809
+ const page = await pdfDoc.getPage(i);
810
+ analysisCache.set(i, await analyzePage(page, pdfjs.OPS));
811
+ }
812
+ const analysis = analysisCache.get(i);
813
+ for (const region of analysis.regionMap) {
814
+ const offsetRegion = {
815
+ ...region,
816
+ bbox: { ...region.bbox, y: region.bbox.y + combinedPageHeight },
817
+ };
818
+ allRegionMaps.push(offsetRegion);
819
+ if (region.type === "graphic" && analysis.bitmaps.has(region)) {
820
+ allBitmaps.set(offsetRegion, analysis.bitmaps.get(region));
821
+ }
822
+ }
823
+ combinedPageHeight += analysis.pageHeight + 20;
824
+ }
825
+
826
+ const first = analysisCache.get(1);
827
+ currentAnalysis = {
828
+ pageWidth: first.pageWidth,
829
+ pageHeight: combinedPageHeight,
830
+ regionMap: allRegionMaps,
831
+ bitmaps: allBitmaps,
832
+ textBlocks: allRegionMaps.filter(r => r.type === "text").map(r => r.block).filter(Boolean),
833
+ graphicRegions: allRegionMaps.filter(r => r.type === "graphic"),
834
+ offCanvas: first.offCanvas,
835
+ };
836
+
837
+ currentPage = -1;
838
+ scrollY = 0;
839
+ scrollVelocity = 0;
840
+ reflow();
841
+ },
842
+
843
+ async nextPage() {
844
+ if (pdfDoc && currentPage > 0 && currentPage < pdfDoc.numPages) {
845
+ return this.showPage(currentPage + 1);
846
+ }
847
+ },
848
+
849
+ async prevPage() {
850
+ if (pdfDoc && currentPage > 1) {
851
+ return this.showPage(currentPage - 1);
852
+ }
853
+ },
854
+
855
+ destroy() {
856
+ destroyed = true;
857
+ cancelAnimationFrame(raf);
858
+ canvas.removeEventListener("touchstart", onTouchStart);
859
+ canvas.removeEventListener("touchmove", onTouchMove);
860
+ canvas.removeEventListener("touchend", onTouchEnd);
861
+ canvas.removeEventListener("wheel", onWheel);
862
+ window.removeEventListener("resize", handleResize);
863
+ canvas.remove();
864
+ analysisCache.clear();
865
+ pdfDoc?.destroy();
866
+ pdfDoc = null;
867
+ },
868
+
869
+ setFontSize(newSize) {
870
+ const clamped = clamp(newSize, minFont, maxFont);
871
+ if (clamped !== fontSize) {
872
+ fontSize = clamped;
873
+ reflow();
874
+ onZoom?.(fontSize);
875
+ }
876
+ },
877
+
878
+ get fontSize() { return fontSize; },
879
+ get currentPage() { return currentPage; },
880
+ get numPages() { return pdfDoc?.numPages || 0; },
881
+ get canvas() { return canvas; },
882
+ get regions() {
883
+ if (!currentAnalysis) return { text: [], graphic: [] };
884
+ return {
885
+ text: currentAnalysis.textBlocks,
886
+ graphic: currentAnalysis.graphicRegions,
887
+ };
888
+ },
889
+ };
890
+ }