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 +21 -0
- package/README.md +117 -0
- package/dist/index.cjs +563 -0
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +561 -0
- package/package.json +98 -2
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
|
+
[](https://github.com/ExaDev/documents.js/tree/main/packages/pdf-raster-cpu) [](https://www.npmjs.com/package/pdf-raster-cpu) [](https://www.npmjs.com/package/pdf-raster-cpu) [](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;
|