pdf-raster-cpu 0.0.0 → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Mearman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # pdf-raster-cpu
2
+
3
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/pdf-raster-cpu) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/pdf-raster-cpu) [![npm version](https://img.shields.io/npm/v/pdf-raster-cpu)](https://www.npmjs.com/package/pdf-raster-cpu) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)
4
+
5
+ > The pure-software reference backend for [pdf-codec](../pdf-codec/README.md)'s `PageRasteriser` port ([#1198](https://github.com/ExaDev/documents.js/issues/1198)): renders a PDF page, or one located region of one, to PNG bytes with a scanline rasteriser that has no canvas dependency at all and runs identically under Node, a browser Worker, and workerd.
6
+
7
+ `pdf-codec`'s `renderPdfPage` defines the contract — it walks a page's content through the same read machinery `readPdf` runs and drives a caller-supplied rasteriser with device-space draw operations — and deliberately does not pick an implementation. This package is the family's own answer: correct and deterministic first, fast second. It exists so a consumer that needs pixels (OCR input, a vision model, a thumbnail) can get them on any runtime with nothing but `pdf-codec` and arithmetic; a runtime with a real canvas (a browser, or Cloudflare Browser Rendering) supplies its own backend through the same port and none of this code ships.
8
+
9
+ ```mermaid
10
+ graph TD
11
+ bytecodec("byte-codec")
12
+ pdfcodec("pdf-codec")
13
+ cpuraster("pdf-raster-cpu")
14
+
15
+ bytecodec --> pdfcodec
16
+ pdfcodec --> cpuraster
17
+ bytecodec --> cpuraster
18
+
19
+ click bytecodec "https://github.com/ExaDev/documents.js/tree/main/packages/byte-codec" "byte-codec"
20
+ click pdfcodec "https://github.com/ExaDev/documents.js/tree/main/packages/pdf-codec" "pdf-codec"
21
+ click cpuraster "https://github.com/ExaDev/documents.js/tree/main/packages/pdf-raster-cpu" "pdf-raster-cpu"
22
+
23
+ style cpuraster fill:#f9a825,stroke:#333,stroke-width:3px
24
+ ```
25
+
26
+ ## Getting started
27
+
28
+ Requires Node.js `>=20` and pnpm `11.6.0` (pinned via `packageManager` in `package.json`).
29
+
30
+ ```sh
31
+ pnpm install
32
+ pnpm build # tsdown (ESM + CJS + .d.ts)
33
+ pnpm typecheck # tsc -p tsconfig.json + tsconfig.node.json
34
+ pnpm lint # eslint . --fix --cache --max-warnings 0
35
+ pnpm test # vitest run
36
+ pnpm test:workers # vitest run --config vitest.workers.config.ts, inside a real Cloudflare Workers (workerd) isolate
37
+ ```
38
+
39
+ Rendering a page:
40
+
41
+ ```ts
42
+ import { renderPdfPage } from "pdf-codec/raster";
43
+ import { createCpuRasteriser } from "pdf-raster-cpu";
44
+
45
+ const pngBytes = renderPdfPage(
46
+ pdfBytes,
47
+ 0, // page index
48
+ { dpi: 200 }, // or { scale: 2 }; exactly one of the two
49
+ createCpuRasteriser(),
50
+ );
51
+
52
+ // Just a located figure region, in the same point coordinates
53
+ // segmentPdfRegions' bounds (and LayoutFrame) use:
54
+ const figurePng = renderPdfPage(
55
+ pdfBytes,
56
+ 0,
57
+ {
58
+ dpi: 200,
59
+ clipPt: {
60
+ xPt: region.bounds.xPt,
61
+ yPt: region.bounds.yPt,
62
+ widthPt: region.bounds.widthPt,
63
+ heightPt: region.bounds.heightPt,
64
+ },
65
+ },
66
+ createCpuRasteriser(),
67
+ );
68
+ ```
69
+
70
+ One `CpuRasteriser` instance renders any number of pages sequentially — `renderPdfPage` drives it `beginPage` → `draw` … → `finish` per page, and each page starts from a cleared canvas. The factory also takes this backend's own options:
71
+
72
+ ```ts
73
+ import { createCpuRasteriser } from "pdf-raster-cpu";
74
+
75
+ const rasteriser = createCpuRasteriser({
76
+ onDiagnostic: (d) => console.warn(d.code, d.message),
77
+ // "raster-cpu/jpeg-image-undecoded" -- the one refusal this backend
78
+ // reports: a JPEG image op, which it declines rather than misrenders
79
+ });
80
+ ```
81
+
82
+ ## What renders and what refuses
83
+
84
+ Everything below is pinned by tests on decoded output pixels, not on intermediate structures.
85
+
86
+ - **Rectangle fills** paint with exact analytic coverage: for an axis-aligned rectangle each pixel's covered fraction is computable in closed form, so crisp table rules on integer boundaries paint whole pixels and fractional edges blend at exactly their overlap fraction — slightly more accurate than any supersample could state.
87
+ - **Vector paths** (arbitrary curves, ellipses, chart lines, and every glyph outline) fill through a sub-scanline sweep at 4×4 supersamples per pixel (a 1/16 coverage quantum), honouring both winding rules across all of a path's subpaths at once — the same pass that makes an evenodd hole a hole makes overlapping stroke pieces union rather than double-paint.
88
+ - **Strokes** render as geometry: each flattened centreline segment becomes an offset quad, each join the wedge that fills the notch on the outside of the turn — a miter (PDF's own default limit, 10.0) that falls back to a bevel past it — with butt caps, width-relative dash arrays exactly as the port recovers them, and a zero-width stroke clamped to one device pixel per the format's own "thinnest possible width" rule. The port converts dotted strokes to filled squares before they arrive, so no round-cap primitive is ever needed. One documented approximation: a join landing exactly on a dash boundary takes the boundary's butt ends rather than its wedge, a sub-stroke-width difference at dash scale.
89
+ - **Images** sample bilinearly under their own placement quad's coverage, so a rotated placement antialiases exactly like every other edge. PNG image ops (everything the port decoded) draw through byte-codec's decoder, cached per page by content. **JPEG (DCTDecode) image ops are declined by name** — `raster-cpu/jpeg-image-undecoded` through `onDiagnostic` — because byte-codec carries no DCT decoder and approximating a scan's pixels would silently misrender it. A canvas-owning backend draws these natively instead; the compressed bytes remain available through the port's op (and through `readPdf`'s image recovery) for consumers that want them.
90
+ - **Text** arrives from the port already resolved: each run's embedded TrueType outlines as filled path ops, placed at the run's own text matrices. Faces that state no sfnt outlines (CFF programs, standard-14 faces with nothing embedded, Type 3 glyph procedures) are refused by the port itself, through pdf-codec's own sink (`raster/text-cff-outlines`, `raster/text-outlines-unavailable`), never approximated here.
91
+
92
+ ## Runtime boundaries
93
+
94
+ - **Worker-isomorphic**: published `src/` imports no `node:*` builtins, no `Buffer`, no DOM, no canvas, and makes no network calls — enforced by the same lint rule every foundation package in this workspace opts into and proved at runtime by a workerd test suite that renders a page inside a real Cloudflare Workers isolate and asserts the node suite's own pinned pixels. No OCR, no vision, anywhere: this package hands a caller pixels and stops.
95
+ - **Deterministic**: the supersample factor is a fixed constant, curve flattening is a tolerance-bounded recursion, and every blend rounds once — so two renders of the same page produce byte-identical PNGs on every runtime, which the workerd suite pins directly.
96
+ - **Opaque white background**: a PDF page is notionally painted on white and the port deliberately specifies no background, so this backend clears each canvas to opaque white (what a viewer displays). Output PNGs therefore carry colour channels only — there is no alpha to encode, since source-over compositing onto an opaque base stays opaque.
97
+ - **Correct before fast**: a page of text at OCR-friendly 150–300 dpi renders in seconds, not milliseconds; the coverage passes visit every pixel row a shape touches. A runtime with a hardware-accelerated canvas should wrap it as its own backend through the same port rather than tolerate this one's speed.
98
+
99
+ ## Conventions
100
+
101
+ - Worker-isomorphic (see [the family-wide convention](../../README.md#conventions)); `isomorphic: true` in `packageLintConfig`, with a `test:workers` suite proving it at runtime.
102
+ - No non-null assertions, no type assertions: this package is new enough to hold none of the indexed-access debt the older packages carry a burn-down exemption for.
103
+ - Conventional commits, enforced via commitlint + husky, workspace-wide.
104
+
105
+ ## Install
106
+
107
+ ```sh
108
+ pnpm add pdf-raster-cpu
109
+ # or
110
+ npm install pdf-raster-cpu
111
+ ```
112
+
113
+ Peer expectations: `pdf-codec` (the port this implements) and `byte-codec` (the PNG codec) are declared as ordinary pinned dependencies — install this package and they arrive with it.
114
+
115
+ ## License
116
+
117
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,563 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let byte_codec = require("byte-codec");
3
+ var CoverageMask = class {
4
+ widthPx;
5
+ heightPx;
6
+ counts;
7
+ constructor(widthPx, heightPx) {
8
+ this.widthPx = widthPx;
9
+ this.heightPx = heightPx;
10
+ this.counts = new Uint16Array(widthPx * heightPx);
11
+ }
12
+ reset() {
13
+ this.counts.fill(0);
14
+ }
15
+ countAt(pixelIndex) {
16
+ return this.counts[pixelIndex] ?? 0;
17
+ }
18
+ fillPolygons(polygons, fillRule) {
19
+ if (polygons.length === 0) return;
20
+ let minY = Infinity;
21
+ let maxY = -Infinity;
22
+ for (const polygon of polygons) {
23
+ if (polygon.length < 3) continue;
24
+ for (const point of polygon) {
25
+ minY = Math.min(minY, point.y);
26
+ maxY = Math.max(maxY, point.y);
27
+ }
28
+ }
29
+ if (maxY <= minY) return;
30
+ const rowMin = Math.max(0, Math.ceil(minY * 4 - .5));
31
+ const rowMax = Math.min(4 * this.heightPx - 1, Math.floor(maxY * 4 - .5));
32
+ const crossings = [];
33
+ for (let row = rowMin; row <= rowMax; row++) {
34
+ const y = (row + .5) / 4;
35
+ crossings.length = 0;
36
+ for (const polygon of polygons) for (let i = 0; i < polygon.length; i++) {
37
+ const a = polygon[i];
38
+ const b = polygon[(i + 1) % polygon.length];
39
+ if (a === void 0 || b === void 0) continue;
40
+ if (a.y <= y === b.y <= y) continue;
41
+ const t = (y - a.y) / (b.y - a.y);
42
+ crossings.push({
43
+ x: a.x + t * (b.x - a.x),
44
+ dir: b.y > a.y ? 1 : -1
45
+ });
46
+ }
47
+ if (crossings.length === 0) continue;
48
+ crossings.sort((p, q) => p.x - q.x);
49
+ const pixelRowBase = Math.floor(row / 4) * this.widthPx;
50
+ let spanStartX;
51
+ let winding = 0;
52
+ for (const crossing of crossings) {
53
+ if (fillRule === "evenodd") {
54
+ if (spanStartX === void 0) spanStartX = crossing.x;
55
+ else {
56
+ this.markSpan(pixelRowBase, spanStartX, crossing.x);
57
+ spanStartX = void 0;
58
+ }
59
+ continue;
60
+ }
61
+ const previous = winding;
62
+ winding += crossing.dir;
63
+ if (previous === 0 && winding !== 0) spanStartX = crossing.x;
64
+ else if (spanStartX !== void 0 && winding === 0) {
65
+ this.markSpan(pixelRowBase, spanStartX, crossing.x);
66
+ spanStartX = void 0;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ markSpan(pixelRowBase, startX, endX) {
72
+ if (endX <= startX) return;
73
+ const columnFirst = Math.max(0, Math.ceil(startX * 4 - .5));
74
+ const columnLast = Math.min(4 * this.widthPx - 1, Math.floor(endX * 4 - .5));
75
+ for (let c = columnFirst; c <= columnLast; c++) {
76
+ const cellIndex = pixelRowBase + Math.floor(c / 4);
77
+ const cell = this.counts[cellIndex];
78
+ if (cell !== void 0) this.counts[cellIndex] = cell + 1;
79
+ }
80
+ }
81
+ };
82
+ function flattenCubic(p0, c1, c2, p1) {
83
+ const points = [];
84
+ const flatten = (a, b, c, d, depth) => {
85
+ const chordX = d.x - a.x;
86
+ const chordY = d.y - a.y;
87
+ const chordLength = Math.hypot(chordX, chordY) || 1;
88
+ const dist1 = Math.abs((b.x - a.x) * chordY - (b.y - a.y) * chordX) / chordLength;
89
+ const dist2 = Math.abs((c.x - a.x) * chordY - (c.y - a.y) * chordX) / chordLength;
90
+ if (depth >= 16 || Math.max(dist1, dist2) <= .05) {
91
+ points.push(d);
92
+ return;
93
+ }
94
+ const ab = {
95
+ x: (a.x + b.x) / 2,
96
+ y: (a.y + b.y) / 2
97
+ };
98
+ const bc = {
99
+ x: (b.x + c.x) / 2,
100
+ y: (b.y + c.y) / 2
101
+ };
102
+ const cd = {
103
+ x: (c.x + d.x) / 2,
104
+ y: (c.y + d.y) / 2
105
+ };
106
+ const abc = {
107
+ x: (ab.x + bc.x) / 2,
108
+ y: (ab.y + bc.y) / 2
109
+ };
110
+ const bcd = {
111
+ x: (bc.x + cd.x) / 2,
112
+ y: (bc.y + cd.y) / 2
113
+ };
114
+ const mid = {
115
+ x: (abc.x + bcd.x) / 2,
116
+ y: (abc.y + bcd.y) / 2
117
+ };
118
+ flatten(a, ab, abc, mid, depth + 1);
119
+ flatten(mid, bcd, cd, d, depth + 1);
120
+ };
121
+ flatten(p0, c1, c2, p1, 0);
122
+ return points;
123
+ }
124
+ function flattenSubpath(subpath) {
125
+ const points = [{
126
+ x: subpath.startXPx,
127
+ y: subpath.startYPx
128
+ }];
129
+ for (const segment of subpath.segments) {
130
+ if (segment.kind === "line") {
131
+ points.push({
132
+ x: segment.xPx,
133
+ y: segment.yPx
134
+ });
135
+ continue;
136
+ }
137
+ const previous = points[points.length - 1];
138
+ if (previous === void 0) continue;
139
+ points.push(...flattenCubic(previous, {
140
+ x: segment.c1xPx,
141
+ y: segment.c1yPx
142
+ }, {
143
+ x: segment.c2xPx,
144
+ y: segment.c2yPx
145
+ }, {
146
+ x: segment.xPx,
147
+ y: segment.yPx
148
+ }));
149
+ }
150
+ return {
151
+ points,
152
+ closed: subpath.closed
153
+ };
154
+ }
155
+ function strokeOutlinePolygons(subpaths, widthPx, dashPx) {
156
+ const half = Math.max(widthPx, 1) / 2;
157
+ const polygons = [];
158
+ const emitPiece = (piece) => {
159
+ for (let i = 0; i + 1 < piece.length; i++) {
160
+ const p = piece[i];
161
+ const q = piece[i + 1];
162
+ if (p === void 0 || q === void 0) continue;
163
+ const d = unitDirection(p, q);
164
+ if (d === void 0) continue;
165
+ const n = {
166
+ x: -d.y,
167
+ y: d.x
168
+ };
169
+ polygons.push([
170
+ {
171
+ x: p.x + n.x * half,
172
+ y: p.y + n.y * half
173
+ },
174
+ {
175
+ x: q.x + n.x * half,
176
+ y: q.y + n.y * half
177
+ },
178
+ {
179
+ x: q.x - n.x * half,
180
+ y: q.y - n.y * half
181
+ },
182
+ {
183
+ x: p.x - n.x * half,
184
+ y: p.y - n.y * half
185
+ }
186
+ ]);
187
+ }
188
+ for (let i = 1; i + 1 < piece.length; i++) {
189
+ const vertex = piece[i];
190
+ const before = piece[i - 1];
191
+ const after = piece[i + 1];
192
+ if (vertex === void 0 || before === void 0 || after === void 0) continue;
193
+ emitJoinWedge(polygons, vertex, before, after, half);
194
+ }
195
+ };
196
+ if (dashPx === void 0) {
197
+ for (const subpath of subpaths) {
198
+ const first = subpath.points[0];
199
+ if (first === void 0) continue;
200
+ emitPiece(subpath.closed ? [...subpath.points, first] : subpath.points);
201
+ if (subpath.closed && subpath.points.length >= 3) {
202
+ const last = subpath.points[subpath.points.length - 1];
203
+ const second = subpath.points[1];
204
+ if (last !== void 0 && second !== void 0) emitJoinWedge(polygons, first, last, second, half);
205
+ }
206
+ }
207
+ return polygons;
208
+ }
209
+ for (const subpath of subpaths) for (const piece of dashPolyline(subpath, dashPx)) emitPiece(piece);
210
+ return polygons;
211
+ }
212
+ function emitJoinWedge(polygons, vertex, before, after, half) {
213
+ const d1 = unitDirection(before, vertex);
214
+ const d2 = unitDirection(vertex, after);
215
+ if (d1 === void 0 || d2 === void 0) return;
216
+ const cross = d1.x * d2.y - d1.y * d2.x;
217
+ if (Math.abs(cross) < 1e-12) return;
218
+ const side = cross > 0 ? -1 : 1;
219
+ const n1 = {
220
+ x: -d1.y * side,
221
+ y: d1.x * side
222
+ };
223
+ const n2 = {
224
+ x: -d2.y * side,
225
+ y: d2.x * side
226
+ };
227
+ const a1 = {
228
+ x: vertex.x + n1.x * half,
229
+ y: vertex.y + n1.y * half
230
+ };
231
+ const a2 = {
232
+ x: vertex.x + n2.x * half,
233
+ y: vertex.y + n2.y * half
234
+ };
235
+ const dot = n1.x * n2.x + n1.y * n2.y;
236
+ const denominator = 1 + dot;
237
+ let wedge;
238
+ if (denominator > 1e-12 && miterRatio(dot) <= 10) {
239
+ const scale = half / denominator;
240
+ wedge = [
241
+ a1,
242
+ {
243
+ x: vertex.x + (n1.x + n2.x) * scale,
244
+ y: vertex.y + (n1.y + n2.y) * scale
245
+ },
246
+ a2,
247
+ vertex
248
+ ];
249
+ } else wedge = [
250
+ a1,
251
+ vertex,
252
+ a2
253
+ ];
254
+ polygons.push(polygonSignedArea(wedge) < 0 ? [...wedge] : [...wedge].reverse());
255
+ }
256
+ function miterRatio(dot) {
257
+ return Math.sqrt(2 + 2 * dot) / (1 + dot);
258
+ }
259
+ function polygonSignedArea(polygon) {
260
+ let area = 0;
261
+ for (let i = 0; i < polygon.length; i++) {
262
+ const a = polygon[i];
263
+ const b = polygon[(i + 1) % polygon.length];
264
+ if (a === void 0 || b === void 0) continue;
265
+ area += a.x * b.y - b.x * a.y;
266
+ }
267
+ return area;
268
+ }
269
+ function unitDirection(p, q) {
270
+ const dx = q.x - p.x;
271
+ const dy = q.y - p.y;
272
+ const length = Math.hypot(dx, dy);
273
+ if (length === 0) return;
274
+ return {
275
+ x: dx / length,
276
+ y: dy / length
277
+ };
278
+ }
279
+ function dashPolyline(subpath, dashPx) {
280
+ if (!dashPx.some((length) => length > 0)) return [];
281
+ const pattern = dashPx.length % 2 === 1 ? [...dashPx, ...dashPx] : dashPx;
282
+ const points = subpath.closed ? [...subpath.points, subpath.points[0]] : subpath.points;
283
+ const start = points[0];
284
+ if (start === void 0) return [];
285
+ const pieces = [];
286
+ const endOnPiece = () => {
287
+ if (current.length >= 2) pieces.push(current);
288
+ current = [];
289
+ };
290
+ const atBoundary = () => {
291
+ if (index % 2 === 0) endOnPiece();
292
+ for (;;) {
293
+ index = (index + 1) % pattern.length;
294
+ const entry = pattern[index];
295
+ if (entry !== void 0 && entry > 0) {
296
+ remaining = entry;
297
+ break;
298
+ }
299
+ }
300
+ if (index % 2 === 0) current = [cursor];
301
+ };
302
+ let index = 0;
303
+ let remaining = 0;
304
+ let cursor = start;
305
+ let current = [];
306
+ const firstEntry = pattern[0];
307
+ if (firstEntry !== void 0 && firstEntry > 0) {
308
+ remaining = firstEntry;
309
+ current = [cursor];
310
+ } else atBoundary();
311
+ for (let i = 0; i + 1 < points.length; i++) {
312
+ const from = points[i];
313
+ const to = points[i + 1];
314
+ if (from === void 0 || to === void 0) continue;
315
+ const direction = unitDirection(from, to);
316
+ if (direction === void 0) continue;
317
+ cursor = from;
318
+ let segmentRemaining = Math.hypot(to.x - from.x, to.y - from.y);
319
+ while (segmentRemaining > 0 && remaining > 0) {
320
+ const step = Math.min(segmentRemaining, remaining);
321
+ cursor = {
322
+ x: cursor.x + direction.x * step,
323
+ y: cursor.y + direction.y * step
324
+ };
325
+ segmentRemaining -= step;
326
+ remaining -= step;
327
+ if (current.length > 0) current.push(cursor);
328
+ if (remaining === 0) atBoundary();
329
+ }
330
+ }
331
+ if (current.length > 0) endOnPiece();
332
+ return pieces;
333
+ }
334
+ //#endregion
335
+ //#region src/rasteriser.ts
336
+ function createCpuRasteriser(options = {}) {
337
+ return new CpuRasteriser(options);
338
+ }
339
+ var CpuRasteriser = class {
340
+ onDiagnostic;
341
+ buffers;
342
+ decodedImages = /* @__PURE__ */ new Map();
343
+ constructor(options = {}) {
344
+ this.onDiagnostic = options.onDiagnostic;
345
+ }
346
+ beginPage(geometry) {
347
+ this.buffers = {
348
+ geometry,
349
+ canvas: new Uint8Array(geometry.widthPx * geometry.heightPx * 3).fill(255),
350
+ mask: new CoverageMask(geometry.widthPx, geometry.heightPx)
351
+ };
352
+ this.decodedImages.clear();
353
+ }
354
+ draw(op) {
355
+ if (op.kind === "fillRect") {
356
+ this.fillRectOp(op);
357
+ return;
358
+ }
359
+ if (op.kind === "path") {
360
+ this.pathOp(op);
361
+ return;
362
+ }
363
+ this.imageOp(op);
364
+ }
365
+ finish() {
366
+ const { geometry, canvas } = this.requirePage("finish");
367
+ const png = (0, byte_codec.encodePng)({
368
+ width: geometry.widthPx,
369
+ height: geometry.heightPx,
370
+ channels: 3,
371
+ data: canvas
372
+ });
373
+ this.buffers = void 0;
374
+ return png;
375
+ }
376
+ requirePage(operation) {
377
+ if (this.buffers === void 0) throw new Error(`CpuRasteriser.${operation}() was called before beginPage(); renderPdfPage (or the caller) drives one page at a time through beginPage, draw..., finish`);
378
+ return this.buffers;
379
+ }
380
+ fillRectOp(op) {
381
+ const { geometry, canvas } = this.requirePage("draw");
382
+ const left = Math.max(op.xPx, 0);
383
+ const right = Math.min(op.xPx + op.widthPx, geometry.widthPx);
384
+ const top = Math.max(op.yPx, 0);
385
+ const bottom = Math.min(op.yPx + op.heightPx, geometry.heightPx);
386
+ if (right <= left || bottom <= top) return;
387
+ const colour = colourBytes(op.color);
388
+ const columnStart = Math.floor(left);
389
+ const columnEnd = Math.ceil(right);
390
+ const rowStart = Math.floor(top);
391
+ const rowEnd = Math.ceil(bottom);
392
+ for (let row = rowStart; row < rowEnd; row++) {
393
+ const coverageY = pixelOverlap(row, top, bottom);
394
+ if (coverageY === 0) continue;
395
+ const rowBase = row * geometry.widthPx;
396
+ for (let column = columnStart; column < columnEnd; column++) {
397
+ const coverage = coverageY * pixelOverlap(column, left, right);
398
+ if (coverage === 0) continue;
399
+ this.blendPixel(canvas, rowBase + column, colour, coverage);
400
+ }
401
+ }
402
+ }
403
+ pathOp(op) {
404
+ const { canvas, mask } = this.requirePage("draw");
405
+ const flattened = op.subpaths.map(flattenSubpath);
406
+ if (op.fill !== void 0) {
407
+ const polygons = flattened.map((subpath) => subpath.points).filter((points) => points.length >= 3);
408
+ mask.reset();
409
+ mask.fillPolygons(polygons, op.fill.fillRule);
410
+ this.blendMask(canvas, mask, op.fill.color);
411
+ }
412
+ if (op.stroke !== void 0) {
413
+ const polygons = strokeOutlinePolygons(flattened, op.stroke.widthPx, op.stroke.dashPx);
414
+ mask.reset();
415
+ mask.fillPolygons(polygons, "nonzero");
416
+ this.blendMask(canvas, mask, op.stroke.color);
417
+ }
418
+ }
419
+ imageOp(op) {
420
+ const { geometry, canvas, mask } = this.requirePage("draw");
421
+ if (op.format === "jpeg") {
422
+ this.onDiagnostic?.({
423
+ code: "raster-cpu/jpeg-image-undecoded",
424
+ message: "a JPEG (DCTDecode) image placement was not drawn: this backend decodes PNG images only (byte-codec has no DCT decoder), and approximating the scan's pixels would misrender it; the op's bytes remain available to the caller through the port"
425
+ });
426
+ return;
427
+ }
428
+ const source = this.decodeCached(op.bytes);
429
+ const quad = quadCorners(op.matrix);
430
+ mask.reset();
431
+ mask.fillPolygons([quad], "nonzero");
432
+ const inverse = invertMatrix(op.matrix);
433
+ const rowStart = Math.max(0, Math.floor(Math.min(...quad.map((corner) => corner.y))));
434
+ const rowEnd = Math.min(geometry.heightPx, Math.ceil(Math.max(...quad.map((corner) => corner.y))));
435
+ const columnStart = Math.max(0, Math.floor(Math.min(...quad.map((corner) => corner.x))));
436
+ const columnEnd = Math.min(geometry.widthPx, Math.ceil(Math.max(...quad.map((corner) => corner.x))));
437
+ for (let row = rowStart; row < rowEnd; row++) {
438
+ const rowBase = row * geometry.widthPx;
439
+ for (let column = columnStart; column < columnEnd; column++) {
440
+ const samples = mask.countAt(rowBase + column);
441
+ if (samples === 0) continue;
442
+ const [u, v] = inverse(column + .5, row + .5);
443
+ const [r, g, b, a] = sampleBilinear(source, u, v);
444
+ this.blendPixel(canvas, rowBase + column, {
445
+ r,
446
+ g,
447
+ b
448
+ }, samples / 16 * a);
449
+ }
450
+ }
451
+ }
452
+ decodeCached(bytes) {
453
+ const key = `${bytes.length}:${(0, byte_codec.crc32)(bytes)}`;
454
+ const cached = this.decodedImages.get(key);
455
+ if (cached !== void 0) return cached;
456
+ const decoded = (0, byte_codec.decodePng)(bytes);
457
+ this.decodedImages.set(key, decoded);
458
+ return decoded;
459
+ }
460
+ blendMask(canvas, mask, color) {
461
+ const colour = colourBytes(color);
462
+ const pixels = mask.widthPx * mask.heightPx;
463
+ for (let i = 0; i < pixels; i++) {
464
+ const samples = mask.countAt(i);
465
+ if (samples === 0) continue;
466
+ this.blendPixel(canvas, i, colour, samples / 16);
467
+ }
468
+ }
469
+ blendPixel(canvas, pixelIndex, colour, alpha) {
470
+ const base = pixelIndex * 3;
471
+ canvas[base] = Math.round((canvas[base] ?? 0) + (colour.r - (canvas[base] ?? 0)) * alpha);
472
+ canvas[base + 1] = Math.round((canvas[base + 1] ?? 0) + (colour.g - (canvas[base + 1] ?? 0)) * alpha);
473
+ canvas[base + 2] = Math.round((canvas[base + 2] ?? 0) + (colour.b - (canvas[base + 2] ?? 0)) * alpha);
474
+ }
475
+ };
476
+ function colourBytes(color) {
477
+ return {
478
+ r: Math.round(color.r * 255),
479
+ g: Math.round(color.g * 255),
480
+ b: Math.round(color.b * 255)
481
+ };
482
+ }
483
+ function pixelOverlap(index, edgeLow, edgeHigh) {
484
+ return Math.max(0, Math.min(index + 1, edgeHigh) - Math.max(index, edgeLow));
485
+ }
486
+ function quadCorners(matrix) {
487
+ const [a, b, c, d, e, f] = matrix;
488
+ const at = (u, v) => ({
489
+ x: a * u + c * v + e,
490
+ y: b * u + d * v + f
491
+ });
492
+ return [
493
+ at(0, 0),
494
+ at(1, 0),
495
+ at(1, 1),
496
+ at(0, 1)
497
+ ];
498
+ }
499
+ function invertMatrix(matrix) {
500
+ const [a, b, c, d, e, f] = matrix;
501
+ const determinant = a * d - b * c;
502
+ return (xPx, yPx) => {
503
+ const dx = xPx - e;
504
+ const dy = yPx - f;
505
+ return [(d * dx - c * dy) / determinant, (-b * dx + a * dy) / determinant];
506
+ };
507
+ }
508
+ function sampleBilinear(source, u, v) {
509
+ const clampedU = Math.min(Math.max(u, 0), 1);
510
+ const clampedV = Math.min(Math.max(v, 0), 1);
511
+ const sx = Math.min(Math.max(clampedU * source.width - .5, 0), source.width - 1);
512
+ const sy = Math.min(Math.max(clampedV * source.height - .5, 0), source.height - 1);
513
+ const x0 = Math.floor(sx);
514
+ const y0 = Math.floor(sy);
515
+ const x1 = Math.min(x0 + 1, source.width - 1);
516
+ const y1 = Math.min(y0 + 1, source.height - 1);
517
+ const fx = sx - x0;
518
+ const fy = sy - y0;
519
+ const channelCount = source.channels === 1 ? 1 : 3;
520
+ const sample = (x, y, channel) => {
521
+ const index = (y * source.width + x) * channelCount + channel;
522
+ return source.data[index] ?? 0;
523
+ };
524
+ const lerpChannel = (channel) => {
525
+ const top0 = sample(x0, y0, channel);
526
+ const top1 = sample(x1, y0, channel);
527
+ const bottom0 = sample(x0, y1, channel);
528
+ const bottom1 = sample(x1, y1, channel);
529
+ const top = top0 + (top1 - top0) * fx;
530
+ return top + (bottom0 + (bottom1 - bottom0) * fx - top) * fy;
531
+ };
532
+ let r;
533
+ let g;
534
+ let b;
535
+ if (channelCount === 1) {
536
+ r = lerpChannel(0);
537
+ g = r;
538
+ b = r;
539
+ } else {
540
+ r = lerpChannel(0);
541
+ g = lerpChannel(1);
542
+ b = lerpChannel(2);
543
+ }
544
+ const alpha = source.alpha;
545
+ if (alpha === void 0) return [
546
+ r,
547
+ g,
548
+ b,
549
+ 1
550
+ ];
551
+ const alphaAt = (x, y) => alpha[y * source.width + x] ?? 0;
552
+ const alphaTop = alphaAt(x0, y0) + (alphaAt(x1, y0) - alphaAt(x0, y0)) * fx;
553
+ const alphaBottom = alphaAt(x0, y1) + (alphaAt(x1, y1) - alphaAt(x0, y1)) * fx;
554
+ return [
555
+ r,
556
+ g,
557
+ b,
558
+ (alphaTop + (alphaBottom - alphaTop) * fy) / 255
559
+ ];
560
+ }
561
+ //#endregion
562
+ exports.CpuRasteriser = CpuRasteriser;
563
+ exports.createCpuRasteriser = createCpuRasteriser;