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