@zumer/snapdom-plugins 3.0.0 → 4.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/CHANGELOG.md +13 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +16 -5
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,2571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVD -> PDF. The vector backend.
|
|
3
|
+
*
|
|
4
|
+
* The sibling product, `@zumer/snapdom-pdf`, writes a raster page with an
|
|
5
|
+
* invisible text layer on top. This file writes the other thing: the page is PDF
|
|
6
|
+
* *drawing operators* built from the SVD, so a box is a path, a gradient is a
|
|
7
|
+
* shading, and a line of text is a `Tj` at its measured baseline. Nothing is
|
|
8
|
+
* rasterized here that did not arrive as a raster in `doc.assets`.
|
|
9
|
+
*
|
|
10
|
+
* The byte-level writer under `../writer/` is this product's own source. It was a
|
|
11
|
+
* package shared with the PDF product while both lived in one repository; they are
|
|
12
|
+
* separate repositories now and each owns its copy outright. The two are free to
|
|
13
|
+
* diverge and neither can break the other — which is the point, and also the
|
|
14
|
+
* price: a fix here does not reach the other one.
|
|
15
|
+
*
|
|
16
|
+
* It is the sibling of `svg-flat.js` and it follows the same reading of the SVD
|
|
17
|
+
* invariants:
|
|
18
|
+
*
|
|
19
|
+
* - Children are emitted sorted by `paint.z` (invariant 3), never re-derived.
|
|
20
|
+
* - `blur` is the CSS radius, so every Gaussian sigma here is `blur / 2`
|
|
21
|
+
* (invariant 5), in exactly one helper.
|
|
22
|
+
* - Gradient geometry is unit square + 2x3 matrix in PX (invariant 6). The
|
|
23
|
+
* matrix is pushed with `cm` AFTER the clip is set, which is the one thing SVG
|
|
24
|
+
* could not do (there `transform` establishes the space its own `clip-path` is
|
|
25
|
+
* resolved in).
|
|
26
|
+
* - `unit` keys are read verbatim per CONTRACT.md: `linear:{p0,p1}`,
|
|
27
|
+
* `radial:{center,radius,shape}`, `angular:{center,startAngle}`.
|
|
28
|
+
*
|
|
29
|
+
* What PDF makes hard, and what this file does about it — all of it declared
|
|
30
|
+
* through `ctx.note()`, none of it silent:
|
|
31
|
+
*
|
|
32
|
+
* - **Alpha.** The basic graphics state has none. Every translucent fill, stroke
|
|
33
|
+
* and group goes through an `ExtGState` with `/ca` and `/CA`.
|
|
34
|
+
* - **Gradient stop alpha.** A shading carries colour, never alpha. A ramp whose
|
|
35
|
+
* stops differ in alpha is emitted with a **luminosity soft mask**: the same
|
|
36
|
+
* shading geometry, in DeviceGray, whose value IS the alpha, inside a
|
|
37
|
+
* transparency group referenced from `/SMask`. That is exact, not an
|
|
38
|
+
* approximation, and it is why `radial-gradient(..., rgba(255,255,255,0))`
|
|
39
|
+
* does not come out as an opaque white ellipse.
|
|
40
|
+
* - **Conic gradients.** PDF has no angular shading, so they are composed as a
|
|
41
|
+
* fan of flat sectors, the same construction `svg-flat.js` uses — laid down
|
|
42
|
+
* over an opaque disc and two bands wide each, because sectors that only
|
|
43
|
+
* touch let the backdrop through their shared antialiased edge and turn a
|
|
44
|
+
* conic into a pinwheel of spokes.
|
|
45
|
+
* - **Shadows and blur.** PDF has neither. A shadow with no blur is one offset
|
|
46
|
+
* path. A blurred one is composed as concentric grown/shrunk copies of the
|
|
47
|
+
* path whose accumulated opacity follows the Gaussian edge profile — the
|
|
48
|
+
* "layers" option of the two honest ones. **Nothing is rasterized to fake it**,
|
|
49
|
+
* and every blurred shadow says so in the diagnostics.
|
|
50
|
+
* - **Text.** One `BT`/`ET` per block, one `Tj` per measured line at
|
|
51
|
+
* `text.lines[].baseline` — the SVD already broke the lines and PDF is never
|
|
52
|
+
* allowed to re-break them. `Tc`/`Tw` carry letter/word spacing and `Tz`
|
|
53
|
+
* stretches the line to the width the browser measured. There are no font
|
|
54
|
+
* binaries in an SVD today, so the glyphs are base-14 and the diagnostics name
|
|
55
|
+
* the substitution, family by family. A character WinAnsi cannot address is
|
|
56
|
+
* drawn from Symbol or ZapfDingbats when either has it — that is what makes
|
|
57
|
+
* `★★★★☆`, `→` and `Δ` appear at all — with a `/ToUnicode` so the character
|
|
58
|
+
* on the clipboard is still the page's. What neither font has stays invisible
|
|
59
|
+
* but selectable, and says so.
|
|
60
|
+
*
|
|
61
|
+
* Pure and DOM-free in Node for everything except one path: a raster asset that
|
|
62
|
+
* is not already a JPEG needs a canvas to re-encode, and that path is taken only
|
|
63
|
+
* when a DOM is present. Without one it degrades, loudly.
|
|
64
|
+
*/
|
|
65
|
+
// The byte-level writer, from the internal package both paid plugins share. It
|
|
66
|
+
// used to be a relative path into `packages/pdf`, which made this product import
|
|
67
|
+
// the other product's source: undeclared, unbuildable on its own, and a licence
|
|
68
|
+
// question nobody wanted to answer. the vendored writer is bundled into this
|
|
69
|
+
// build and into that one separately, so the two plugins ship with no reference
|
|
70
|
+
// to each other. Nothing above the object table is shared, and nothing here may
|
|
71
|
+
// import from `packages/pdf` again.
|
|
72
|
+
import {
|
|
73
|
+
createPdfDoc, pdfString, BASE14, deflate, canDeflate, createUnicodeFont, encodeImage,
|
|
74
|
+
setWarnPrefix,
|
|
75
|
+
} from '../writer/index.js'
|
|
76
|
+
|
|
77
|
+
setWarnPrefix('[snapdom-vector]')
|
|
78
|
+
|
|
79
|
+
/** CSS px -> PDF points (96dpi -> 72dpi). PDF's own unit, not a shared constant. */
|
|
80
|
+
const PT = 0.75
|
|
81
|
+
|
|
82
|
+
/** Circular arc as a cubic: the classic 4/3·tan(θ/4), θ = 90°. */
|
|
83
|
+
const KAPPA = 0.5523
|
|
84
|
+
|
|
85
|
+
/** How many flat wedges approximate one conic gradient. 2.8° each. */
|
|
86
|
+
const CONIC_WEDGES = 128
|
|
87
|
+
|
|
88
|
+
/** Unit-space radius of the conic fan: a unit square's far corner is at √2. */
|
|
89
|
+
const CONIC_REACH = 2
|
|
90
|
+
|
|
91
|
+
/** Degrees between two points on a sector's outer boundary. See `sectorPath`. */
|
|
92
|
+
const CONIC_STEP = 45
|
|
93
|
+
|
|
94
|
+
/** Painted px of the disc that caps the point 128 sectors cannot resolve. */
|
|
95
|
+
const CONIC_APEX_PX = 1.25
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Concentric copies per blurred shadow.
|
|
99
|
+
*
|
|
100
|
+
* Measured against Chromium's own blur on the `0 12px 28px -8px` card shadow:
|
|
101
|
+
* 10 copies leave a staircase whose steps read 14/255, which is visible on a
|
|
102
|
+
* flat backdrop; 24 bring it to ~5/255, at the price of ~40% more content
|
|
103
|
+
* stream. Anything past 24 buys under 2/255.
|
|
104
|
+
*/
|
|
105
|
+
const SHADOW_LAYERS = 24
|
|
106
|
+
|
|
107
|
+
/** A Gaussian edge is dead by 3σ; that is where the outermost copy sits. */
|
|
108
|
+
const SHADOW_REACH = 3
|
|
109
|
+
|
|
110
|
+
const ZERO_RADII = [[0, 0], [0, 0], [0, 0], [0, 0]]
|
|
111
|
+
|
|
112
|
+
/** CSS `mix-blend-mode` -> PDF `/BM`. `plus-lighter` has no PDF equivalent. */
|
|
113
|
+
const BLEND_MODES = {
|
|
114
|
+
normal: 'Normal', multiply: 'Multiply', screen: 'Screen', overlay: 'Overlay',
|
|
115
|
+
darken: 'Darken', lighten: 'Lighten', 'color-dodge': 'ColorDodge',
|
|
116
|
+
'color-burn': 'ColorBurn', 'hard-light': 'HardLight', 'soft-light': 'SoftLight',
|
|
117
|
+
difference: 'Difference', exclusion: 'Exclusion', hue: 'Hue',
|
|
118
|
+
saturation: 'Saturation', color: 'Color', luminosity: 'Luminosity',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Diagnostic severity per SVD fidelity grade — the orchestrator's own table. */
|
|
122
|
+
const SEVERITY_OF = { E: 'info', A: 'info', C: 'info', R: 'warn', RH: 'warn', O: 'warn' }
|
|
123
|
+
|
|
124
|
+
// ——— primitives ———
|
|
125
|
+
|
|
126
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
127
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
|
128
|
+
const finite = (v, dflt = 0) => (Number.isFinite(Number(v)) ? Number(v) : dflt)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A number for a content stream. PDF has no exponent syntax, so anything that
|
|
132
|
+
* would serialize as `1e-7` has to come out as a decimal or not at all.
|
|
133
|
+
*/
|
|
134
|
+
function fmt (v, places) {
|
|
135
|
+
const n = Number.isFinite(Number(v)) ? Number(v) : 0
|
|
136
|
+
if (Math.abs(n) < 5e-7) return '0'
|
|
137
|
+
let s = n.toFixed(places)
|
|
138
|
+
if (s.indexOf('.') !== -1) s = s.replace(/0+$/, '').replace(/\.$/, '')
|
|
139
|
+
return s === '-0' ? '0' : s
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Coordinates: 4 decimals of a CSS px is far below a device pixel. */
|
|
143
|
+
const num = (v) => fmt(v, 4)
|
|
144
|
+
|
|
145
|
+
/** Matrix entries: a scale of 1/1000 still needs to survive rounding. */
|
|
146
|
+
const nm = (v) => fmt(v, 6)
|
|
147
|
+
|
|
148
|
+
const clamp01 = (v) => (isNum(v) ? Math.min(1, Math.max(0, v)) : 1)
|
|
149
|
+
|
|
150
|
+
/** Uppercase hex of a fixed width, for CMap entries. */
|
|
151
|
+
const hex = (n, width) => n.toString(16).toUpperCase().padStart(width, '0')
|
|
152
|
+
|
|
153
|
+
/** A `/ToUnicode` destination is UTF-16BE, so an astral codepoint is a pair. */
|
|
154
|
+
function utf16be (cp) {
|
|
155
|
+
if (cp <= 0xffff) return hex(cp, 4)
|
|
156
|
+
const v = cp - 0x10000
|
|
157
|
+
return hex(0xd800 | (v >> 10), 4) + hex(0xdc00 | (v & 0x3ff), 4)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const rectOf = (r) => ({
|
|
161
|
+
x: finite(r && r.x), y: finite(r && r.y),
|
|
162
|
+
w: Math.max(0, finite(r && r.w)), h: Math.max(0, finite(r && r.h)),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/** `m1 · m2`: the point is transformed by `m2` first — SVG's reading order. */
|
|
166
|
+
function multiply (m1, m2) {
|
|
167
|
+
return [
|
|
168
|
+
m1[0] * m2[0] + m1[2] * m2[1],
|
|
169
|
+
m1[1] * m2[0] + m1[3] * m2[1],
|
|
170
|
+
m1[0] * m2[2] + m1[2] * m2[3],
|
|
171
|
+
m1[1] * m2[2] + m1[3] * m2[3],
|
|
172
|
+
m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
|
|
173
|
+
m1[1] * m2[4] + m1[3] * m2[5] + m1[5],
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const matrixOp = (m) => `${m.map(nm).join(' ')} cm`
|
|
178
|
+
|
|
179
|
+
const IDENTITY = [1, 0, 0, 1, 0, 0]
|
|
180
|
+
|
|
181
|
+
/** `a` then `b`, in PDF's own order: the point goes through `a` first. */
|
|
182
|
+
const matMul = (a, b) => [
|
|
183
|
+
a[0] * b[0] + a[1] * b[2], a[0] * b[1] + a[1] * b[3],
|
|
184
|
+
a[2] * b[0] + a[3] * b[2], a[2] * b[1] + a[3] * b[3],
|
|
185
|
+
a[4] * b[0] + a[5] * b[2] + b[4], a[4] * b[1] + a[5] * b[3] + b[5],
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
const applyMat = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The axis-aligned box of a rect after a matrix — all four corners, not two,
|
|
192
|
+
* because a rotated run's bounding box is not the transform of its corners.
|
|
193
|
+
*/
|
|
194
|
+
function transformBox (m, x0, y0, x1, y1) {
|
|
195
|
+
const pts = [applyMat(m, x0, y0), applyMat(m, x1, y0), applyMat(m, x1, y1), applyMat(m, x0, y1)]
|
|
196
|
+
return [
|
|
197
|
+
Math.min(...pts.map((p) => p[0])), Math.min(...pts.map((p) => p[1])),
|
|
198
|
+
Math.max(...pts.map((p) => p[0])), Math.max(...pts.map((p) => p[1])),
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const isMatrix = (m) => Array.isArray(m) && m.length === 6 && m.every((v) => Number.isFinite(Number(v)))
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The caller's page geometry, validated. A malformed entry is not silently
|
|
206
|
+
* dropped into a default page — it is the difference between a document that
|
|
207
|
+
* prints and one that does not, and the caller cannot see the PDF.
|
|
208
|
+
*/
|
|
209
|
+
function readPages (pages) {
|
|
210
|
+
if (pages === null || pages === undefined) return null
|
|
211
|
+
if (!Array.isArray(pages) || !pages.length) {
|
|
212
|
+
throw new TypeError('svdToPdf: `pages`, when given, must be a non-empty array of page geometries')
|
|
213
|
+
}
|
|
214
|
+
return pages.map((p, i) => {
|
|
215
|
+
if (!isObject(p)) throw new TypeError(`svdToPdf: pages[${i}] is not an object`)
|
|
216
|
+
const size = p.size
|
|
217
|
+
if (!Array.isArray(size) || size.length !== 2 || !(finite(size[0]) > 0) || !(finite(size[1]) > 0)) {
|
|
218
|
+
throw new TypeError(`svdToPdf: pages[${i}].size must be [w, h] in points, both > 0`)
|
|
219
|
+
}
|
|
220
|
+
if (!isMatrix(p.matrix)) {
|
|
221
|
+
throw new TypeError(`svdToPdf: pages[${i}].matrix must be a 6-number PDF matrix`)
|
|
222
|
+
}
|
|
223
|
+
let clip = null
|
|
224
|
+
if (p.clip !== null && p.clip !== undefined) {
|
|
225
|
+
if (!Array.isArray(p.clip) || p.clip.length !== 4 || !p.clip.every((v) => Number.isFinite(Number(v)))) {
|
|
226
|
+
throw new TypeError(`svdToPdf: pages[${i}].clip must be [x, y, w, h] in page points`)
|
|
227
|
+
}
|
|
228
|
+
clip = p.clip.map(Number)
|
|
229
|
+
}
|
|
230
|
+
return { size: [Number(size[0]), Number(size[1])], matrix: p.matrix.map(Number), clip }
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const readMatrix = (v) => (Array.isArray(v) && v.length === 6 && v.every(isNum) ? v : null)
|
|
235
|
+
|
|
236
|
+
// ——— colour ———
|
|
237
|
+
|
|
238
|
+
const channel = (rgba, i) => Math.min(1, Math.max(0, finite(rgba && rgba[i], i === 3 ? 1 : 0)))
|
|
239
|
+
|
|
240
|
+
const alphaOf = (rgba) => (Array.isArray(rgba) && isNum(rgba[3]) ? clamp01(rgba[3]) : 1)
|
|
241
|
+
|
|
242
|
+
/** Non-stroking colour. */
|
|
243
|
+
const fillColorOp = (rgba) => `${num(channel(rgba, 0))} ${num(channel(rgba, 1))} ${num(channel(rgba, 2))} rg`
|
|
244
|
+
|
|
245
|
+
/** Stroking colour. */
|
|
246
|
+
const strokeColorOp = (rgba) => `${num(channel(rgba, 0))} ${num(channel(rgba, 1))} ${num(channel(rgba, 2))} RG`
|
|
247
|
+
|
|
248
|
+
const colorArray = (rgba) => `[${num(channel(rgba, 0))} ${num(channel(rgba, 1))} ${num(channel(rgba, 2))}]`
|
|
249
|
+
|
|
250
|
+
// ——— geometry ———
|
|
251
|
+
|
|
252
|
+
function normalizeRadii (radii, w, h) {
|
|
253
|
+
if (!Array.isArray(radii) || radii.length !== 4) return ZERO_RADII
|
|
254
|
+
const out = radii.map((corner) => (Array.isArray(corner)
|
|
255
|
+
? [Math.max(0, finite(corner[0])), Math.max(0, finite(corner[1]))]
|
|
256
|
+
: [0, 0]))
|
|
257
|
+
// parseRadii already applied CSS Backgrounds §5.5, but a hand-assembled
|
|
258
|
+
// document has not, and overlapping arcs are a self-intersecting path here.
|
|
259
|
+
let f = 1
|
|
260
|
+
const limit = (side, a, b) => {
|
|
261
|
+
const sum = a + b
|
|
262
|
+
if (sum > 0 && side >= 0) f = Math.min(f, side / sum)
|
|
263
|
+
}
|
|
264
|
+
limit(w, out[0][0], out[1][0])
|
|
265
|
+
limit(h, out[1][1], out[2][1])
|
|
266
|
+
limit(w, out[3][0], out[2][0])
|
|
267
|
+
limit(h, out[0][1], out[3][1])
|
|
268
|
+
return f < 1 ? out.map(([rx, ry]) => [rx * f, ry * f]) : out
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const hasRadius = (radii) => radii.some(([rx, ry]) => rx > 0 && ry > 0)
|
|
272
|
+
|
|
273
|
+
/** Grow (d < 0) or shrink (d > 0) radii. A square corner stays square growing. */
|
|
274
|
+
function offsetRadii (radii, d) {
|
|
275
|
+
if (!d) return radii
|
|
276
|
+
return radii.map(([rx, ry]) => (d > 0
|
|
277
|
+
? [Math.max(0, rx - d), Math.max(0, ry - d)]
|
|
278
|
+
: [rx > 0 ? rx - d : 0, ry > 0 ? ry - d : 0]))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const rectPath = (x, y, w, h) => `${num(x)} ${num(y)} ${num(w)} ${num(h)} re`
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A rounded rectangle with four independent `[rx, ry]` corners, as cubics.
|
|
285
|
+
*
|
|
286
|
+
* PDF has no arc operator, so each quarter ellipse is one Bézier with control
|
|
287
|
+
* points at KAPPA of the corner's own rx/ry — which is what makes an anisotropic
|
|
288
|
+
* corner (`border-radius: 40px / 12px`) come out as the ellipse CSS asks for and
|
|
289
|
+
* not as a circle of one of the two radii. The corners run tl -> tr -> br -> bl,
|
|
290
|
+
* clockwise in this y-down space, the same direction `svg-flat.js` sweeps.
|
|
291
|
+
*/
|
|
292
|
+
function boxPath (box, radii) {
|
|
293
|
+
const [tl, tr, br, bl] = radii
|
|
294
|
+
const { x, y } = box
|
|
295
|
+
const x2 = x + box.w
|
|
296
|
+
const y2 = y + box.h
|
|
297
|
+
const ops = [`${num(x + tl[0])} ${num(y)} m`]
|
|
298
|
+
ops.push(`${num(x2 - tr[0])} ${num(y)} l`)
|
|
299
|
+
if (tr[0] > 0 && tr[1] > 0) {
|
|
300
|
+
ops.push(`${num(x2 - tr[0] + KAPPA * tr[0])} ${num(y)} ` +
|
|
301
|
+
`${num(x2)} ${num(y + tr[1] - KAPPA * tr[1])} ${num(x2)} ${num(y + tr[1])} c`)
|
|
302
|
+
}
|
|
303
|
+
ops.push(`${num(x2)} ${num(y2 - br[1])} l`)
|
|
304
|
+
if (br[0] > 0 && br[1] > 0) {
|
|
305
|
+
ops.push(`${num(x2)} ${num(y2 - br[1] + KAPPA * br[1])} ` +
|
|
306
|
+
`${num(x2 - br[0] + KAPPA * br[0])} ${num(y2)} ${num(x2 - br[0])} ${num(y2)} c`)
|
|
307
|
+
}
|
|
308
|
+
ops.push(`${num(x + bl[0])} ${num(y2)} l`)
|
|
309
|
+
if (bl[0] > 0 && bl[1] > 0) {
|
|
310
|
+
ops.push(`${num(x + bl[0] - KAPPA * bl[0])} ${num(y2)} ` +
|
|
311
|
+
`${num(x)} ${num(y2 - bl[1] + KAPPA * bl[1])} ${num(x)} ${num(y2 - bl[1])} c`)
|
|
312
|
+
}
|
|
313
|
+
ops.push(`${num(x)} ${num(y + tl[1])} l`)
|
|
314
|
+
if (tl[0] > 0 && tl[1] > 0) {
|
|
315
|
+
ops.push(`${num(x)} ${num(y + tl[1] - KAPPA * tl[1])} ` +
|
|
316
|
+
`${num(x + tl[0] - KAPPA * tl[0])} ${num(y)} ${num(x + tl[0])} ${num(y)} c`)
|
|
317
|
+
}
|
|
318
|
+
ops.push('h')
|
|
319
|
+
return ops.join('\n')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The box as a path, choosing `re` when there is no corner to draw. */
|
|
323
|
+
function shapePath (box, radii) {
|
|
324
|
+
return hasRadius(radii) ? boxPath(box, radii) : rectPath(box.x, box.y, box.w, box.h)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A circle as four cubics — the conic fan's apex cap. */
|
|
328
|
+
function circlePath (cx, cy, r) {
|
|
329
|
+
const k = KAPPA * r
|
|
330
|
+
return [
|
|
331
|
+
`${num(cx + r)} ${num(cy)} m`,
|
|
332
|
+
`${num(cx + r)} ${num(cy + k)} ${num(cx + k)} ${num(cy + r)} ${num(cx)} ${num(cy + r)} c`,
|
|
333
|
+
`${num(cx - k)} ${num(cy + r)} ${num(cx - r)} ${num(cy + k)} ${num(cx - r)} ${num(cy)} c`,
|
|
334
|
+
`${num(cx - r)} ${num(cy - k)} ${num(cx - k)} ${num(cy - r)} ${num(cx)} ${num(cy - r)} c`,
|
|
335
|
+
`${num(cx + k)} ${num(cy - r)} ${num(cx + r)} ${num(cy - k)} ${num(cx + r)} ${num(cy)} c`,
|
|
336
|
+
'h',
|
|
337
|
+
].join('\n')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Grow a box and its radii by `g` (negative shrinks). */
|
|
341
|
+
function grownBox (box, radii, g) {
|
|
342
|
+
return {
|
|
343
|
+
box: { x: box.x - g, y: box.y - g, w: box.w + 2 * g, h: box.h + 2 * g },
|
|
344
|
+
radii: offsetRadii(radii, -g),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ——— resources ———
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* One `/Resources` dictionary shared by the page and every form XObject.
|
|
352
|
+
*
|
|
353
|
+
* It has to be one indirect object because a transparency group that paints a
|
|
354
|
+
* shading needs that shading in scope, and a soft-mask group is reached from an
|
|
355
|
+
* ExtGState that lives in the very dictionary the group points back at. Sharing
|
|
356
|
+
* one object makes that circle legal instead of a duplication problem.
|
|
357
|
+
*/
|
|
358
|
+
function createResources (doc) {
|
|
359
|
+
const fonts = new Map()
|
|
360
|
+
const symbols = new Map()
|
|
361
|
+
const gstates = new Map()
|
|
362
|
+
const shadings = new Map()
|
|
363
|
+
const xobjects = new Map()
|
|
364
|
+
let unicode = null
|
|
365
|
+
|
|
366
|
+
const intern = (map, prefix, key, make) => {
|
|
367
|
+
const hit = map.get(key)
|
|
368
|
+
if (hit) return hit.name
|
|
369
|
+
const name = `${prefix}${map.size}`
|
|
370
|
+
map.set(key, { name, id: make(name) })
|
|
371
|
+
return name
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
/** A base-14 font resource, by BASE14 key. */
|
|
376
|
+
font: (key) => intern(fonts, 'F', key, () => doc.add(
|
|
377
|
+
`<< /Type /Font /Subtype /Type1 /BaseFont /${BASE14[key] || BASE14.sans} ` +
|
|
378
|
+
'/Encoding /WinAnsiEncoding >>'
|
|
379
|
+
)),
|
|
380
|
+
/**
|
|
381
|
+
* Symbol or ZapfDingbats — the two base-14 faces that are not Latin, and the
|
|
382
|
+
* only glyphs this backend can paint for a codepoint WinAnsi cannot address.
|
|
383
|
+
* Both have a BUILT-IN encoding, so no `/Encoding`: naming WinAnsi here is the
|
|
384
|
+
* classic way to get Helvetica's glyphs out of a Dingbats font.
|
|
385
|
+
*
|
|
386
|
+
* @param {'Symbol'|'ZapfDingbats'} base
|
|
387
|
+
* @returns {{name: string, used: Map<number, number>}} `used` is code -> the
|
|
388
|
+
* real codepoint, and it becomes the font's `/ToUnicode`: the glyph is
|
|
389
|
+
* Adobe's, but a copy-paste has to give back the character the page had.
|
|
390
|
+
*/
|
|
391
|
+
symbolFont (base) {
|
|
392
|
+
let entry = symbols.get(base)
|
|
393
|
+
if (!entry) {
|
|
394
|
+
entry = { name: `FS${symbols.size}`, id: doc.reserve(), used: new Map() }
|
|
395
|
+
symbols.set(base, entry)
|
|
396
|
+
}
|
|
397
|
+
return entry
|
|
398
|
+
},
|
|
399
|
+
/** Writes the symbol font dictionaries. Call once, before `body()`. */
|
|
400
|
+
finalizeSymbols () {
|
|
401
|
+
for (const [base, entry] of symbols) {
|
|
402
|
+
const chars = [...entry.used].map(([code, cp]) => `<${hex(code, 2)}> <${utf16be(cp)}>`)
|
|
403
|
+
const lines = [
|
|
404
|
+
'/CIDInit /ProcSet findresource begin', '12 dict begin', 'begincmap',
|
|
405
|
+
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
|
|
406
|
+
'/CMapName /Adobe-Identity-UCS def', '/CMapType 2 def',
|
|
407
|
+
'1 begincodespacerange', '<00> <FF>', 'endcodespacerange',
|
|
408
|
+
]
|
|
409
|
+
for (let i = 0; i < chars.length; i += 100) {
|
|
410
|
+
const chunk = chars.slice(i, i + 100)
|
|
411
|
+
lines.push(`${chunk.length} beginbfchar`, ...chunk, 'endbfchar')
|
|
412
|
+
}
|
|
413
|
+
lines.push('endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end')
|
|
414
|
+
const toUnicode = doc.addStream('', lines.join('\n'))
|
|
415
|
+
doc.fill(entry.id, `<< /Type /Font /Subtype /Type1 /BaseFont /${base} ` +
|
|
416
|
+
`/ToUnicode ${toUnicode} 0 R >>`)
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
/** The document-wide Type0 font, registered on first use. */
|
|
420
|
+
useUnicode (font) { unicode = font },
|
|
421
|
+
/** An ExtGState, content-addressed: `<< ... >>` body without the delimiters. */
|
|
422
|
+
gs: (body) => intern(gstates, 'G', body, () => doc.add(`<< ${body} >>`)),
|
|
423
|
+
shading: (body) => intern(shadings, 'S', body, () => doc.add(`<< ${body} >>`)),
|
|
424
|
+
/** A form or image XObject whose object id already exists. */
|
|
425
|
+
xobject: (id, prefix = 'X') => intern(xobjects, prefix, `${prefix}:${id}`, () => id),
|
|
426
|
+
body () {
|
|
427
|
+
const parts = []
|
|
428
|
+
const fontRefs = [...fonts.values()].map((f) => `/${f.name} ${f.id} 0 R`)
|
|
429
|
+
for (const entry of symbols.values()) fontRefs.push(`/${entry.name} ${entry.id} 0 R`)
|
|
430
|
+
if (unicode && unicode.id !== null) fontRefs.push(`/${unicode.name} ${unicode.id} 0 R`)
|
|
431
|
+
if (fontRefs.length) parts.push(`/Font << ${fontRefs.join(' ')} >>`)
|
|
432
|
+
for (const [label, map] of [['ExtGState', gstates], ['Shading', shadings], ['XObject', xobjects]]) {
|
|
433
|
+
if (!map.size) continue
|
|
434
|
+
parts.push(`/${label} << ${[...map.values()].map((e) => `/${e.name} ${e.id} 0 R`).join(' ')} >>`)
|
|
435
|
+
}
|
|
436
|
+
// ProcSet is obsolete but Acrobat's preflight still asks for it.
|
|
437
|
+
parts.push('/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]')
|
|
438
|
+
return `<< ${parts.join(' ')} >>`
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The graphics-state name for a compositing triple, or null when the default
|
|
445
|
+
* state already says all of it.
|
|
446
|
+
*
|
|
447
|
+
* @param {object} ctx
|
|
448
|
+
* @param {number} ca non-stroking alpha
|
|
449
|
+
* @param {number} CA stroking alpha
|
|
450
|
+
* @param {string} [blend]
|
|
451
|
+
* @param {string} [smask] `<< /S /Luminosity ... >>` body, already assembled
|
|
452
|
+
*/
|
|
453
|
+
function gsFor (ctx, ca, CA, blend, smask) {
|
|
454
|
+
const parts = []
|
|
455
|
+
if (ca < 1) parts.push(`/ca ${num(ca)}`)
|
|
456
|
+
if (CA < 1) parts.push(`/CA ${num(CA)}`)
|
|
457
|
+
if (blend && blend !== 'Normal') parts.push(`/BM /${blend}`)
|
|
458
|
+
if (smask) parts.push(`/SMask ${smask}`)
|
|
459
|
+
if (!parts.length) return null
|
|
460
|
+
return ctx.res.gs(`/Type /ExtGState ${parts.join(' ')}`)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** CSS blend name -> PDF `/BM`, with a note when PDF has no counterpart. */
|
|
464
|
+
function blendName (ctx, nodeId, css) {
|
|
465
|
+
if (typeof css !== 'string' || css === 'normal' || !css) return null
|
|
466
|
+
const name = BLEND_MODES[css]
|
|
467
|
+
if (!name) {
|
|
468
|
+
ctx.note(nodeId, `blend mode "${css}" has no PDF equivalent; it is composited as Normal.`,
|
|
469
|
+
'emit.pdf.blend-unsupported', 'O')
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
472
|
+
return name
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ——— gradients ———
|
|
476
|
+
|
|
477
|
+
function readPoint (v) {
|
|
478
|
+
if (Array.isArray(v) && isNum(Number(v[0])) && isNum(Number(v[1]))) return [Number(v[0]), Number(v[1])]
|
|
479
|
+
if (isObject(v) && Number.isFinite(Number(v.x)) && Number.isFinite(Number(v.y))) return [Number(v.x), Number(v.y)]
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* `unit.radius` is `[rx, ry]` OR a bare number, both by contract: the collector
|
|
485
|
+
* normalizes anisotropy into `transform`, so the isotropic scalar is the honest
|
|
486
|
+
* shape and `[r, r]` is what it means. Not a divergence — see CONTRACT.md.
|
|
487
|
+
*/
|
|
488
|
+
function readRadius (v) {
|
|
489
|
+
if (Array.isArray(v)) {
|
|
490
|
+
const rx = Number(v[0])
|
|
491
|
+
const ry = Number(v[1])
|
|
492
|
+
return isNum(rx) && isNum(ry) ? { r: [rx, ry], scalar: false } : null
|
|
493
|
+
}
|
|
494
|
+
return isNum(Number(v)) && v !== null && v !== '' ? { r: [Number(v), Number(v)], scalar: true } : null
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* `stops` as a monotonic 0..1 list. Null when the paint carries nothing
|
|
499
|
+
* paintable, which the caller turns into a note plus a dropped layer.
|
|
500
|
+
*/
|
|
501
|
+
function readStops (paint, ctx, nodeId) {
|
|
502
|
+
if (!Array.isArray(paint.stops)) return null
|
|
503
|
+
const out = []
|
|
504
|
+
let prev = 0
|
|
505
|
+
let unplaced = 0
|
|
506
|
+
for (const stop of paint.stops) {
|
|
507
|
+
if (!isObject(stop) || !Array.isArray(stop.color)) continue
|
|
508
|
+
// CONTRACT.md fixes the field as `t`. Documents in `out/vector` currently
|
|
509
|
+
// carry BOTH `t` and a duplicate `p`; `t` is the contract and the one read.
|
|
510
|
+
const t = Number(stop.t)
|
|
511
|
+
if (!Number.isFinite(t)) unplaced++
|
|
512
|
+
const p = Math.min(1, Math.max(prev, Number.isFinite(t) ? t : prev))
|
|
513
|
+
prev = p
|
|
514
|
+
out.push({ p, color: stop.color })
|
|
515
|
+
}
|
|
516
|
+
if (unplaced && ctx) {
|
|
517
|
+
ctx.note(nodeId, `${unplaced} of ${out.length} gradient stops carry no numeric \`t\`; they collapse onto ` +
|
|
518
|
+
'the previous stop, so the ramp is flatter than the source.', 'emit.pdf.gradient-stop-unplaced', 'O')
|
|
519
|
+
}
|
|
520
|
+
return out.length >= 2 ? out : null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Linear sRGB interpolation between stops — the same one every editor does. */
|
|
524
|
+
function sampleStops (stops, p) {
|
|
525
|
+
if (p <= stops[0].p) return stops[0].color
|
|
526
|
+
const last = stops[stops.length - 1]
|
|
527
|
+
if (p >= last.p) return last.color
|
|
528
|
+
for (let i = 1; i < stops.length; i++) {
|
|
529
|
+
const b = stops[i]
|
|
530
|
+
if (p > b.p) continue
|
|
531
|
+
const a = stops[i - 1]
|
|
532
|
+
const span = b.p - a.p
|
|
533
|
+
const t = span > 0 ? (p - a.p) / span : 0
|
|
534
|
+
return [0, 1, 2, 3].map((k) => finite(a.color[k], k === 3 ? 1 : 0) +
|
|
535
|
+
(finite(b.color[k], k === 3 ? 1 : 0) - finite(a.color[k], k === 3 ? 1 : 0)) * t)
|
|
536
|
+
}
|
|
537
|
+
return last.color
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Stops padded to span exactly 0..1 with strictly increasing positions, which is
|
|
542
|
+
* what a Type 3 stitching function's `/Bounds` requires. A hard stop (two stops
|
|
543
|
+
* at the same `t`) keeps a 1e-6 gap: a step that steep is a step.
|
|
544
|
+
*/
|
|
545
|
+
function functionStops (stops) {
|
|
546
|
+
const out = stops.map((s) => ({ p: Math.min(1, Math.max(0, s.p)), color: s.color }))
|
|
547
|
+
if (out[0].p > 0) out.unshift({ p: 0, color: out[0].color })
|
|
548
|
+
if (out[out.length - 1].p < 1) out.push({ p: 1, color: out[out.length - 1].color })
|
|
549
|
+
const packed = [out[0]]
|
|
550
|
+
for (let i = 1; i < out.length; i++) {
|
|
551
|
+
const prev = packed[packed.length - 1]
|
|
552
|
+
packed.push({ p: Math.max(out[i].p, prev.p + 1e-6), color: out[i].color })
|
|
553
|
+
}
|
|
554
|
+
// The padding above can push the last stop past 1; renormalize onto [0, 1].
|
|
555
|
+
const span = packed[packed.length - 1].p
|
|
556
|
+
if (span > 1) for (const s of packed) s.p /= span
|
|
557
|
+
packed[0].p = 0
|
|
558
|
+
packed[packed.length - 1].p = 1
|
|
559
|
+
return packed
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* A colour ramp as a PDF function: one Type 2 exponential per segment, stitched
|
|
564
|
+
* by a Type 3 when there is more than one.
|
|
565
|
+
*
|
|
566
|
+
* @param {(rgba: number[]) => string} out `colorArray` for colour, a gray
|
|
567
|
+
* literal for the alpha plane of a soft mask
|
|
568
|
+
* @param {number} n components of the output space
|
|
569
|
+
*/
|
|
570
|
+
function rampFunction (pdf, stops, out, n) {
|
|
571
|
+
const packed = functionStops(stops)
|
|
572
|
+
const segment = (a, b) => pdf.add(
|
|
573
|
+
`<< /FunctionType 2 /Domain [0 1] /C0 ${out(a.color)} /C1 ${out(b.color)} /N 1 >>`
|
|
574
|
+
)
|
|
575
|
+
if (packed.length === 2) return segment(packed[0], packed[1])
|
|
576
|
+
const ids = []
|
|
577
|
+
const bounds = []
|
|
578
|
+
for (let i = 1; i < packed.length; i++) {
|
|
579
|
+
ids.push(segment(packed[i - 1], packed[i]))
|
|
580
|
+
if (i < packed.length - 1) bounds.push(num(packed[i].p))
|
|
581
|
+
}
|
|
582
|
+
return pdf.add(
|
|
583
|
+
`<< /FunctionType 3 /Domain [0 1] /Functions [${ids.map((id) => `${id} 0 R`).join(' ')}] ` +
|
|
584
|
+
`/Bounds [${bounds.join(' ')}] /Encode [${ids.map(() => '0 1').join(' ')}] ` +
|
|
585
|
+
`/Range [${new Array(n).fill('0 1').join(' ')}] >>`
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const grayArray = (rgba) => `[${num(alphaOf(rgba))}]`
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Shading geometry for a linear or radial paint, in the gradient's own unit
|
|
593
|
+
* space, plus whatever has to be folded into the matrix to get there.
|
|
594
|
+
*
|
|
595
|
+
* @returns {{coords: string, type: number, matrix: number[]}|null}
|
|
596
|
+
*/
|
|
597
|
+
function shadingGeometry (paint, ctx, nodeId, matrix) {
|
|
598
|
+
const unit = isObject(paint.unit) ? paint.unit : {}
|
|
599
|
+
if (paint.type === 'linear') {
|
|
600
|
+
const from = readPoint(unit.p0)
|
|
601
|
+
const to = readPoint(unit.p1)
|
|
602
|
+
if (!from || !to) {
|
|
603
|
+
ctx.note(nodeId, 'a linear gradient carries no unit.p0/unit.p1; the default top-to-bottom axis is used ' +
|
|
604
|
+
'and the gradient direction is lost.', 'emit.pdf.gradient-no-axis', 'A')
|
|
605
|
+
}
|
|
606
|
+
const [x1, y1] = from || [0, 0]
|
|
607
|
+
const [x2, y2] = to || [0, 1]
|
|
608
|
+
if (Math.abs(x2 - x1) < 1e-9 && Math.abs(y2 - y1) < 1e-9) {
|
|
609
|
+
ctx.note(nodeId, 'a linear gradient has a zero-length axis; it is painted as the flat colour of its last stop.',
|
|
610
|
+
'emit.pdf.gradient-zero-axis', 'A')
|
|
611
|
+
return null
|
|
612
|
+
}
|
|
613
|
+
return { type: 2, coords: `[${num(x1)} ${num(y1)} ${num(x2)} ${num(y2)}]`, matrix }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const center = readPoint(unit.center)
|
|
617
|
+
if (!center) {
|
|
618
|
+
ctx.note(nodeId, 'a radial gradient carries no unit.center; the unit square centre is used.',
|
|
619
|
+
'emit.pdf.gradient-no-center', 'A')
|
|
620
|
+
}
|
|
621
|
+
const [cx, cy] = center || [0.5, 0.5]
|
|
622
|
+
const radius = readRadius(unit.radius)
|
|
623
|
+
if (!radius) {
|
|
624
|
+
ctx.note(nodeId, 'a radial gradient carries no unit.radius; 0.5 (the inscribed circle) is used.',
|
|
625
|
+
'emit.pdf.gradient-no-radius', 'A')
|
|
626
|
+
}
|
|
627
|
+
const [rx, ry] = radius ? radius.r : [0.5, 0.5]
|
|
628
|
+
if (!(rx > 0) || !(ry > 0)) {
|
|
629
|
+
ctx.note(nodeId, 'a radial gradient has a zero radius; it is painted as the flat colour of its last stop.',
|
|
630
|
+
'emit.pdf.gradient-zero-radius', 'A')
|
|
631
|
+
return null
|
|
632
|
+
}
|
|
633
|
+
// A Type 3 shading has one radius per circle. An anisotropic unit radius is
|
|
634
|
+
// still exact: scaling y about the centre inside the matrix is that ellipse.
|
|
635
|
+
let m = matrix
|
|
636
|
+
if (Math.abs(ry - rx) > 1e-9) {
|
|
637
|
+
const k = ry / rx
|
|
638
|
+
m = multiply(m, [1, 0, 0, k, 0, cy - k * cy])
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
type: 3,
|
|
642
|
+
coords: `[${num(cx)} ${num(cy)} 0 ${num(cx)} ${num(cy)} ${num(rx)}]`,
|
|
643
|
+
matrix: m,
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Paint a linear or radial gradient into the current clip.
|
|
649
|
+
*
|
|
650
|
+
* The soft mask is the whole reason this is not four lines: a PDF shading has a
|
|
651
|
+
* colour function and nothing else, so a ramp whose stops differ in alpha needs a
|
|
652
|
+
* second, DeviceGray shading of the SAME geometry, rendered into a luminosity
|
|
653
|
+
* transparency group and hung off `/SMask`. `/BC [0]` makes everything outside
|
|
654
|
+
* the group's BBox fully transparent, which is why that box is 100 unit-squares
|
|
655
|
+
* wide — the clip, not the BBox, is what bounds the paint.
|
|
656
|
+
*
|
|
657
|
+
* @returns {boolean} false when nothing could be painted
|
|
658
|
+
*/
|
|
659
|
+
function paintGradient (out, ctx, nodeId, paint, matrix, blend, rect) {
|
|
660
|
+
const stops = readStops(paint, ctx, nodeId)
|
|
661
|
+
if (!stops) {
|
|
662
|
+
ctx.note(nodeId, `a ${paint.type} gradient has fewer than 2 usable stops; the layer is dropped.`,
|
|
663
|
+
'emit.pdf.gradient-no-stops', 'O')
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
const geometry = shadingGeometry(paint, ctx, nodeId, matrix)
|
|
667
|
+
if (!geometry) {
|
|
668
|
+
const color = stops[stops.length - 1].color
|
|
669
|
+
const alpha = alphaOf(color)
|
|
670
|
+
const gs = gsFor(ctx, alpha, alpha, blend)
|
|
671
|
+
if (gs) out.push(`/${gs} gs`)
|
|
672
|
+
out.push(fillColorOp(color))
|
|
673
|
+
out.push(rectPath(rect.x, rect.y, rect.w, rect.h))
|
|
674
|
+
out.push('f')
|
|
675
|
+
return true
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const alphas = stops.map((s) => alphaOf(s.color))
|
|
679
|
+
const flat = alphas.every((a) => Math.abs(a - alphas[0]) < 1e-6)
|
|
680
|
+
let smask = null
|
|
681
|
+
let ca = 1
|
|
682
|
+
if (flat) {
|
|
683
|
+
ca = alphas[0]
|
|
684
|
+
} else {
|
|
685
|
+
const alphaFn = rampFunction(ctx.pdf, stops, grayArray, 1)
|
|
686
|
+
const alphaName = ctx.res.shading(`/ShadingType ${geometry.type} /ColorSpace /DeviceGray ` +
|
|
687
|
+
`/Coords ${geometry.coords} /Function ${alphaFn} 0 R /Extend [true true]`)
|
|
688
|
+
const formId = ctx.pdf.reserve()
|
|
689
|
+
ctx.forms.push({
|
|
690
|
+
id: formId,
|
|
691
|
+
body: `/${alphaName} sh`,
|
|
692
|
+
// The BBox is 100 unit squares wide because /BC [0] makes everything
|
|
693
|
+
// outside it fully transparent, and the CLIP — not this box — is what
|
|
694
|
+
// bounds the paint.
|
|
695
|
+
dict: '/Type /XObject /Subtype /Form /FormType 1 /BBox [-100 -100 101 101] ' +
|
|
696
|
+
`/Matrix [${geometry.matrix.map(nm).join(' ')}] ` +
|
|
697
|
+
'/Group << /Type /Group /S /Transparency /CS /DeviceGray /I true >> ' +
|
|
698
|
+
`/Resources ${ctx.resourcesId} 0 R`,
|
|
699
|
+
})
|
|
700
|
+
smask = `<< /Type /Mask /S /Luminosity /G ${formId} 0 R /BC [0] >>`
|
|
701
|
+
ctx.note(nodeId, `a ${paint.type} gradient has per-stop alpha, which a PDF shading cannot carry; the ramp's ` +
|
|
702
|
+
'alpha is emitted as a luminosity soft mask over the same geometry (exact, not an approximation).',
|
|
703
|
+
'emit.pdf.gradient-soft-mask', 'E')
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const colorFn = rampFunction(ctx.pdf, stops, colorArray, 3)
|
|
707
|
+
const shBody = `/ShadingType ${geometry.type} /ColorSpace /DeviceRGB ` +
|
|
708
|
+
`/Coords ${geometry.coords} /Function ${colorFn} 0 R /Extend [true true]`
|
|
709
|
+
const shName = ctx.res.shading(shBody)
|
|
710
|
+
const gs = gsFor(ctx, ca, ca, blend, smask)
|
|
711
|
+
if (gs) out.push(`/${gs} gs`)
|
|
712
|
+
out.push(matrixOp(geometry.matrix))
|
|
713
|
+
out.push(`/${shName} sh`)
|
|
714
|
+
return true
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* One sector of the fan, from `from` degrees to `to`, as a polygon out to
|
|
719
|
+
* `CONIC_REACH`. The boundary is sampled every `CONIC_STEP` degrees so the chord
|
|
720
|
+
* never cuts inside the clip: at reach 2 a 45° chord still stands 1.85 unit
|
|
721
|
+
* squares off the centre, and the farthest corner of a unit square is at 1.415.
|
|
722
|
+
*/
|
|
723
|
+
function sectorPath (cx, cy, from, to) {
|
|
724
|
+
const steps = Math.max(1, Math.ceil((to - from) / CONIC_STEP))
|
|
725
|
+
// CSS conic angles run clockwise from 12 o'clock; y grows down here, so up is -y.
|
|
726
|
+
const at = (deg) => {
|
|
727
|
+
const rad = (deg * Math.PI) / 180
|
|
728
|
+
return `${num(cx + CONIC_REACH * Math.sin(rad))} ${num(cy - CONIC_REACH * Math.cos(rad))}`
|
|
729
|
+
}
|
|
730
|
+
let path = `${num(cx)} ${num(cy)} m`
|
|
731
|
+
for (let s = 0; s <= steps; s++) path += ` ${at(from + ((to - from) * s) / steps)} l`
|
|
732
|
+
return `${path} h`
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The fan, back to front.
|
|
737
|
+
*
|
|
738
|
+
* Two rules, and both of them are the difference between a conic and a pinwheel:
|
|
739
|
+
*
|
|
740
|
+
* - The first band is the whole DISC, not a sector. Sectors that merely touch
|
|
741
|
+
* are antialiased independently and let the backdrop through the seam; 128 of
|
|
742
|
+
* those drew 128 white spokes on a page rendered at 1x. With an opaque disc
|
|
743
|
+
* underneath, no seam can ever reach the backdrop, at any radius.
|
|
744
|
+
* - Every other band is painted TWO bands wide, so the sector after it buries
|
|
745
|
+
* its trailing edge instead of butting against it. Two bands and no more:
|
|
746
|
+
* when all 128 ended on the same ray, that one ray stacked 128 antialiased
|
|
747
|
+
* edges and the accumulated tail dragged colour from a third of the sweep
|
|
748
|
+
* away into it — a visible hairline at the start angle.
|
|
749
|
+
*
|
|
750
|
+
* @param {(rgba: number[]) => string} colorOp `rg` for the paint, `g` for its mask
|
|
751
|
+
*/
|
|
752
|
+
function conicFan (cx, cy, start, stops, colorOp, apex) {
|
|
753
|
+
const ops = []
|
|
754
|
+
const mean = [0, 0, 0, 0]
|
|
755
|
+
for (let i = 0; i < CONIC_WEDGES; i++) {
|
|
756
|
+
const sample = sampleStops(stops, (i + 0.5) / CONIC_WEDGES)
|
|
757
|
+
const alpha = alphaOf(sample)
|
|
758
|
+
for (let k = 0; k < 3; k++) mean[k] += channel(sample, k) * alpha
|
|
759
|
+
mean[3] += alpha
|
|
760
|
+
ops.push(colorOp(sample))
|
|
761
|
+
ops.push(i === 0 ? circlePath(cx, cy, CONIC_REACH)
|
|
762
|
+
: sectorPath(cx, cy, start + (i / CONIC_WEDGES) * 360,
|
|
763
|
+
start + Math.min(1, (i + 2) / CONIC_WEDGES) * 360))
|
|
764
|
+
ops.push('f')
|
|
765
|
+
}
|
|
766
|
+
if (apex > 0 && mean[3] > 1e-6) {
|
|
767
|
+
ops.push(colorOp([mean[0] / mean[3], mean[1] / mean[3], mean[2] / mean[3], mean[3] / CONIC_WEDGES]))
|
|
768
|
+
ops.push(circlePath(cx, cy, apex))
|
|
769
|
+
ops.push('f')
|
|
770
|
+
}
|
|
771
|
+
return ops
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* A conic gradient as a fan of flat sectors — PDF has no angular shading and a
|
|
776
|
+
* radial is not an approximation of a pinwheel, it is a different picture. The
|
|
777
|
+
* fan is exact geometry apart from the quantized sweep.
|
|
778
|
+
*/
|
|
779
|
+
function paintConic (out, ctx, nodeId, paint, matrix, blend) {
|
|
780
|
+
const stops = readStops(paint, ctx, nodeId)
|
|
781
|
+
if (!stops) {
|
|
782
|
+
ctx.note(nodeId, 'an angular gradient has fewer than 2 usable stops; the layer is dropped.',
|
|
783
|
+
'emit.pdf.gradient-no-stops', 'O')
|
|
784
|
+
return false
|
|
785
|
+
}
|
|
786
|
+
const unit = isObject(paint.unit) ? paint.unit : {}
|
|
787
|
+
const center = readPoint(unit.center)
|
|
788
|
+
if (!center) {
|
|
789
|
+
ctx.note(nodeId, 'an angular gradient carries no unit.center; the unit square centre is used.',
|
|
790
|
+
'emit.pdf.gradient-no-center', 'A')
|
|
791
|
+
}
|
|
792
|
+
const hasStart = isNum(Number(unit.startAngle)) && unit.startAngle !== null && unit.startAngle !== ''
|
|
793
|
+
if (!hasStart) {
|
|
794
|
+
ctx.note(nodeId, 'an angular gradient carries no unit.startAngle; the sweep starts at 12 o\'clock and its ' +
|
|
795
|
+
'rotation is lost.', 'emit.pdf.gradient-no-start-angle', 'A')
|
|
796
|
+
}
|
|
797
|
+
ctx.note(nodeId, `conic-gradient has no PDF shading type; it is composed as ${CONIC_WEDGES} flat sectors ` +
|
|
798
|
+
`(the sweep is quantized to ${num(360 / CONIC_WEDGES)} degrees, the colour within each band is its ` +
|
|
799
|
+
'mid-angle sample).', 'emit.pdf.conic-fan', 'A')
|
|
800
|
+
|
|
801
|
+
const start = hasStart ? Number(unit.startAngle) : 0
|
|
802
|
+
const [cx, cy] = center || [0.5, 0.5]
|
|
803
|
+
// The apex is asked for in painted px and drawn in unit space.
|
|
804
|
+
const scale = (Math.hypot(matrix[0], matrix[1]) + Math.hypot(matrix[2], matrix[3])) / 2
|
|
805
|
+
const apex = scale > 1e-6 ? CONIC_APEX_PX / scale : 0
|
|
806
|
+
if (apex > 0) {
|
|
807
|
+
ctx.note(nodeId, `the ${CONIC_WEDGES} sectors cannot resolve the point they all meet at, so a ` +
|
|
808
|
+
`${num(CONIC_APEX_PX * 2)}px disc of the sweep's mean colour caps the apex; within that disc the angular ` +
|
|
809
|
+
'ramp is flat.', 'emit.pdf.conic-apex', 'A')
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const alphas = stops.map((s) => alphaOf(s.color))
|
|
813
|
+
const flat = alphas.every((a) => Math.abs(a - alphas[0]) < 1e-6)
|
|
814
|
+
let smask = null
|
|
815
|
+
let ca = 1
|
|
816
|
+
if (flat) {
|
|
817
|
+
ca = alphas[0]
|
|
818
|
+
} else {
|
|
819
|
+
// Same construction the axial and radial ramps use: the alpha becomes a
|
|
820
|
+
// DeviceGray fan of the SAME geometry inside a luminosity group. It has to
|
|
821
|
+
// be a mask rather than per-sector `ca` because the sectors OVERLAP — a
|
|
822
|
+
// translucent one painted over its neighbour would darken the overlap.
|
|
823
|
+
const formId = ctx.pdf.reserve()
|
|
824
|
+
ctx.forms.push({
|
|
825
|
+
id: formId,
|
|
826
|
+
body: conicFan(cx, cy, start, stops, (rgba) => `${num(alphaOf(rgba))} g`, apex).join('\n'),
|
|
827
|
+
dict: '/Type /XObject /Subtype /Form /FormType 1 /BBox [-100 -100 101 101] ' +
|
|
828
|
+
`/Matrix [${matrix.map(nm).join(' ')}] ` +
|
|
829
|
+
'/Group << /Type /Group /S /Transparency /CS /DeviceGray /I true >> ' +
|
|
830
|
+
`/Resources ${ctx.resourcesId} 0 R`,
|
|
831
|
+
})
|
|
832
|
+
smask = `<< /Type /Mask /S /Luminosity /G ${formId} 0 R /BC [0] >>`
|
|
833
|
+
ctx.note(nodeId, 'an angular gradient has per-stop alpha; the fan is painted opaque and its alpha is ' +
|
|
834
|
+
'emitted as a luminosity soft mask over the same sectors, so the overlap that removes the seams ' +
|
|
835
|
+
'cannot accumulate.', 'emit.pdf.gradient-soft-mask', 'E')
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const fan = conicFan(cx, cy, start, stops, fillColorOp, apex)
|
|
839
|
+
const gs = gsFor(ctx, ca, ca, blend, smask)
|
|
840
|
+
if (!gs) {
|
|
841
|
+
out.push(matrixOp(matrix))
|
|
842
|
+
for (const op of fan) out.push(op)
|
|
843
|
+
return true
|
|
844
|
+
}
|
|
845
|
+
// Group alpha or a blend mode applies to the fan AS A WHOLE. Composited sector
|
|
846
|
+
// by sector, every overlap would be composited twice.
|
|
847
|
+
const formId = ctx.pdf.reserve()
|
|
848
|
+
ctx.forms.push({
|
|
849
|
+
id: formId,
|
|
850
|
+
body: fan.join('\n'),
|
|
851
|
+
dict: '/Type /XObject /Subtype /Form /FormType 1 /BBox [-100 -100 101 101] ' +
|
|
852
|
+
`/Matrix [${matrix.map(nm).join(' ')}] ` +
|
|
853
|
+
'/Group << /Type /Group /S /Transparency /CS /DeviceRGB /I true >> ' +
|
|
854
|
+
`/Resources ${ctx.resourcesId} 0 R`,
|
|
855
|
+
})
|
|
856
|
+
out.push(`/${gs} gs`)
|
|
857
|
+
out.push(`/${ctx.res.xobject(formId)} Do`)
|
|
858
|
+
return true
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ——— raster assets ———
|
|
862
|
+
|
|
863
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
864
|
+
|
|
865
|
+
/** Base64 without `atob`, so the module carries its own decoder everywhere. */
|
|
866
|
+
function fromBase64 (text) {
|
|
867
|
+
const clean = String(text).replace(/[^A-Za-z0-9+/]/g, '')
|
|
868
|
+
const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
|
|
869
|
+
let acc = 0
|
|
870
|
+
let bits = 0
|
|
871
|
+
let at = 0
|
|
872
|
+
for (let i = 0; i < clean.length; i++) {
|
|
873
|
+
acc = (acc << 6) | B64.indexOf(clean[i])
|
|
874
|
+
bits += 6
|
|
875
|
+
if (bits >= 8) {
|
|
876
|
+
bits -= 8
|
|
877
|
+
bytes[at++] = (acc >> bits) & 0xff
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return bytes.subarray(0, at)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Width, height and component count from a JPEG's own SOF marker.
|
|
885
|
+
*
|
|
886
|
+
* Trusting `asset.w`/`asset.h` and assuming DeviceRGB would put a grayscale or
|
|
887
|
+
* CMYK JPEG into a stream that says it has three components, which is a corrupt
|
|
888
|
+
* file rather than a wrong-looking one.
|
|
889
|
+
*/
|
|
890
|
+
function jpegInfo (bytes) {
|
|
891
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null
|
|
892
|
+
let i = 2
|
|
893
|
+
while (i + 9 < bytes.length) {
|
|
894
|
+
if (bytes[i] !== 0xff) { i++; continue }
|
|
895
|
+
const marker = bytes[i + 1]
|
|
896
|
+
if (marker === 0xff) { i++; continue }
|
|
897
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { i += 2; continue }
|
|
898
|
+
const len = (bytes[i + 2] << 8) | bytes[i + 3]
|
|
899
|
+
const isSOF = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
|
|
900
|
+
if (isSOF) {
|
|
901
|
+
return { h: (bytes[i + 5] << 8) | bytes[i + 6], w: (bytes[i + 7] << 8) | bytes[i + 8], comps: bytes[i + 9] }
|
|
902
|
+
}
|
|
903
|
+
if (marker === 0xda) break
|
|
904
|
+
if (len < 2) break
|
|
905
|
+
i += 2 + len
|
|
906
|
+
}
|
|
907
|
+
return null
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const JPEG_SPACES = { 1: 'DeviceGray', 3: 'DeviceRGB', 4: 'DeviceCMYK' }
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* An image XObject for an SVD asset.
|
|
914
|
+
*
|
|
915
|
+
* A JPEG goes in verbatim: `/DCTDecode` is the same codec, so the bytes are
|
|
916
|
+
* copied and nothing is re-encoded. Anything else needs a decoder, and the only
|
|
917
|
+
* one in reach is a canvas — so in a DOM the asset is drawn and handed to
|
|
918
|
+
* `encodeImage` from the shared writer, and in Node it degrades.
|
|
919
|
+
*
|
|
920
|
+
* @returns {Promise<{name: string, w: number, h: number}|null>}
|
|
921
|
+
*/
|
|
922
|
+
async function imageXObject (ctx, nodeId, key, asset) {
|
|
923
|
+
if (ctx.images.has(key)) return ctx.images.get(key)
|
|
924
|
+
const href = (typeof asset.data === 'string' && asset.data) || (typeof asset.src === 'string' && asset.src) || null
|
|
925
|
+
let result = null
|
|
926
|
+
|
|
927
|
+
const data = /^data:([^;,]*)(;base64)?,/i.exec(href || '')
|
|
928
|
+
if (data && /jpe?g/i.test(data[1]) && data[2]) {
|
|
929
|
+
const bytes = fromBase64(href.slice(data[0].length))
|
|
930
|
+
const info = jpegInfo(bytes)
|
|
931
|
+
if (info && info.w > 0 && info.h > 0) {
|
|
932
|
+
const space = JPEG_SPACES[info.comps]
|
|
933
|
+
if (!space) {
|
|
934
|
+
ctx.note(nodeId, `asset "${key}" is a JPEG with ${info.comps} components, which has no PDF colour ` +
|
|
935
|
+
'space; the layer is dropped.', 'emit.pdf.image-jpeg-components', 'O')
|
|
936
|
+
} else {
|
|
937
|
+
const id = ctx.pdf.addStream(
|
|
938
|
+
`/Type /XObject /Subtype /Image /Width ${info.w} /Height ${info.h} /BitsPerComponent 8 ` +
|
|
939
|
+
`/ColorSpace /${space} /Filter /DCTDecode`, bytes)
|
|
940
|
+
result = { name: ctx.res.xobject(id, 'Im'), w: info.w, h: info.h }
|
|
941
|
+
}
|
|
942
|
+
} else {
|
|
943
|
+
ctx.note(nodeId, `asset "${key}" claims to be a JPEG but carries no readable SOF marker; the layer is dropped.`,
|
|
944
|
+
'emit.pdf.image-jpeg-unreadable', 'O')
|
|
945
|
+
}
|
|
946
|
+
} else if (href && typeof document !== 'undefined' && typeof createImageBitmap === 'function') {
|
|
947
|
+
// Everything that is not already a JPEG has to be decoded to be re-encoded,
|
|
948
|
+
// and a canvas is the only decoder here. This is the one path in the file
|
|
949
|
+
// that touches a DOM, and it exists so a browser export is not worse than a
|
|
950
|
+
// Node one.
|
|
951
|
+
try {
|
|
952
|
+
const blob = await (await fetch(href)).blob()
|
|
953
|
+
const bitmap = await createImageBitmap(blob)
|
|
954
|
+
const canvas = typeof OffscreenCanvas === 'function'
|
|
955
|
+
? new OffscreenCanvas(bitmap.width, bitmap.height)
|
|
956
|
+
: Object.assign(document.createElement('canvas'), { width: bitmap.width, height: bitmap.height })
|
|
957
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0)
|
|
958
|
+
const encoded = await encodeImage(canvas, { quality: ctx.quality })
|
|
959
|
+
const smaskRef = encoded.smask
|
|
960
|
+
? ` /SMask ${ctx.pdf.addStream(encoded.smask.dict, encoded.smask.bytes)} 0 R`
|
|
961
|
+
: ''
|
|
962
|
+
const id = ctx.pdf.addStream(encoded.dict + smaskRef, encoded.bytes)
|
|
963
|
+
result = { name: ctx.res.xobject(id, 'Im'), w: bitmap.width, h: bitmap.height }
|
|
964
|
+
ctx.note(nodeId, `asset "${key}" is not a JPEG, so it was decoded through a canvas and re-encoded as one ` +
|
|
965
|
+
`at quality ${ctx.quality}; the pixels are resampled, not copied.`, 'emit.pdf.image-recoded', 'R')
|
|
966
|
+
} catch (err) {
|
|
967
|
+
ctx.note(nodeId, `asset "${key}" could not be decoded (${err && err.message}); the layer is dropped.`,
|
|
968
|
+
'emit.pdf.image-decode-failed', 'O')
|
|
969
|
+
}
|
|
970
|
+
} else if (href) {
|
|
971
|
+
ctx.note(nodeId, `asset "${key}" is not a base64 JPEG and there is no canvas in this runtime to decode it; ` +
|
|
972
|
+
'the layer is dropped. PDF carries JPEG and raw samples only — a PNG has to be decoded by somebody.',
|
|
973
|
+
'emit.pdf.image-needs-decoder', 'O')
|
|
974
|
+
} else {
|
|
975
|
+
ctx.note(nodeId, `image fill references asset "${key}", which carries no data or src; the layer is dropped.`,
|
|
976
|
+
'emit.pdf.image-no-data', 'O')
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
ctx.images.set(key, result)
|
|
980
|
+
return result
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ——— fills ———
|
|
984
|
+
|
|
985
|
+
/** Where one background layer paints, in node-local px. */
|
|
986
|
+
function layerRect (fill, box) {
|
|
987
|
+
if (isObject(fill.rect)) {
|
|
988
|
+
const r = rectOf(fill.rect)
|
|
989
|
+
if (r.w > 0 && r.h > 0) return r
|
|
990
|
+
}
|
|
991
|
+
if (isObject(fill.placement) && isObject(fill.placement.area)) {
|
|
992
|
+
const r = rectOf(fill.placement.area)
|
|
993
|
+
if (r.w > 0 && r.h > 0) return r
|
|
994
|
+
}
|
|
995
|
+
return box
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Tile origins for a repeating layer, in node-local px. PDF has tiling patterns,
|
|
1000
|
+
* but a pattern cannot carry the soft mask a translucent gradient needs and would
|
|
1001
|
+
* fork every layer type in two; repeating the draw is the same picture.
|
|
1002
|
+
*
|
|
1003
|
+
* @returns {{offsets: number[][], capped: boolean}}
|
|
1004
|
+
*/
|
|
1005
|
+
function layerTiles (fill, rect, area) {
|
|
1006
|
+
const repeat = isObject(fill.placement) && isObject(fill.placement.repeat) ? fill.placement.repeat : null
|
|
1007
|
+
if (!repeat || !(rect.w > 0) || !(rect.h > 0)) return { offsets: [[0, 0]], capped: false }
|
|
1008
|
+
const repX = repeat.x === 'repeat' && rect.w < area.w - 1e-6
|
|
1009
|
+
const repY = repeat.y === 'repeat' && rect.h < area.h - 1e-6
|
|
1010
|
+
if (!repX && !repY) return { offsets: [[0, 0]], capped: false }
|
|
1011
|
+
const before = (grow, start, edge, size) => (grow ? Math.ceil((start - edge) / size) : 0)
|
|
1012
|
+
const x0 = -before(repX, rect.x, area.x, rect.w)
|
|
1013
|
+
const y0 = -before(repY, rect.y, area.y, rect.h)
|
|
1014
|
+
const nx = repX ? Math.ceil((area.x + area.w - (rect.x + x0 * rect.w)) / rect.w) : 1
|
|
1015
|
+
const ny = repY ? Math.ceil((area.y + area.h - (rect.y + y0 * rect.h)) / rect.h) : 1
|
|
1016
|
+
const offsets = []
|
|
1017
|
+
let capped = false
|
|
1018
|
+
for (let iy = 0; iy < Math.max(1, ny); iy++) {
|
|
1019
|
+
for (let ix = 0; ix < Math.max(1, nx); ix++) {
|
|
1020
|
+
if (offsets.length >= 4096) { capped = true; break }
|
|
1021
|
+
offsets.push([(x0 + ix) * rect.w, (y0 + iy) * rect.h])
|
|
1022
|
+
}
|
|
1023
|
+
if (capped) break
|
|
1024
|
+
}
|
|
1025
|
+
return { offsets: offsets.length ? offsets : [[0, 0]], capped }
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** One fill layer. Emitted in `fills` order, which is back to front. */
|
|
1029
|
+
async function emitLayer (out, ctx, nodeId, fill, box, radii) {
|
|
1030
|
+
const opacity = isNum(fill.opacity) ? clamp01(fill.opacity) : 1
|
|
1031
|
+
if (opacity <= 0) {
|
|
1032
|
+
ctx.note(nodeId, 'a fill layer has opacity 0; it is not emitted.', 'emit.pdf.layer-invisible', 'E')
|
|
1033
|
+
return
|
|
1034
|
+
}
|
|
1035
|
+
const blend = blendName(ctx, nodeId, fill.blend)
|
|
1036
|
+
const rect = layerRect(fill, box)
|
|
1037
|
+
|
|
1038
|
+
if (fill.clipBox && fill.clipBox !== 'border' && fill.type !== 'image') {
|
|
1039
|
+
ctx.note(nodeId, `a fill declares clipBox:"${fill.clipBox}", but the node carries no padding/content inset; ` +
|
|
1040
|
+
'the layer is clipped to the border box.', 'emit.pdf.clipbox-inset-unknown', 'A')
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (fill.type === 'solid') {
|
|
1044
|
+
const alpha = alphaOf(fill.color) * opacity
|
|
1045
|
+
if (alpha <= 0) return
|
|
1046
|
+
out.push('q')
|
|
1047
|
+
const gs = gsFor(ctx, alpha, alpha, blend)
|
|
1048
|
+
if (gs) out.push(`/${gs} gs`)
|
|
1049
|
+
out.push(fillColorOp(fill.color))
|
|
1050
|
+
out.push(shapePath(box, radii))
|
|
1051
|
+
out.push('f')
|
|
1052
|
+
out.push('Q')
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (fill.type === 'image') {
|
|
1057
|
+
const key = typeof fill.asset === 'string' ? fill.asset : null
|
|
1058
|
+
const asset = key && isObject(ctx.doc.assets) ? ctx.doc.assets[key] : null
|
|
1059
|
+
if (!isObject(asset)) {
|
|
1060
|
+
ctx.note(nodeId, `image fill references asset "${String(fill.asset)}", which is not in the document; ` +
|
|
1061
|
+
'the layer is dropped.', 'emit.pdf.image-missing', 'O')
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
const image = await imageXObject(ctx, nodeId, key, asset)
|
|
1065
|
+
if (!image) return
|
|
1066
|
+
const placement = readMatrix(fill.transform)
|
|
1067
|
+
out.push('q')
|
|
1068
|
+
out.push(shapePath(box, radii))
|
|
1069
|
+
out.push('W n')
|
|
1070
|
+
const gs = gsFor(ctx, opacity, opacity, blend)
|
|
1071
|
+
if (gs) out.push(`/${gs} gs`)
|
|
1072
|
+
if (placement && isObject(fill.crop)) {
|
|
1073
|
+
const crop = rectOf(fill.crop)
|
|
1074
|
+
if (crop.w > 0 && crop.h > 0) {
|
|
1075
|
+
// `collectImage` resolved object-fit/object-position as: `crop` is the
|
|
1076
|
+
// source region in the asset's own pixels, `transform` maps the unit
|
|
1077
|
+
// square onto where that region paints. So the unit square is the crop:
|
|
1078
|
+
// scale the whole image up by natural/crop and slide the crop origin to 0.
|
|
1079
|
+
out.push(matrixOp(placement))
|
|
1080
|
+
out.push(rectPath(0, 0, 1, 1))
|
|
1081
|
+
out.push('W n')
|
|
1082
|
+
out.push(matrixOp([image.w / crop.w, 0, 0, -image.h / crop.h,
|
|
1083
|
+
-crop.x / crop.w, 1 + crop.y / crop.h]))
|
|
1084
|
+
out.push(`/${image.name} Do`)
|
|
1085
|
+
out.push('Q')
|
|
1086
|
+
return
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (placement || isObject(fill.crop)) {
|
|
1090
|
+
ctx.note(nodeId, 'image fill carries half an object-fit placement (crop without matrix or the other way ' +
|
|
1091
|
+
'round); it is stretched over its box and the fit is lost.', 'emit.pdf.image-fit-partial', 'A')
|
|
1092
|
+
}
|
|
1093
|
+
if (typeof fill.fit === 'string' && fill.fit !== 'stretch' && fill.fit !== 'fill') {
|
|
1094
|
+
ctx.note(nodeId, `image fill asks for fit "${fill.fit}" but carries no crop matrix; it is stretched to ` +
|
|
1095
|
+
'its box.', 'emit.pdf.image-fit-unknown', 'A')
|
|
1096
|
+
}
|
|
1097
|
+
const { offsets, capped } = layerTiles(fill, rect, box)
|
|
1098
|
+
if (capped) {
|
|
1099
|
+
ctx.note(nodeId, 'a repeating image background needs more than 4096 tiles to cover its area; the rest are ' +
|
|
1100
|
+
'not painted.', 'emit.pdf.tile-cap', 'O')
|
|
1101
|
+
}
|
|
1102
|
+
for (const [dx, dy] of offsets) {
|
|
1103
|
+
out.push('q')
|
|
1104
|
+
// The flip: an image XObject's unit square has y UP from its own origin,
|
|
1105
|
+
// and this whole page is drawn y-down.
|
|
1106
|
+
out.push(matrixOp([rect.w, 0, 0, -rect.h, rect.x + dx, rect.y + dy + rect.h]))
|
|
1107
|
+
out.push(`/${image.name} Do`)
|
|
1108
|
+
out.push('Q')
|
|
1109
|
+
}
|
|
1110
|
+
out.push('Q')
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (fill.type === 'linear' || fill.type === 'radial' || fill.type === 'angular') {
|
|
1115
|
+
const raw = readMatrix(fill.transform)
|
|
1116
|
+
if (!raw) {
|
|
1117
|
+
ctx.note(nodeId, `a ${fill.type} gradient has no usable unit->box matrix; the identity is used instead.`,
|
|
1118
|
+
'emit.pdf.gradient-no-matrix', 'A')
|
|
1119
|
+
}
|
|
1120
|
+
const base = raw || [1, 0, 0, 1, 0, 0]
|
|
1121
|
+
const { offsets, capped } = layerTiles(fill, rect, box)
|
|
1122
|
+
if (capped) {
|
|
1123
|
+
ctx.note(nodeId, 'a repeating gradient background needs more than 4096 tiles to cover its area; the rest ' +
|
|
1124
|
+
'are not painted.', 'emit.pdf.tile-cap', 'O')
|
|
1125
|
+
}
|
|
1126
|
+
for (const [dx, dy] of offsets) {
|
|
1127
|
+
const matrix = [base[0], base[1], base[2], base[3], base[4] + rect.x + dx, base[5] + rect.y + dy]
|
|
1128
|
+
out.push('q')
|
|
1129
|
+
out.push(shapePath(box, radii))
|
|
1130
|
+
out.push('W n')
|
|
1131
|
+
// A gradient never paints outside its own layer rect, and `sh` with
|
|
1132
|
+
// /Extend would happily flood the whole clip.
|
|
1133
|
+
if (offsets.length > 1 || rect.w < box.w - 1e-6 || rect.h < box.h - 1e-6 ||
|
|
1134
|
+
rect.x > box.x + 1e-6 || rect.y > box.y + 1e-6) {
|
|
1135
|
+
out.push(rectPath(rect.x + dx, rect.y + dy, rect.w, rect.h))
|
|
1136
|
+
out.push('W n')
|
|
1137
|
+
}
|
|
1138
|
+
if (opacity < 1) {
|
|
1139
|
+
const gs = gsFor(ctx, opacity, opacity)
|
|
1140
|
+
if (gs) out.push(`/${gs} gs`)
|
|
1141
|
+
}
|
|
1142
|
+
if (fill.type === 'angular') paintConic(out, ctx, nodeId, fill, matrix, blend)
|
|
1143
|
+
else paintGradient(out, ctx, nodeId, fill, matrix, blend, { x: rect.x + dx, y: rect.y + dy, w: rect.w, h: rect.h })
|
|
1144
|
+
out.push('Q')
|
|
1145
|
+
}
|
|
1146
|
+
return
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
ctx.note(nodeId, `fill type "${String(fill.type)}" is not known to the PDF emitter; the layer is dropped.`,
|
|
1150
|
+
'emit.pdf.fill-type-unknown', 'O')
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ——— strokes ———
|
|
1154
|
+
|
|
1155
|
+
/** `stroke.dash` -> the `d` operator, or nothing. */
|
|
1156
|
+
function dashOp (stroke) {
|
|
1157
|
+
if (!Array.isArray(stroke.dash) || !stroke.dash.length) return null
|
|
1158
|
+
if (!stroke.dash.every((n) => Number.isFinite(Number(n)) && Number(n) >= 0)) return null
|
|
1159
|
+
if (stroke.dash.every((n) => Number(n) === 0)) return null
|
|
1160
|
+
return `[${stroke.dash.map((n) => num(n)).join(' ')}] ${num(finite(stroke.dashOffset))} d`
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const CAP_CODES = { butt: 0, round: 1, square: 2 }
|
|
1164
|
+
const JOIN_CODES = { miter: 0, round: 1, bevel: 2 }
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Per-side weights (or per-side colours) as four mitered quads — the same
|
|
1168
|
+
* trapezoids the browser paints, and the only shape a single `w` cannot express.
|
|
1169
|
+
*/
|
|
1170
|
+
function emitPerSideStroke (out, ctx, nodeId, stroke, weights, box, radii) {
|
|
1171
|
+
const [t, r, b, l] = weights
|
|
1172
|
+
const rounded = hasRadius(radii)
|
|
1173
|
+
if (rounded) {
|
|
1174
|
+
ctx.note(nodeId, 'per-side border weights on a rounded box: the sides are emitted as square-mitered quads ' +
|
|
1175
|
+
'clipped to the border radius, so the corner arcs thin out where the miter is cut away.',
|
|
1176
|
+
'emit.pdf.stroke-per-side-miter', 'A')
|
|
1177
|
+
}
|
|
1178
|
+
if (Array.isArray(stroke.dash) && stroke.dash.length) {
|
|
1179
|
+
ctx.note(nodeId, 'a dashed border with per-side weights is emitted as four solid quads; the dash pattern is lost.',
|
|
1180
|
+
'emit.pdf.stroke-dash-lost', 'O')
|
|
1181
|
+
}
|
|
1182
|
+
if (isObject(stroke.paint) && stroke.paint.type !== 'solid') {
|
|
1183
|
+
ctx.note(nodeId, `a ${stroke.paint.type} border with per-side weights is emitted as four flat quads.`,
|
|
1184
|
+
'emit.pdf.stroke-per-side-flat', 'A')
|
|
1185
|
+
}
|
|
1186
|
+
const { w, h } = box
|
|
1187
|
+
const x0 = box.x
|
|
1188
|
+
const y0 = box.y
|
|
1189
|
+
const quad = (pts) => pts.map(([px, py], i) => `${num(px)} ${num(py)} ${i ? 'l' : 'm'}`).join(' ') + ' h'
|
|
1190
|
+
const sides = [
|
|
1191
|
+
[t, quad([[x0, y0], [x0 + w, y0], [x0 + w - r, y0 + t], [x0 + l, y0 + t]])],
|
|
1192
|
+
[r, quad([[x0 + w, y0], [x0 + w, y0 + h], [x0 + w - r, y0 + h - b], [x0 + w - r, y0 + t]])],
|
|
1193
|
+
[b, quad([[x0 + w, y0 + h], [x0, y0 + h], [x0 + l, y0 + h - b], [x0 + w - r, y0 + h - b]])],
|
|
1194
|
+
[l, quad([[x0, y0 + h], [x0, y0], [x0 + l, y0 + t], [x0 + l, y0 + h - b]])],
|
|
1195
|
+
]
|
|
1196
|
+
const perSide = Array.isArray(stroke.sides) ? stroke.sides : null
|
|
1197
|
+
const fallback = isObject(stroke.paint) && stroke.paint.type === 'solid' ? stroke.paint.color : [0, 0, 0, 1]
|
|
1198
|
+
|
|
1199
|
+
out.push('q')
|
|
1200
|
+
// A border never paints outside the border box: without this clip a mitered
|
|
1201
|
+
// corner spills past a rounded silhouette.
|
|
1202
|
+
if (rounded) {
|
|
1203
|
+
out.push(shapePath(box, radii))
|
|
1204
|
+
out.push('W n')
|
|
1205
|
+
}
|
|
1206
|
+
sides.forEach(([weight, path], i) => {
|
|
1207
|
+
if (!(weight > 0)) return
|
|
1208
|
+
const own = perSide && isObject(perSide[i]) && Array.isArray(perSide[i].color) ? perSide[i].color : null
|
|
1209
|
+
const color = own || fallback
|
|
1210
|
+
const alpha = alphaOf(color)
|
|
1211
|
+
if (alpha <= 0) return
|
|
1212
|
+
out.push('q')
|
|
1213
|
+
const gs = gsFor(ctx, alpha, alpha)
|
|
1214
|
+
if (gs) out.push(`/${gs} gs`)
|
|
1215
|
+
out.push(fillColorOp(color))
|
|
1216
|
+
out.push(path)
|
|
1217
|
+
out.push('f')
|
|
1218
|
+
out.push('Q')
|
|
1219
|
+
})
|
|
1220
|
+
out.push('Q')
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function emitStrokes (out, ctx, nodeId, strokes, box, radii) {
|
|
1224
|
+
for (const stroke of strokes) {
|
|
1225
|
+
if (!isObject(stroke)) continue
|
|
1226
|
+
const raw = stroke.weight !== undefined ? stroke.weight : stroke.weights
|
|
1227
|
+
const weights = Array.isArray(raw)
|
|
1228
|
+
? [0, 1, 2, 3].map((i) => Math.max(0, finite(raw[i])))
|
|
1229
|
+
: [0, 1, 2, 3].map(() => Math.max(0, finite(raw)))
|
|
1230
|
+
if (!weights.some((v) => v > 0)) continue
|
|
1231
|
+
|
|
1232
|
+
if (stroke.sibling && stroke.order === 'last') {
|
|
1233
|
+
ctx.note(nodeId, 'an outline declares sibling:true / order:"last" — it should paint after this node\'s ' +
|
|
1234
|
+
'siblings, not with it. It is painted with the node, so an overlapping later sibling covers it.',
|
|
1235
|
+
'emit.pdf.stroke-sibling-order', 'A')
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const base = normalizeRadii(Array.isArray(stroke.radii) ? stroke.radii : radii, box.w, box.h)
|
|
1239
|
+
const uniform = weights.every((v) => v === weights[0])
|
|
1240
|
+
const visible = Array.isArray(stroke.sides)
|
|
1241
|
+
? stroke.sides.filter((s, i) => isObject(s) && weights[i] > 0 && Array.isArray(s.color))
|
|
1242
|
+
: []
|
|
1243
|
+
const sameColor = visible.every((s) => s.color.join() === visible[0].color.join())
|
|
1244
|
+
if (!uniform || !sameColor) {
|
|
1245
|
+
emitPerSideStroke(out, ctx, nodeId, stroke, weights, box, base)
|
|
1246
|
+
continue
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const weight = weights[0]
|
|
1250
|
+
const align = typeof stroke.align === 'string' ? stroke.align : 'inside'
|
|
1251
|
+
const offset = finite(stroke.offset)
|
|
1252
|
+
// PDF strokes are centred on the path and there is no alignment operator, so
|
|
1253
|
+
// alignment is the path moving: half the weight inwards for `inside`,
|
|
1254
|
+
// outwards for `outside`.
|
|
1255
|
+
const inset = align === 'inside' ? weight / 2 : align === 'outside' ? -(offset + weight / 2) : 0
|
|
1256
|
+
const shape = { x: box.x + inset, y: box.y + inset, w: box.w - 2 * inset, h: box.h - 2 * inset }
|
|
1257
|
+
const paint = isObject(stroke.paint) ? stroke.paint : null
|
|
1258
|
+
|
|
1259
|
+
if (shape.w <= 0 || shape.h <= 0) {
|
|
1260
|
+
ctx.note(nodeId, `a ${num(weight)}px ${align} stroke is wider than its box; it is emitted as a filled ` +
|
|
1261
|
+
'shape instead.', 'emit.pdf.stroke-overflows-box', 'A')
|
|
1262
|
+
const color = paint && paint.type === 'solid' ? paint.color : [0, 0, 0, 1]
|
|
1263
|
+
const alpha = alphaOf(color)
|
|
1264
|
+
out.push('q')
|
|
1265
|
+
const gs = gsFor(ctx, alpha, alpha)
|
|
1266
|
+
if (gs) out.push(`/${gs} gs`)
|
|
1267
|
+
out.push(fillColorOp(color))
|
|
1268
|
+
out.push(shapePath(box, base))
|
|
1269
|
+
out.push('f')
|
|
1270
|
+
out.push('Q')
|
|
1271
|
+
continue
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// When the stroke brought its own radii those are already offset (an
|
|
1275
|
+
// outline's radii are expanded by outline-offset upstream), so only the
|
|
1276
|
+
// trace itself is left to grow by.
|
|
1277
|
+
const radialInset = Array.isArray(stroke.radii)
|
|
1278
|
+
? (align === 'inside' ? weight / 2 : align === 'outside' ? -(weight / 2) : 0)
|
|
1279
|
+
: inset
|
|
1280
|
+
const shapeRadii = normalizeRadii(offsetRadii(base, radialInset), shape.w, shape.h)
|
|
1281
|
+
|
|
1282
|
+
if (!paint) continue
|
|
1283
|
+
if (paint.type === 'linear' || paint.type === 'radial' || paint.type === 'angular') {
|
|
1284
|
+
// A stroke is a region like any other: clip to it and flood it with the
|
|
1285
|
+
// shading. `W n` after `S`-less path construction needs the stroke turned
|
|
1286
|
+
// into a fillable region, which PDF cannot do — so the honest version is a
|
|
1287
|
+
// flat sample, declared.
|
|
1288
|
+
ctx.note(nodeId, `a ${paint.type} gradient stroke has no PDF form (there is no "clip to the stroke of this ` +
|
|
1289
|
+
'path" operator); the stroke is painted as the flat colour of the gradient\'s mid stop.',
|
|
1290
|
+
'emit.pdf.stroke-gradient-flat', 'O')
|
|
1291
|
+
const stops = readStops(paint, ctx, nodeId)
|
|
1292
|
+
const color = stops ? sampleStops(stops, 0.5) : [0, 0, 0, 1]
|
|
1293
|
+
strokeOnce(out, ctx, stroke, shape, shapeRadii, weight, color)
|
|
1294
|
+
continue
|
|
1295
|
+
}
|
|
1296
|
+
if (paint.type !== 'solid') {
|
|
1297
|
+
ctx.note(nodeId, `a ${String(paint.type)} stroke paint has no PDF equivalent; the stroke is dropped.`,
|
|
1298
|
+
'emit.pdf.stroke-paint-unsupported', 'O')
|
|
1299
|
+
continue
|
|
1300
|
+
}
|
|
1301
|
+
strokeOnce(out, ctx, stroke, shape, shapeRadii, weight, paint.color)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function strokeOnce (out, ctx, stroke, shape, radii, weight, color) {
|
|
1306
|
+
const alpha = alphaOf(color)
|
|
1307
|
+
if (alpha <= 0) return
|
|
1308
|
+
out.push('q')
|
|
1309
|
+
const gs = gsFor(ctx, alpha, alpha)
|
|
1310
|
+
if (gs) out.push(`/${gs} gs`)
|
|
1311
|
+
out.push(strokeColorOp(color))
|
|
1312
|
+
out.push(`${num(weight)} w`)
|
|
1313
|
+
const cap = CAP_CODES[stroke.cap]
|
|
1314
|
+
if (cap) out.push(`${cap} J`)
|
|
1315
|
+
const join = JOIN_CODES[stroke.join]
|
|
1316
|
+
if (join) out.push(`${join} j`)
|
|
1317
|
+
if (Number.isFinite(Number(stroke.miter))) out.push(`${num(stroke.miter)} M`)
|
|
1318
|
+
const dash = dashOp(stroke)
|
|
1319
|
+
if (dash) out.push(dash)
|
|
1320
|
+
out.push(shapePath(shape, radii))
|
|
1321
|
+
out.push('S')
|
|
1322
|
+
out.push('Q')
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// ——— effects ———
|
|
1326
|
+
|
|
1327
|
+
/** Invariant 5, in one place: the CSS blur radius is twice the Gaussian sigma. */
|
|
1328
|
+
const sigma = (blur) => Math.max(0, finite(blur) / 2)
|
|
1329
|
+
|
|
1330
|
+
/** Abramowitz & Stegun 7.1.26 — 1.5e-7 absolute, which is far past visible. */
|
|
1331
|
+
function erf (x) {
|
|
1332
|
+
const sign = x < 0 ? -1 : 1
|
|
1333
|
+
const t = 1 / (1 + 0.3275911 * Math.abs(x))
|
|
1334
|
+
const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
|
|
1335
|
+
t * Math.exp(-x * x)
|
|
1336
|
+
return sign * y
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const gaussCdf = (x) => 0.5 * (1 + erf(x / Math.SQRT2))
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* A blurred shadow edge as concentric copies of the path.
|
|
1343
|
+
*
|
|
1344
|
+
* PDF has no blur. Of the two honest answers — rasterize the effect, or compose
|
|
1345
|
+
* it from geometry — this file takes the second, so the page stays resolution
|
|
1346
|
+
* independent and a reader can still select the text under it. A Gaussian-blurred
|
|
1347
|
+
* straight edge has opacity `Φ(d/σ)` at signed depth `d`, so copy `i` is grown to
|
|
1348
|
+
* where that profile reads `T_i`, and its own alpha is whatever brings the
|
|
1349
|
+
* accumulated coverage there from `T_{i-1}` to `T_i`. Curvature is the error: on
|
|
1350
|
+
* a corner tighter than σ the true blur is a little softer than this stack.
|
|
1351
|
+
*
|
|
1352
|
+
* @returns {{grow: number, alpha: number}[]} outermost first
|
|
1353
|
+
*/
|
|
1354
|
+
function shadowRamp (blur, spread, alpha, layers) {
|
|
1355
|
+
const s = sigma(blur)
|
|
1356
|
+
if (!(s > 0) || layers < 2) return [{ grow: spread, alpha }]
|
|
1357
|
+
const out = []
|
|
1358
|
+
let acc = 0
|
|
1359
|
+
for (let i = 0; i < layers; i++) {
|
|
1360
|
+
const grow = spread + SHADOW_REACH * s * (1 - (2 * i) / (layers - 1))
|
|
1361
|
+
const target = alpha * (1 - gaussCdf((grow - spread) / s))
|
|
1362
|
+
const step = acc >= 1 ? 0 : (target - acc) / (1 - acc)
|
|
1363
|
+
acc = acc + step * (1 - acc)
|
|
1364
|
+
if (step > 1e-4) out.push({ grow, alpha: Math.min(1, step) })
|
|
1365
|
+
}
|
|
1366
|
+
return out
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** How far a shadow reaches outside its box, in px. */
|
|
1370
|
+
function shadowBleed (effects) {
|
|
1371
|
+
let bleed = 0
|
|
1372
|
+
for (const e of effects) {
|
|
1373
|
+
const offset = Array.isArray(e.offset) ? e.offset : [0, 0]
|
|
1374
|
+
const reach = SHADOW_REACH * sigma(e.blur) + Math.abs(finite(e.spread))
|
|
1375
|
+
bleed = Math.max(bleed, Math.abs(finite(offset[0])) + reach, Math.abs(finite(offset[1])) + reach)
|
|
1376
|
+
}
|
|
1377
|
+
return Math.ceil(bleed) + 2
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Outer box-shadows. CSS paints them behind the box and clips them OUT of the
|
|
1382
|
+
* border box, which here is an even-odd clip of a big rectangle against the box
|
|
1383
|
+
* path — the only reason a translucent card does not show its own shadow through
|
|
1384
|
+
* its own background.
|
|
1385
|
+
*/
|
|
1386
|
+
function emitDropShadows (out, ctx, nodeId, effects, box, radii) {
|
|
1387
|
+
for (const e of effects) {
|
|
1388
|
+
const alpha = alphaOf(e.color)
|
|
1389
|
+
if (alpha <= 0) continue
|
|
1390
|
+
const offset = Array.isArray(e.offset) ? e.offset : [0, 0]
|
|
1391
|
+
const dx = finite(offset[0])
|
|
1392
|
+
const dy = finite(offset[1])
|
|
1393
|
+
const spread = finite(e.spread)
|
|
1394
|
+
const ramp = shadowRamp(e.blur, spread, alpha, ctx.shadowLayers)
|
|
1395
|
+
if (finite(e.blur) > 0) {
|
|
1396
|
+
ctx.note(nodeId, `a ${num(e.blur)}px drop shadow is composed as ${ramp.length} concentric copies of the ` +
|
|
1397
|
+
'box with a Gaussian opacity ramp — PDF has no blur, and this file does not rasterize to fake one. ' +
|
|
1398
|
+
'Corners tighter than the blur radius come out slightly harder than the browser paints them.',
|
|
1399
|
+
'emit.pdf.shadow-layered', 'A')
|
|
1400
|
+
}
|
|
1401
|
+
const bleed = shadowBleed([e])
|
|
1402
|
+
out.push('q')
|
|
1403
|
+
if (!e.behind) {
|
|
1404
|
+
out.push(rectPath(box.x - bleed - 1, box.y - bleed - 1, box.w + 2 * bleed + 2, box.h + 2 * bleed + 2))
|
|
1405
|
+
out.push(shapePath(box, radii))
|
|
1406
|
+
out.push('W* n')
|
|
1407
|
+
}
|
|
1408
|
+
out.push(fillColorOp(e.color))
|
|
1409
|
+
for (const layer of ramp) {
|
|
1410
|
+
const grown = grownBox({ x: box.x + dx, y: box.y + dy, w: box.w, h: box.h }, radii, layer.grow)
|
|
1411
|
+
if (grown.box.w <= 0 || grown.box.h <= 0) continue
|
|
1412
|
+
const gs = gsFor(ctx, layer.alpha, layer.alpha)
|
|
1413
|
+
out.push(gs ? `/${gs} gs` : `/${ctx.res.gs('/Type /ExtGState /ca 1 /CA 1')} gs`)
|
|
1414
|
+
out.push(shapePath(grown.box, normalizeRadii(grown.radii, grown.box.w, grown.box.h)))
|
|
1415
|
+
out.push('f')
|
|
1416
|
+
}
|
|
1417
|
+
out.push('Q')
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Inner shadows. The shape is the complement of the offset, shrunk box, clipped
|
|
1423
|
+
* to the box — which is exactly the crescent CSS paints, and the same concentric
|
|
1424
|
+
* ramp softens its edge.
|
|
1425
|
+
*/
|
|
1426
|
+
function emitInnerShadows (out, ctx, nodeId, effects, box, radii) {
|
|
1427
|
+
for (const e of effects) {
|
|
1428
|
+
const alpha = alphaOf(e.color)
|
|
1429
|
+
if (alpha <= 0) continue
|
|
1430
|
+
const offset = Array.isArray(e.offset) ? e.offset : [0, 0]
|
|
1431
|
+
const dx = finite(offset[0])
|
|
1432
|
+
const dy = finite(offset[1])
|
|
1433
|
+
const spread = finite(e.spread)
|
|
1434
|
+
const ramp = shadowRamp(e.blur, spread, alpha, ctx.shadowLayers)
|
|
1435
|
+
if (finite(e.blur) > 0) {
|
|
1436
|
+
ctx.note(nodeId, `a ${num(e.blur)}px inner shadow is composed as ${ramp.length} concentric copies of the ` +
|
|
1437
|
+
'box with a Gaussian opacity ramp; PDF has no blur.', 'emit.pdf.shadow-layered', 'A')
|
|
1438
|
+
}
|
|
1439
|
+
ctx.note(nodeId, 'an inner shadow is clipped to the border box; CSS clips it to the padding box, and the ' +
|
|
1440
|
+
'node carries no padding inset.', 'emit.pdf.inner-shadow-box', 'A')
|
|
1441
|
+
const bleed = shadowBleed([e]) + Math.max(box.w, box.h)
|
|
1442
|
+
out.push('q')
|
|
1443
|
+
out.push(shapePath(box, radii))
|
|
1444
|
+
out.push('W n')
|
|
1445
|
+
out.push(fillColorOp(e.color))
|
|
1446
|
+
for (const layer of ramp) {
|
|
1447
|
+
const hole = grownBox({ x: box.x + dx, y: box.y + dy, w: box.w, h: box.h }, radii, -layer.grow)
|
|
1448
|
+
const gs = gsFor(ctx, layer.alpha, layer.alpha)
|
|
1449
|
+
out.push(gs ? `/${gs} gs` : `/${ctx.res.gs('/Type /ExtGState /ca 1 /CA 1')} gs`)
|
|
1450
|
+
out.push(rectPath(box.x - bleed, box.y - bleed, box.w + 2 * bleed, box.h + 2 * bleed))
|
|
1451
|
+
if (hole.box.w > 0 && hole.box.h > 0) {
|
|
1452
|
+
out.push(shapePath(hole.box, normalizeRadii(hole.radii, hole.box.w, hole.box.h)))
|
|
1453
|
+
}
|
|
1454
|
+
out.push('f*')
|
|
1455
|
+
}
|
|
1456
|
+
out.push('Q')
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// ——— fonts ———
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Advance widths, 1/1000 em, for the base-14 faces this file can reach. They are
|
|
1464
|
+
* the AFM widths of the standard fonts, and they exist for exactly one number:
|
|
1465
|
+
* the natural width of a line, which divided into the browser's measured width
|
|
1466
|
+
* gives `Tz`. Without them a substituted face would set the line to its own
|
|
1467
|
+
* width and every measured line box in the document would be a lie.
|
|
1468
|
+
*
|
|
1469
|
+
* Codes 32..126, then the WinAnsi punctuation real copy is full of.
|
|
1470
|
+
*/
|
|
1471
|
+
const HELVETICA = [
|
|
1472
|
+
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
|
|
1473
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
|
|
1474
|
+
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
|
|
1475
|
+
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
|
|
1476
|
+
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
|
|
1477
|
+
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
|
|
1478
|
+
]
|
|
1479
|
+
|
|
1480
|
+
const HELVETICA_BOLD = [
|
|
1481
|
+
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
|
|
1482
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
|
|
1483
|
+
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
|
|
1484
|
+
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
|
|
1485
|
+
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
|
|
1486
|
+
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
|
|
1487
|
+
]
|
|
1488
|
+
|
|
1489
|
+
const TIMES = [
|
|
1490
|
+
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278,
|
|
1491
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444,
|
|
1492
|
+
921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722,
|
|
1493
|
+
556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500,
|
|
1494
|
+
333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500,
|
|
1495
|
+
500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541,
|
|
1496
|
+
]
|
|
1497
|
+
|
|
1498
|
+
const TIMES_BOLD = [
|
|
1499
|
+
250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278,
|
|
1500
|
+
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500,
|
|
1501
|
+
930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778,
|
|
1502
|
+
611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500,
|
|
1503
|
+
333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500,
|
|
1504
|
+
556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520,
|
|
1505
|
+
]
|
|
1506
|
+
|
|
1507
|
+
/** The punctuation above U+00FF that WinAnsi can still address. */
|
|
1508
|
+
const EXTRA_WIDTHS = {
|
|
1509
|
+
sans: { 8211: 556, 8212: 1000, 8216: 222, 8217: 222, 8220: 333, 8221: 333, 8226: 350, 8230: 1000, 8364: 556, 8482: 1000 },
|
|
1510
|
+
serif: { 8211: 500, 8212: 1000, 8216: 333, 8217: 333, 8220: 444, 8221: 444, 8226: 350, 8230: 1000, 8364: 500, 8482: 980 },
|
|
1511
|
+
mono: {},
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const WIDTH_TABLES = {
|
|
1515
|
+
sans: HELVETICA, 'sans-bold': HELVETICA_BOLD, 'sans-italic': HELVETICA, 'sans-bolditalic': HELVETICA_BOLD,
|
|
1516
|
+
serif: TIMES, 'serif-bold': TIMES_BOLD, 'serif-italic': TIMES, 'serif-bolditalic': TIMES_BOLD,
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* The two base-14 faces that are not Latin, as `codepoint:code:width` triples —
|
|
1521
|
+
* hex codepoint, byte in the font's BUILT-IN encoding, advance in em/1000. The
|
|
1522
|
+
* values are Adobe's own (the AFM tables pdf.js ships, `SymbolSetEncoding` and
|
|
1523
|
+
* `ZapfDingbatsEncoding` resolved through the Adobe glyph list), not a guess.
|
|
1524
|
+
*
|
|
1525
|
+
* This is what stands between `★★★★☆ 4,2` and a rating that renders as ` 4,2`.
|
|
1526
|
+
* Every viewer has these two fonts, so a star, an arrow, a check mark or a Greek
|
|
1527
|
+
* letter can be PAINTED instead of going out as invisible text. The glyph is
|
|
1528
|
+
* Adobe's drawing rather than the page's font — a substitution like the Helvetica
|
|
1529
|
+
* one, declared per family, and `/ToUnicode` keeps the character itself intact
|
|
1530
|
+
* for search and copy-paste.
|
|
1531
|
+
*
|
|
1532
|
+
* Codepoints below U+0100 are absent on purpose: WinAnsi already addresses them,
|
|
1533
|
+
* and Symbol's `!` is not the page's `!`.
|
|
1534
|
+
*/
|
|
1535
|
+
const SYMBOL_GLYPHS =
|
|
1536
|
+
'391:65:722 392:66:667 393:71:603 394:68:612 395:69:611 396:90:611 397:72:722 398:81:741 ' +
|
|
1537
|
+
'399:73:333 39a:75:722 39b:76:686 39c:77:889 39d:78:722 39e:88:645 39f:79:722 3a0:80:768 ' +
|
|
1538
|
+
'3a1:82:556 3a3:83:592 3a4:84:611 3a5:85:690 3a6:70:763 3a7:67:722 3a8:89:795 3a9:87:768 ' +
|
|
1539
|
+
'3b1:97:631 3b2:98:549 3b3:103:411 3b4:100:494 3b5:101:439 3b6:122:494 3b7:104:603 3b8:113:521 ' +
|
|
1540
|
+
'3b9:105:329 3ba:107:549 3bb:108:549 3bc:109:576 3bd:110:521 3be:120:493 3bf:111:549 3c0:112:549 ' +
|
|
1541
|
+
'3c1:114:549 3c2:86:439 3c3:115:603 3c4:116:439 3c5:117:576 3c6:102:521 3c7:99:549 3c8:121:686 ' +
|
|
1542
|
+
'3c9:119:686 3d1:74:631 3d2:161:620 3d5:106:603 3d6:118:713 2022:183:460 2026:188:1000 ' +
|
|
1543
|
+
'2032:162:247 2033:178:411 2044:164:167 20ac:160:750 2111:193:686 2118:195:987 211c:194:795 ' +
|
|
1544
|
+
'2126:87:768 2135:192:823 2190:172:987 2191:173:603 2192:174:987 2193:175:603 2194:171:1042 ' +
|
|
1545
|
+
'21b5:191:658 21d0:220:987 21d1:221:603 21d2:222:987 21d3:223:603 21d4:219:1042 2200:34:713 ' +
|
|
1546
|
+
'2202:182:494 2203:36:549 2205:198:823 2206:68:612 2207:209:713 2208:206:713 2209:207:713 ' +
|
|
1547
|
+
'220b:39:439 220f:213:823 2211:229:713 2212:45:549 2217:42:500 221a:214:549 221d:181:713 ' +
|
|
1548
|
+
'221e:165:713 2220:208:768 2227:217:603 2228:218:603 2229:199:768 222a:200:768 222b:242:274 ' +
|
|
1549
|
+
'2234:92:863 223c:126:549 2245:64:549 2248:187:549 2260:185:549 2261:186:549 2264:163:549 ' +
|
|
1550
|
+
'2265:179:549 2282:204:713 2283:201:713 2284:203:713 2286:205:713 2287:202:713 2295:197:768 ' +
|
|
1551
|
+
'2297:196:768 22a5:94:658 22c5:215:250 2320:243:686 2321:245:686 2329:225:329 232a:241:329 ' +
|
|
1552
|
+
'25ca:224:494 2660:170:753 2663:167:753 2665:169:753 2666:168:753'
|
|
1553
|
+
|
|
1554
|
+
const DINGBAT_GLYPHS =
|
|
1555
|
+
'2192:213:838 2194:214:1016 2195:215:458 2460:172:788 2461:173:788 2462:174:788 2463:175:788 ' +
|
|
1556
|
+
'2464:176:788 2465:177:788 2466:178:788 2467:179:788 2468:180:788 2469:181:788 25a0:110:761 ' +
|
|
1557
|
+
'25b2:115:892 25bc:116:892 25c6:117:788 25cf:108:791 25d7:119:438 2605:72:816 260e:37:719 ' +
|
|
1558
|
+
'261b:42:960 261e:43:939 2660:171:626 2663:168:776 2665:170:694 2666:169:595 2701:33:974 ' +
|
|
1559
|
+
'2702:34:961 2703:35:974 2704:36:980 2706:38:789 2707:39:790 2708:40:791 2709:41:690 270c:44:549 ' +
|
|
1560
|
+
'270d:45:855 270e:46:911 270f:47:933 2710:48:911 2711:49:945 2712:50:974 2713:51:755 2714:52:846 ' +
|
|
1561
|
+
'2715:53:762 2716:54:761 2717:55:571 2718:56:677 2719:57:763 271a:58:760 271b:59:759 271c:60:754 ' +
|
|
1562
|
+
'271d:61:494 271e:62:552 271f:63:537 2720:64:577 2721:65:692 2722:66:786 2723:67:788 2724:68:788 ' +
|
|
1563
|
+
'2725:69:790 2726:70:793 2727:71:794 2729:73:823 272a:74:789 272b:75:841 272c:76:823 272d:77:833 ' +
|
|
1564
|
+
'272e:78:816 272f:79:831 2730:80:923 2731:81:744 2732:82:723 2733:83:749 2734:84:790 2735:85:792 ' +
|
|
1565
|
+
'2736:86:695 2737:87:776 2738:88:768 2739:89:792 273a:90:759 273b:91:707 273c:92:708 273d:93:682 ' +
|
|
1566
|
+
'273e:94:701 273f:95:826 2740:96:815 2741:97:789 2742:98:789 2743:99:707 2744:100:687 ' +
|
|
1567
|
+
'2745:101:696 2746:102:689 2747:103:786 2748:104:787 2749:105:713 274a:106:791 274b:107:785 ' +
|
|
1568
|
+
'274d:109:873 274f:111:762 2750:112:762 2751:113:759 2752:114:759 2756:118:784 2758:120:138 ' +
|
|
1569
|
+
'2759:121:277 275a:122:415 275b:123:392 275c:124:392 275d:125:668 275e:126:668 2761:161:732 ' +
|
|
1570
|
+
'2762:162:544 2763:163:544 2764:164:910 2765:165:667 2766:166:760 2767:167:760 2768:128:390 ' +
|
|
1571
|
+
'2769:129:390 276a:130:317 276b:131:317 276c:132:276 276d:133:276 276e:134:509 276f:135:509 ' +
|
|
1572
|
+
'2770:136:410 2771:137:410 2772:138:234 2773:139:234 2774:140:334 2775:141:334 2776:182:788 ' +
|
|
1573
|
+
'2777:183:788 2778:184:788 2779:185:788 277a:186:788 277b:187:788 277c:188:788 277d:189:788 ' +
|
|
1574
|
+
'277e:190:788 277f:191:788 2780:192:788 2781:193:788 2782:194:788 2783:195:788 2784:196:788 ' +
|
|
1575
|
+
'2785:197:788 2786:198:788 2787:199:788 2788:200:788 2789:201:788 278a:202:788 278b:203:788 ' +
|
|
1576
|
+
'278c:204:788 278d:205:788 278e:206:788 278f:207:788 2790:208:788 2791:209:788 2792:210:788 ' +
|
|
1577
|
+
'2793:211:788 2794:212:894 2798:216:748 2799:217:924 279a:218:748 279b:219:918 279c:220:927 ' +
|
|
1578
|
+
'279d:221:928 279e:222:928 279f:223:834 27a0:224:873 27a1:225:828 27a2:226:924 27a3:227:924 ' +
|
|
1579
|
+
'27a4:228:917 27a5:229:930 27a6:230:931 27a7:231:463 27a8:232:883 27a9:233:836 27aa:234:836 ' +
|
|
1580
|
+
'27ab:235:867 27ac:236:867 27ad:237:696 27ae:238:696 27af:239:874 27b1:241:874 27b2:242:760 ' +
|
|
1581
|
+
'27b3:243:946 27b4:244:771 27b5:245:865 27b6:246:771 27b7:247:888 27b8:248:967 27b9:249:888 ' +
|
|
1582
|
+
'27ba:250:831 27bb:251:873 27bc:252:927 27bd:253:970 27be:254:918'
|
|
1583
|
+
|
|
1584
|
+
function readGlyphTable (packed, base) {
|
|
1585
|
+
const map = new Map()
|
|
1586
|
+
for (const entry of packed.split(' ')) {
|
|
1587
|
+
const [cp, code, width] = entry.split(':')
|
|
1588
|
+
map.set(parseInt(cp, 16), { base, code: Number(code), width: Number(width) })
|
|
1589
|
+
}
|
|
1590
|
+
return map
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const SYMBOL_MAP = readGlyphTable(SYMBOL_GLYPHS, 'Symbol')
|
|
1594
|
+
const DINGBAT_MAP = readGlyphTable(DINGBAT_GLYPHS, 'ZapfDingbats')
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* The base-14 glyph for a character WinAnsi cannot address, or null.
|
|
1598
|
+
*
|
|
1599
|
+
* Symbol wins a tie: its arrows and card suits are drawn as text, the Dingbats
|
|
1600
|
+
* ones as ornaments, and a page that writes `→` in a sentence means the former.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {string} ch one character (not a codepoint — surrogate pairs included)
|
|
1603
|
+
* @returns {{base: string, code: number, width: number}|null}
|
|
1604
|
+
*/
|
|
1605
|
+
function symbolGlyph (ch) {
|
|
1606
|
+
const cp = ch.codePointAt(0)
|
|
1607
|
+
return SYMBOL_MAP.get(cp) || DINGBAT_MAP.get(cp) || null
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const GENERIC_OF = (key) => key.split('-')[0]
|
|
1611
|
+
|
|
1612
|
+
/** Combining marks, so `Í` can borrow `I`'s advance — which is what AFM gives it. */
|
|
1613
|
+
const COMBINING = new RegExp('[\\u0300-\\u036f]', 'g')
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* The advance of one string in a base-14 face, in em/1000.
|
|
1617
|
+
*
|
|
1618
|
+
* The per-character order mirrors `chunkByEncodability` exactly, because these
|
|
1619
|
+
* two decide the same thing twice — where the glyph comes from — and a line whose
|
|
1620
|
+
* width is measured against one font and painted in another drifts.
|
|
1621
|
+
*
|
|
1622
|
+
* Anything no table can address falls back to the base letter of its
|
|
1623
|
+
* decomposition (an accented Latin letter has its base letter's advance in every
|
|
1624
|
+
* one of these fonts) and then to the face's average.
|
|
1625
|
+
*/
|
|
1626
|
+
function naturalWidth (key, text) {
|
|
1627
|
+
const table = WIDTH_TABLES[key]
|
|
1628
|
+
const generic = GENERIC_OF(key)
|
|
1629
|
+
let total = 0
|
|
1630
|
+
// Courier is 600 per glyph — but a star drawn from ZapfDingbats inside a
|
|
1631
|
+
// monospaced run still advances by the star's own width.
|
|
1632
|
+
if (generic === 'mono') {
|
|
1633
|
+
for (const ch of text) {
|
|
1634
|
+
const glyph = pdfString(ch) === null ? symbolGlyph(ch) : null
|
|
1635
|
+
total += glyph ? glyph.width : 600
|
|
1636
|
+
}
|
|
1637
|
+
return total
|
|
1638
|
+
}
|
|
1639
|
+
const extra = EXTRA_WIDTHS[generic] || {}
|
|
1640
|
+
for (const ch of text) {
|
|
1641
|
+
const cp = ch.codePointAt(0)
|
|
1642
|
+
if (cp >= 32 && cp <= 126) { total += table[cp - 32]; continue }
|
|
1643
|
+
if (extra[cp] !== undefined) { total += extra[cp]; continue }
|
|
1644
|
+
if (pdfString(ch) === null) {
|
|
1645
|
+
const glyph = symbolGlyph(ch)
|
|
1646
|
+
// Symbol and ZapfDingbats are drawn at the run's own size, so their advance
|
|
1647
|
+
// is theirs — not the Latin face's.
|
|
1648
|
+
if (glyph) { total += glyph.width; continue }
|
|
1649
|
+
}
|
|
1650
|
+
const base = ch.normalize('NFD').replace(COMBINING, '')
|
|
1651
|
+
const bp = base ? base.codePointAt(0) : 0
|
|
1652
|
+
total += (bp >= 32 && bp <= 126) ? table[bp - 32] : table[('n'.codePointAt(0)) - 32]
|
|
1653
|
+
}
|
|
1654
|
+
return total
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* The base-14 key for a text style. The SVD carries the family the page asked
|
|
1659
|
+
* for, never a binary, so this is a substitution every time and the document
|
|
1660
|
+
* says so once per family.
|
|
1661
|
+
*/
|
|
1662
|
+
function fontKeyFor (ctx, style) {
|
|
1663
|
+
const stack = `${style.family || ''} ${style.stack || ''}`.toLowerCase()
|
|
1664
|
+
let generic = 'sans'
|
|
1665
|
+
if (/mono|courier|consolas|menlo|sfmono|source code|fira code/.test(stack)) generic = 'mono'
|
|
1666
|
+
else if (/sans-serif|system-ui|-apple-system|helvetica|arial|segoe|roboto|inter|noto sans|verdana|tahoma/.test(stack)) generic = 'sans'
|
|
1667
|
+
else if (/serif|georgia|times|garamond|cambria|palatino|book antiqua/.test(stack)) generic = 'serif'
|
|
1668
|
+
const bold = finite(style.weight, 400) >= 600
|
|
1669
|
+
const italic = !!style.italic || style.fontStyle === 'italic' || style.fontStyle === 'oblique'
|
|
1670
|
+
const key = `${generic}${bold && italic ? '-bolditalic' : bold ? '-bold' : italic ? '-italic' : ''}`
|
|
1671
|
+
|
|
1672
|
+
const family = (typeof style.family === 'string' && style.family.trim()) ||
|
|
1673
|
+
(typeof style.stack === 'string' ? style.stack.split(',')[0].trim() : '') || 'sans-serif'
|
|
1674
|
+
if (!ctx.fontNotes.has(family)) {
|
|
1675
|
+
ctx.fontNotes.add(family)
|
|
1676
|
+
ctx.note(null, `text asks for "${family}"; the SVD carries no font binary, so it is drawn in ` +
|
|
1677
|
+
`${BASE14[key]} and each line is stretched with Tz to the width the browser measured. The glyph shapes ` +
|
|
1678
|
+
'are not the page\'s.', 'emit.pdf.font-substituted', 'A')
|
|
1679
|
+
}
|
|
1680
|
+
return key
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// ——— text ———
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Characters that have a faithful WinAnsi counterpart. Without this the U+2212
|
|
1687
|
+
* MINUS SIGN in `−62%` is unrepresentable, and the whole cell goes invisible for
|
|
1688
|
+
* the sake of one dash the base-14 fonts spell `-`.
|
|
1689
|
+
*
|
|
1690
|
+
* Only substitutions that are the same character in another codepoint, or the
|
|
1691
|
+
* space a zero-width/thin space already is. Nothing that changes what the text
|
|
1692
|
+
* says.
|
|
1693
|
+
*/
|
|
1694
|
+
const TRANSLITERATE = {
|
|
1695
|
+
0x2212: '-', 0x2010: '-', 0x2011: '-', 0x2043: '-',
|
|
1696
|
+
0x2044: '/', 0x2027: '·',
|
|
1697
|
+
0x2002: ' ', 0x2003: ' ', 0x2007: ' ', 0x2008: ' ', 0x2009: ' ', 0x200a: ' ', 0x202f: ' ',
|
|
1698
|
+
// U+2028 separates VISUAL LINES in `characters`. A run can span one; inside a
|
|
1699
|
+
// line it would tell a consumer to break where the measurement says it must not.
|
|
1700
|
+
0x2028: ' ', 0x2029: ' ',
|
|
1701
|
+
0x00ad: '', 0x200b: '', 0x200c: '', 0x200d: '', 0xfeff: '',
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/** Replace what WinAnsi can spell another way; leave the rest for the caller. */
|
|
1705
|
+
function transliterate (text, ctx, nodeId) {
|
|
1706
|
+
let out = ''
|
|
1707
|
+
let changed = null
|
|
1708
|
+
for (const ch of text) {
|
|
1709
|
+
const swap = TRANSLITERATE[ch.codePointAt(0)]
|
|
1710
|
+
if (swap === undefined) { out += ch; continue }
|
|
1711
|
+
changed = changed || ch
|
|
1712
|
+
out += swap
|
|
1713
|
+
}
|
|
1714
|
+
if (changed !== null) {
|
|
1715
|
+
ctx.note(nodeId, `text carries characters the base-14 fonts spell differently (first: U+${
|
|
1716
|
+
changed.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}); they are substituted with their ` +
|
|
1717
|
+
'WinAnsi equivalents so the line still reads.', 'emit.pdf.text-transliterated', 'A')
|
|
1718
|
+
}
|
|
1719
|
+
return out
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** Advance of one string in a base-14 face, in px, spacing included. */
|
|
1723
|
+
function advanceOf (key, text, size, ls, ws) {
|
|
1724
|
+
const spaces = (text.match(/ /g) || []).length
|
|
1725
|
+
return (naturalWidth(key, text) / 1000) * size + ls * [...text].length + ws * spaces
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* A run split into maximal stretches by where their glyphs come from: the Latin
|
|
1730
|
+
* base-14 face, Symbol, ZapfDingbats, or nowhere.
|
|
1731
|
+
*
|
|
1732
|
+
* Splitting matters: one U+2605 in `★★★★ 4.8` used to take the whole run into
|
|
1733
|
+
* the invisible Unicode font. Now the stars come out of ZapfDingbats and `4.8`
|
|
1734
|
+
* out of Helvetica, and only what neither font has stays invisible.
|
|
1735
|
+
*
|
|
1736
|
+
* @returns {{font: string, text: string}[]} `font` is the base-14 BaseFont name,
|
|
1737
|
+
* `''` when no base-14 face can draw it, `'*'` for the run's Latin face.
|
|
1738
|
+
*/
|
|
1739
|
+
function chunkByEncodability (text) {
|
|
1740
|
+
const chunks = []
|
|
1741
|
+
for (const ch of text) {
|
|
1742
|
+
const latin = pdfString(ch) !== null
|
|
1743
|
+
const glyph = latin ? null : symbolGlyph(ch)
|
|
1744
|
+
const font = latin ? '*' : glyph ? glyph.base : ''
|
|
1745
|
+
const last = chunks[chunks.length - 1]
|
|
1746
|
+
if (last && last.font === font) last.text += ch
|
|
1747
|
+
else chunks.push({ font, text: ch })
|
|
1748
|
+
}
|
|
1749
|
+
return chunks
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* `text-decoration` PROPAGATES rather than inherits, so a run can carry several.
|
|
1754
|
+
* PDF has no decoration at all — they are drawn as rectangles, all of them, which
|
|
1755
|
+
* is one thing this backend does better than the SVG one.
|
|
1756
|
+
*/
|
|
1757
|
+
/**
|
|
1758
|
+
* The cap height a synthesized small cap is drawn at, as a fraction of the em.
|
|
1759
|
+
*
|
|
1760
|
+
* A real small-caps face has designed glyphs; base-14 has none, so the only
|
|
1761
|
+
* honest approximation is the uppercase letter at roughly cap height. 0.8 is
|
|
1762
|
+
* where Chromium's own synthesis lands for the fonts these fixtures use, and it
|
|
1763
|
+
* is a ratio rather than a metric because the SVD carries no cap-height.
|
|
1764
|
+
*/
|
|
1765
|
+
const SMALL_CAPS_RATIO = 0.8
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* Split a run into stretches that are drawn full size and stretches that are
|
|
1769
|
+
* drawn uppercase at cap height, per `font-variant-caps`.
|
|
1770
|
+
*
|
|
1771
|
+
* `all-small-caps` / `all-petite-caps` shrink every letter; `small-caps` /
|
|
1772
|
+
* `petite-caps` shrink only the ones that were lowercase. Anything else is one
|
|
1773
|
+
* stretch at full size, which is the no-op every other value wants.
|
|
1774
|
+
*
|
|
1775
|
+
* @returns {{text: string, small: boolean}[]}
|
|
1776
|
+
*/
|
|
1777
|
+
function splitSmallCaps (text, variant) {
|
|
1778
|
+
const v = typeof variant === 'string' ? variant.toLowerCase() : ''
|
|
1779
|
+
const all = v.includes('all-small-caps') || v.includes('all-petite-caps')
|
|
1780
|
+
const some = !all && (v.includes('small-caps') || v.includes('petite-caps'))
|
|
1781
|
+
if (!all && !some) return [{ text, small: false }]
|
|
1782
|
+
const out = []
|
|
1783
|
+
for (const ch of text) {
|
|
1784
|
+
// "Lowercase" is `toUpperCase` changing it — which is also what makes ß, fi
|
|
1785
|
+
// and every non-cased script fall out on the correct side without a table.
|
|
1786
|
+
const small = all ? ch.toUpperCase() !== ch.toLowerCase() : ch.toUpperCase() !== ch
|
|
1787
|
+
const piece = small ? ch.toUpperCase() : ch
|
|
1788
|
+
if (out.length && out[out.length - 1].small === small) out[out.length - 1].text += piece
|
|
1789
|
+
else out.push({ text: piece, small })
|
|
1790
|
+
}
|
|
1791
|
+
return out.length ? out : [{ text, small: false }]
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function readDecorations (style) {
|
|
1795
|
+
const raw = Array.isArray(style.decorations) ? style.decorations
|
|
1796
|
+
: Array.isArray(style.decoration) ? style.decoration
|
|
1797
|
+
: style.decoration === null || style.decoration === undefined ? []
|
|
1798
|
+
: [style.decoration]
|
|
1799
|
+
return raw
|
|
1800
|
+
.map((d) => (typeof d === 'string' ? { line: d } : d))
|
|
1801
|
+
.filter((d) => isObject(d) && typeof d.line === 'string' && d.line && d.line !== 'none')
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* One `BT`/`ET` per block, one `Tj` per measured line.
|
|
1806
|
+
*
|
|
1807
|
+
* The SVD already broke the lines, so nothing here may let the PDF re-break them:
|
|
1808
|
+
* every line is placed absolutely with its own `Tm` at `line.baseline`, and the
|
|
1809
|
+
* only thing that touches its width is `Tz`, which stretches the substituted face
|
|
1810
|
+
* onto the width the browser measured. `Tc`/`Tw` carry letter and word spacing
|
|
1811
|
+
* before that scaling (PDF multiplies both by Th, so the ratio stays honest).
|
|
1812
|
+
*/
|
|
1813
|
+
function emitText (out, ctx, nodeId, node) {
|
|
1814
|
+
const t = node.text
|
|
1815
|
+
if (!isObject(t) || typeof t.characters !== 'string' || !Array.isArray(t.lines) || !t.lines.length) {
|
|
1816
|
+
ctx.note(nodeId, 'a text node carries no measured lines; nothing is emitted for it.',
|
|
1817
|
+
'emit.pdf.text-no-lines', 'O')
|
|
1818
|
+
return
|
|
1819
|
+
}
|
|
1820
|
+
if (t.direction === 'rtl') {
|
|
1821
|
+
ctx.note(nodeId, 'a right-to-left text block is emitted in logical order with no bidi reordering; the ' +
|
|
1822
|
+
'glyphs come out left-to-right.', 'emit.pdf.text-rtl', 'O')
|
|
1823
|
+
}
|
|
1824
|
+
const chars = t.characters
|
|
1825
|
+
const styles = isObject(ctx.doc.styles) && isObject(ctx.doc.styles.text) ? ctx.doc.styles.text : {}
|
|
1826
|
+
const runs = (Array.isArray(t.runs) ? t.runs : []).filter(isObject)
|
|
1827
|
+
if (!ctx.wantLinks && runs.some((r) => typeof r.href === 'string' && r.href)) {
|
|
1828
|
+
ctx.note(nodeId, 'text runs carry links and `links: false` was asked for; no link annotations are emitted.',
|
|
1829
|
+
'emit.pdf.text-links-dropped', 'O')
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const body = []
|
|
1833
|
+
const decorations = []
|
|
1834
|
+
// Text state is sticky inside BT/ET, so every operator is only re-emitted when
|
|
1835
|
+
// it actually changes — a 43-node table is otherwise mostly Tf and Tz.
|
|
1836
|
+
const state = { font: null, color: null, tc: null, tw: null, tz: null, mode: null, alpha: null }
|
|
1837
|
+
|
|
1838
|
+
for (const line of t.lines) {
|
|
1839
|
+
if (!isObject(line)) continue
|
|
1840
|
+
const start = Math.max(0, Number.isInteger(line.start) ? line.start : 0)
|
|
1841
|
+
const end = Math.min(chars.length, Number.isInteger(line.end) ? line.end : 0)
|
|
1842
|
+
if (end <= start) continue
|
|
1843
|
+
const measured = Math.max(0, finite(line.w))
|
|
1844
|
+
const baseline = finite(line.baseline)
|
|
1845
|
+
|
|
1846
|
+
const segments = []
|
|
1847
|
+
for (const run of runs) {
|
|
1848
|
+
const from = Math.max(start, Number.isInteger(run.start) ? run.start : 0)
|
|
1849
|
+
const to = Math.min(end, Number.isInteger(run.end) ? run.end : 0)
|
|
1850
|
+
if (to > from) {
|
|
1851
|
+
segments.push({ from, to, style: styles[run.style],
|
|
1852
|
+
href: typeof run.href === 'string' && run.href ? run.href : null })
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
if (!segments.length) segments.push({ from: start, to: end, style: null, href: null })
|
|
1856
|
+
segments.sort((a, b) => a.from - b.from)
|
|
1857
|
+
|
|
1858
|
+
// Measure the whole line first: `Tz` is a property of the LINE, because the
|
|
1859
|
+
// SVD measures line widths and not run widths, and a per-run guess would
|
|
1860
|
+
// accumulate drift across a styled line.
|
|
1861
|
+
let natural = 0
|
|
1862
|
+
const pieces = []
|
|
1863
|
+
for (const segment of segments) {
|
|
1864
|
+
let style = segment.style
|
|
1865
|
+
if (!isObject(style)) {
|
|
1866
|
+
ctx.note(nodeId, 'a text run has no resolvable style; it is emitted at 16px sans-serif black.',
|
|
1867
|
+
'emit.pdf.text-style-missing', 'O')
|
|
1868
|
+
style = { size: 16, stack: 'sans-serif' }
|
|
1869
|
+
}
|
|
1870
|
+
const raw = transliterate(chars.slice(segment.from, segment.to), ctx, nodeId)
|
|
1871
|
+
const key = fontKeyFor(ctx, style)
|
|
1872
|
+
const size = finite(style.size, 16)
|
|
1873
|
+
const ls = finite(style.letterSpacing)
|
|
1874
|
+
const ws = finite(style.wordSpacing)
|
|
1875
|
+
// Small caps are synthesized here, BEFORE the advance is measured, so the
|
|
1876
|
+
// width the line's `Tz` is computed from is the width that gets painted.
|
|
1877
|
+
// Measuring the lowercase and drawing the caps is how a line ends up
|
|
1878
|
+
// stretched to hide its own substitution.
|
|
1879
|
+
const caps = splitSmallCaps(raw, style.variant)
|
|
1880
|
+
if (caps.length > 1 || caps[0].small) {
|
|
1881
|
+
ctx.note(nodeId, `a run is set in ${style.variant}; base-14 has no small-cap glyphs, so it is drawn as ` +
|
|
1882
|
+
`capitals at ${SMALL_CAPS_RATIO} of the em. The shapes are right and the proportions are close, but a ` +
|
|
1883
|
+
'designed small cap is wider and heavier than a shrunk capital.',
|
|
1884
|
+
'emit.pdf.text-small-caps', 'A')
|
|
1885
|
+
}
|
|
1886
|
+
for (const cap of caps) {
|
|
1887
|
+
const capSize = cap.small ? size * SMALL_CAPS_RATIO : size
|
|
1888
|
+
const width = advanceOf(key, cap.text, capSize, ls, ws)
|
|
1889
|
+
pieces.push({ style, text: cap.text, key, size: capSize, ls, ws, width, href: segment.href })
|
|
1890
|
+
natural += width
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
if (!measured) {
|
|
1894
|
+
ctx.note(nodeId, 'a text line has no measured width; it is drawn at the substituted font\'s own width.',
|
|
1895
|
+
'emit.pdf.text-no-width', 'A')
|
|
1896
|
+
}
|
|
1897
|
+
const tz = measured > 0 && natural > 0
|
|
1898
|
+
? Math.max(1, Math.min(1000, (measured / natural) * 100))
|
|
1899
|
+
: 100
|
|
1900
|
+
|
|
1901
|
+
let cursor = finite(line.x)
|
|
1902
|
+
for (const piece of pieces) {
|
|
1903
|
+
const { style, text, key, size, ls, ws } = piece
|
|
1904
|
+
const fill = Array.isArray(style.fills) ? style.fills.find(isObject) : null
|
|
1905
|
+
let color = [0, 0, 0, 1]
|
|
1906
|
+
if (!fill || fill.type === 'solid') {
|
|
1907
|
+
color = fill ? fill.color : [0, 0, 0, 1]
|
|
1908
|
+
} else {
|
|
1909
|
+
const stops = readStops(fill, ctx, nodeId)
|
|
1910
|
+
color = stops ? sampleStops(stops, 0.5) : [0, 0, 0, 1]
|
|
1911
|
+
ctx.note(nodeId, `a ${String(fill.type)} text fill cannot paint glyphs in PDF without a text-clip pass; ` +
|
|
1912
|
+
'the run is painted as the flat colour of the gradient\'s mid stop.',
|
|
1913
|
+
'emit.pdf.text-fill-gradient-flat', 'O')
|
|
1914
|
+
}
|
|
1915
|
+
const alpha = alphaOf(color)
|
|
1916
|
+
const stroked = finite(style.strokeWidth) > 0
|
|
1917
|
+
|
|
1918
|
+
const painted = (piece.width * tz) / 100
|
|
1919
|
+
const at = cursor
|
|
1920
|
+
for (const chunk of chunkByEncodability(text)) {
|
|
1921
|
+
const advance = (advanceOf(key, chunk.text, size, ls, ws) * tz) / 100
|
|
1922
|
+
let str
|
|
1923
|
+
let fontName
|
|
1924
|
+
let mode = stroked ? 2 : 0
|
|
1925
|
+
if (chunk.font === '*') {
|
|
1926
|
+
str = pdfString(chunk.text)
|
|
1927
|
+
fontName = ctx.res.font(key)
|
|
1928
|
+
} else if (chunk.font) {
|
|
1929
|
+
// Painted, not hidden: the character has a real glyph in a font every
|
|
1930
|
+
// viewer carries. Its /ToUnicode puts the original codepoint back on
|
|
1931
|
+
// the clipboard, so this costs shape and nothing else.
|
|
1932
|
+
const font = ctx.res.symbolFont(chunk.font)
|
|
1933
|
+
let literal = '('
|
|
1934
|
+
for (const ch of chunk.text) {
|
|
1935
|
+
const glyph = symbolGlyph(ch)
|
|
1936
|
+
font.used.set(glyph.code, ch.codePointAt(0))
|
|
1937
|
+
const byte = String.fromCharCode(glyph.code)
|
|
1938
|
+
literal += (byte === '(' || byte === ')' || byte === '\\' ? '\\' : '') + byte
|
|
1939
|
+
}
|
|
1940
|
+
str = `${literal})`
|
|
1941
|
+
fontName = font.name
|
|
1942
|
+
ctx.note(nodeId, `"${chunk.text}" has no glyph in a Latin base-14 font, so it is painted from ` +
|
|
1943
|
+
`${chunk.font}, which every PDF viewer carries. The shape is Adobe's, not the page's font's, and ` +
|
|
1944
|
+
'the advance is that face\'s own; /ToUnicode keeps the character itself for search and copy-paste.',
|
|
1945
|
+
'emit.pdf.text-symbol-font', 'A')
|
|
1946
|
+
} else {
|
|
1947
|
+
// No base-14 glyph, and the SVD carries no font binary to embed — so
|
|
1948
|
+
// there is no glyph to draw at all. It still goes in, invisible, so
|
|
1949
|
+
// the page stays searchable and copy-pasteable, and it says so.
|
|
1950
|
+
str = ctx.unicodeFont.encode(chunk.text)
|
|
1951
|
+
if (!str) {
|
|
1952
|
+
ctx.note(nodeId, 'a text run exceeded the 65,534 distinct codepoints one Identity-H font can carry; ' +
|
|
1953
|
+
'it is dropped.', 'emit.pdf.text-cid-overflow', 'O')
|
|
1954
|
+
cursor += advance
|
|
1955
|
+
continue
|
|
1956
|
+
}
|
|
1957
|
+
ctx.res.useUnicode(ctx.unicodeFont)
|
|
1958
|
+
fontName = ctx.unicodeFont.name
|
|
1959
|
+
mode = 3
|
|
1960
|
+
ctx.note(nodeId, `"${chunk.text}" uses characters neither WinAnsi nor Symbol nor ZapfDingbats can ` +
|
|
1961
|
+
'address, and the SVD carries no ' +
|
|
1962
|
+
'font binary to embed; that stretch is emitted as selectable-but-invisible text (Tr 3) rather than ' +
|
|
1963
|
+
'as wrong glyphs. The rest of the run is painted normally.',
|
|
1964
|
+
'emit.pdf.text-unicode-invisible', 'O')
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (state.alpha !== alpha) {
|
|
1968
|
+
const gs = gsFor(ctx, alpha, alpha)
|
|
1969
|
+
body.push(gs ? `/${gs} gs` : `/${ctx.res.gs('/Type /ExtGState /ca 1 /CA 1')} gs`)
|
|
1970
|
+
state.alpha = alpha
|
|
1971
|
+
}
|
|
1972
|
+
const fontLine = `/${fontName} ${num(size)} Tf`
|
|
1973
|
+
if (state.font !== fontLine) { body.push(fontLine); state.font = fontLine }
|
|
1974
|
+
const colorLine = fillColorOp(color)
|
|
1975
|
+
if (state.color !== colorLine) { body.push(colorLine); state.color = colorLine }
|
|
1976
|
+
if (state.tc !== ls) { body.push(`${num(ls)} Tc`); state.tc = ls }
|
|
1977
|
+
if (state.tw !== ws) { body.push(`${num(ws)} Tw`); state.tw = ws }
|
|
1978
|
+
if (state.tz !== tz) { body.push(`${num(tz)} Tz`); state.tz = tz }
|
|
1979
|
+
if (state.mode !== mode) { body.push(`${mode} Tr`); state.mode = mode }
|
|
1980
|
+
if (mode === 2) {
|
|
1981
|
+
body.push(`${num(finite(style.strokeWidth))} w`)
|
|
1982
|
+
body.push(strokeColorOp(Array.isArray(style.strokeColor) ? style.strokeColor : color))
|
|
1983
|
+
}
|
|
1984
|
+
// The counter-flip: the page CTM is y-down, and glyphs are not.
|
|
1985
|
+
body.push(`1 0 0 -1 ${num(cursor)} ${num(baseline)} Tm`)
|
|
1986
|
+
body.push(`${str} Tj`)
|
|
1987
|
+
cursor += advance
|
|
1988
|
+
}
|
|
1989
|
+
cursor = at
|
|
1990
|
+
if (piece.href && ctx.wantLinks && painted > 0) {
|
|
1991
|
+
// The run's own measured box, taken to SVD user space through the CTM
|
|
1992
|
+
// this node was reached under. `asc`/`desc` are the line's, which is
|
|
1993
|
+
// what a hit target should be — the em box, not the ink.
|
|
1994
|
+
const asc = finite(line.asc, size * 0.8)
|
|
1995
|
+
const desc = finite(line.desc, size * 0.2)
|
|
1996
|
+
ctx.links.push({
|
|
1997
|
+
href: piece.href,
|
|
1998
|
+
rect: transformBox(ctx.ctm, at, baseline - asc, at + painted, baseline + desc),
|
|
1999
|
+
})
|
|
2000
|
+
}
|
|
2001
|
+
for (const decoration of readDecorations(style)) {
|
|
2002
|
+
decorations.push({ line: decoration.line, color: decoration.color || color, style: decoration.style,
|
|
2003
|
+
thickness: Number.isFinite(Number(decoration.thickness)) && decoration.thickness !== null
|
|
2004
|
+
? Number(decoration.thickness) : Math.max(0.5, size / 16),
|
|
2005
|
+
x: cursor, w: painted, baseline, size, asc: finite(line.asc, size * 0.8) })
|
|
2006
|
+
}
|
|
2007
|
+
cursor += painted
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (!body.length) return
|
|
2012
|
+
// q/Q, because `gs` is sticky and a translucent run would otherwise fade
|
|
2013
|
+
// whatever the node paints after it.
|
|
2014
|
+
out.push('q')
|
|
2015
|
+
out.push('BT')
|
|
2016
|
+
for (const op of body) out.push(op)
|
|
2017
|
+
out.push('ET')
|
|
2018
|
+
out.push('Q')
|
|
2019
|
+
|
|
2020
|
+
for (const d of decorations) {
|
|
2021
|
+
if (!(d.w > 0) || !(d.thickness > 0)) continue
|
|
2022
|
+
// CSS has no exposed metric for these positions in the SVD, and PDF has no
|
|
2023
|
+
// decoration primitive, so they are drawn from the size: the usual 0.1em
|
|
2024
|
+
// under the baseline, and a strike at the middle of the x-height.
|
|
2025
|
+
const y = d.line === 'overline' ? d.baseline - d.asc
|
|
2026
|
+
: d.line === 'line-through' ? d.baseline - d.size * 0.28
|
|
2027
|
+
: d.baseline + Math.max(1, d.size * 0.1)
|
|
2028
|
+
if (d.style && d.style !== 'solid' && d.style !== 'initial') {
|
|
2029
|
+
ctx.note(nodeId, `a text-decoration is "${d.style}"; it is drawn solid.`,
|
|
2030
|
+
'emit.pdf.text-decoration-style', 'A')
|
|
2031
|
+
}
|
|
2032
|
+
ctx.note(nodeId, `a ${d.line} is drawn as a rectangle positioned from the font size; the SVD carries no ` +
|
|
2033
|
+
'underline offset or thickness metric from the font.', 'emit.pdf.text-decoration-metrics', 'A')
|
|
2034
|
+
const alpha = alphaOf(d.color)
|
|
2035
|
+
if (alpha <= 0) continue
|
|
2036
|
+
out.push('q')
|
|
2037
|
+
const gs = gsFor(ctx, alpha, alpha)
|
|
2038
|
+
if (gs) out.push(`/${gs} gs`)
|
|
2039
|
+
out.push(fillColorOp(d.color))
|
|
2040
|
+
out.push(rectPath(d.x, y - d.thickness / 2, d.w, d.thickness))
|
|
2041
|
+
out.push('f')
|
|
2042
|
+
out.push('Q')
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// ——— nodes ———
|
|
2047
|
+
|
|
2048
|
+
const frameOf = (node) => rectOf(isObject(node.frame) ? node.frame : node.abs)
|
|
2049
|
+
|
|
2050
|
+
/** The node box plus every descendant's, in node-local px. */
|
|
2051
|
+
function subtreeExtent (node, ctx) {
|
|
2052
|
+
const frame = frameOf(node)
|
|
2053
|
+
const origin = isObject(node.abs) ? rectOf(node.abs) : { x: 0, y: 0 }
|
|
2054
|
+
let minX = 0
|
|
2055
|
+
let minY = 0
|
|
2056
|
+
let maxX = frame.w
|
|
2057
|
+
let maxY = frame.h
|
|
2058
|
+
const stack = Array.isArray(node.children) ? node.children.slice() : []
|
|
2059
|
+
const seen = new Set()
|
|
2060
|
+
while (stack.length) {
|
|
2061
|
+
const id = stack.pop()
|
|
2062
|
+
if (seen.has(id)) continue
|
|
2063
|
+
seen.add(id)
|
|
2064
|
+
const child = ctx.nodes[id]
|
|
2065
|
+
if (!isObject(child)) continue
|
|
2066
|
+
if (isObject(child.abs)) {
|
|
2067
|
+
const abs = rectOf(child.abs)
|
|
2068
|
+
minX = Math.min(minX, abs.x - origin.x)
|
|
2069
|
+
minY = Math.min(minY, abs.y - origin.y)
|
|
2070
|
+
maxX = Math.max(maxX, abs.x - origin.x + abs.w)
|
|
2071
|
+
maxY = Math.max(maxY, abs.y - origin.y + abs.h)
|
|
2072
|
+
}
|
|
2073
|
+
if (Array.isArray(child.children)) stack.push(...child.children)
|
|
2074
|
+
}
|
|
2075
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/** Invariant 3: sort by `paint.z`, break ties with DOM order, never re-derive. */
|
|
2079
|
+
function sortedChildren (node, ctx) {
|
|
2080
|
+
const ids = (Array.isArray(node.children) ? node.children : []).filter((id) => isObject(ctx.nodes[id]))
|
|
2081
|
+
return ids
|
|
2082
|
+
.map((id, i) => ({ id, i, z: Number.isInteger(ctx.nodes[id].paint && ctx.nodes[id].paint.z) ? ctx.nodes[id].paint.z : 0 }))
|
|
2083
|
+
.sort((a, b) => (a.z - b.z) || (a.i - b.i))
|
|
2084
|
+
.map((entry) => entry.id)
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* One node: its own paint, then its children.
|
|
2089
|
+
*
|
|
2090
|
+
* `opacity < 1` and a blend mode both go through a transparency group form
|
|
2091
|
+
* XObject rather than a bare `/ca`. That distinction is not pedantry: `/ca` on a
|
|
2092
|
+
* subtree applies to every object in it separately, so two overlapping children
|
|
2093
|
+
* of a 50%-opacity group would show their own seam through each other. The group
|
|
2094
|
+
* composites the subtree first and fades the result, which is what CSS `opacity`
|
|
2095
|
+
* means.
|
|
2096
|
+
*/
|
|
2097
|
+
async function emitNode (out, ctx, nodeId, node) {
|
|
2098
|
+
if (ctx.visited.has(nodeId)) {
|
|
2099
|
+
ctx.note(nodeId, 'the node is reachable twice; the second visit is skipped to keep the emit finite.',
|
|
2100
|
+
'emit.pdf.node-revisited', 'O')
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
ctx.visited.add(nodeId)
|
|
2104
|
+
|
|
2105
|
+
const frame = frameOf(node)
|
|
2106
|
+
const box = { x: 0, y: 0, w: frame.w, h: frame.h }
|
|
2107
|
+
|
|
2108
|
+
// The placement is decided here rather than at the bottom (where it is
|
|
2109
|
+
// WRITTEN) because everything emitted into `inner` below is emitted under it,
|
|
2110
|
+
// and a link annotation has to know that before the fact — it lives in the
|
|
2111
|
+
// page dictionary, which no `cm` reaches.
|
|
2112
|
+
const placement = []
|
|
2113
|
+
let localM = IDENTITY
|
|
2114
|
+
if (frame.x || frame.y) {
|
|
2115
|
+
const m = [1, 0, 0, 1, frame.x, frame.y]
|
|
2116
|
+
placement.push(matrixOp(m))
|
|
2117
|
+
localM = matMul(localM, m)
|
|
2118
|
+
}
|
|
2119
|
+
if (isObject(node.transform) && Array.isArray(node.transform.m) && node.transform.m.length === 6) {
|
|
2120
|
+
placement.push(matrixOp(node.transform.m))
|
|
2121
|
+
localM = matMul(localM, node.transform.m)
|
|
2122
|
+
} else if (node.transform !== null && node.transform !== undefined && !isObject(node.transform)) {
|
|
2123
|
+
ctx.note(nodeId, 'the node carries an unreadable transform; it is emitted untransformed.',
|
|
2124
|
+
'emit.pdf.transform-unreadable', 'O')
|
|
2125
|
+
}
|
|
2126
|
+
const parentCtm = ctx.ctm
|
|
2127
|
+
ctx.ctm = matMul(localM, parentCtm)
|
|
2128
|
+
|
|
2129
|
+
const radii = normalizeRadii(node.radii, box.w, box.h)
|
|
2130
|
+
const effects = (Array.isArray(node.effects) ? node.effects : []).filter(isObject)
|
|
2131
|
+
const drops = effects.filter((e) => e.type === 'dropShadow' && e.source !== 'filter')
|
|
2132
|
+
const inners = effects.filter((e) => e.type === 'innerShadow' && e.source !== 'filter')
|
|
2133
|
+
for (const e of effects) {
|
|
2134
|
+
if (e.type === 'layerBlur' || e.type === 'backgroundBlur') {
|
|
2135
|
+
ctx.note(nodeId, `a CSS filter "${e.type}" applies to the node AND its descendants; PDF has no filter that ` +
|
|
2136
|
+
'reads back what is already painted, so it is dropped. Rasterizing the subtree is the only alternative ' +
|
|
2137
|
+
'and this backend does not rasterize.', 'emit.pdf.filter-dropped', 'O')
|
|
2138
|
+
} else if (e.type === 'colorMatrix') {
|
|
2139
|
+
// Named separately from `filter-dropped` because the loss is a different one and
|
|
2140
|
+
// the fix is a different one: a colour matrix is not a blur, it is `saturate()`,
|
|
2141
|
+
// `hue-rotate()`, `grayscale()` or `sepia()` applied to everything already painted
|
|
2142
|
+
// under this node. PDF's transfer functions (`/TR`) are per-channel curves and
|
|
2143
|
+
// cannot express a matrix that mixes channels, which every one of those does, so
|
|
2144
|
+
// the subtree paints in its ORIGINAL colours — a silent, plausible-looking picture,
|
|
2145
|
+
// which is why it must say so. The SVG backend paints it (`feColorMatrix`).
|
|
2146
|
+
ctx.note(nodeId, `${e.backdrop ? 'backdrop-filter' : 'filter'}: ${e.srcCss || 'a colour matrix'} recolours ` +
|
|
2147
|
+
'this node and everything inside it. PDF has no colour-matrix operator — `/TR` transfer functions are ' +
|
|
2148
|
+
'per-channel and these mix channels — so the subtree keeps its original colours and the recolouring is ' +
|
|
2149
|
+
'dropped. Nothing in the page looks missing, which is the danger: compare against the SVG backend, ' +
|
|
2150
|
+
'which paints it exactly.', 'emit.pdf.color-matrix-dropped', 'O')
|
|
2151
|
+
} else if ((e.type === 'dropShadow' || e.type === 'innerShadow') && e.source === 'filter') {
|
|
2152
|
+
ctx.note(nodeId, 'filter: drop-shadow() follows the alpha silhouette of the whole subtree; it is emitted ' +
|
|
2153
|
+
'from the node\'s own box instead.', 'emit.pdf.filter-shadow-box', 'A')
|
|
2154
|
+
if (e.type === 'dropShadow') drops.push(e)
|
|
2155
|
+
else inners.push(e)
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
const fills = (Array.isArray(node.fills) ? node.fills : []).filter(isObject)
|
|
2159
|
+
const strokes = (Array.isArray(node.strokes) ? node.strokes : []).filter(isObject)
|
|
2160
|
+
|
|
2161
|
+
const opacity = isNum(node.opacity) ? clamp01(node.opacity) : 1
|
|
2162
|
+
const blend = blendName(ctx, nodeId, node.blend)
|
|
2163
|
+
const grouped = opacity < 1 || !!blend
|
|
2164
|
+
// The node's own ops always go to a buffer, so the wrapper — a `q`/`Q` pair or
|
|
2165
|
+
// a transparency group — can be decided after the subtree is known.
|
|
2166
|
+
const inner = []
|
|
2167
|
+
|
|
2168
|
+
if (drops.length && !fills.length && !strokes.length) {
|
|
2169
|
+
ctx.note(nodeId, 'the node has a box-shadow but no fill or stroke to cast it from; the shadow follows ' +
|
|
2170
|
+
'whatever silhouette its own paint has, which is empty.', 'emit.pdf.shadow-no-silhouette', 'A')
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
if (drops.length) emitDropShadows(inner, ctx, nodeId, drops, box, radii)
|
|
2174
|
+
for (const fill of fills) await emitLayer(inner, ctx, nodeId, fill, box, radii)
|
|
2175
|
+
if (inners.length) emitInnerShadows(inner, ctx, nodeId, inners, box, radii)
|
|
2176
|
+
if (strokes.length) emitStrokes(inner, ctx, nodeId, strokes, box, radii)
|
|
2177
|
+
if (node.type === 'text') emitText(inner, ctx, nodeId, node)
|
|
2178
|
+
if (node.type === 'vector') {
|
|
2179
|
+
ctx.note(nodeId, 'a vector node carries passthrough SVG markup, which this backend has no parser for; ' +
|
|
2180
|
+
'nothing is painted for it.', 'emit.pdf.vector-passthrough', 'O')
|
|
2181
|
+
}
|
|
2182
|
+
if (node.type === 'image' && !fills.length) {
|
|
2183
|
+
ctx.note(nodeId, 'an image node carries no image fill; nothing is painted for it.',
|
|
2184
|
+
'emit.pdf.image-node-empty', 'O')
|
|
2185
|
+
}
|
|
2186
|
+
if (node.mask) {
|
|
2187
|
+
ctx.note(nodeId, 'the node declares a mask; this backend does not emit masks yet, so it is painted unmasked.',
|
|
2188
|
+
'emit.pdf.mask-unsupported', 'O')
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const children = sortedChildren(node, ctx)
|
|
2192
|
+
if (children.length) {
|
|
2193
|
+
const clip = isObject(node.clip) ? node.clip : null
|
|
2194
|
+
if (clip) {
|
|
2195
|
+
if (clip.mode !== 'rect') {
|
|
2196
|
+
ctx.note(nodeId, `clip mode "${String(clip.mode)}" is not a rectangle; it is emitted as the box outline.`,
|
|
2197
|
+
'emit.pdf.clip-not-rect', 'A')
|
|
2198
|
+
}
|
|
2199
|
+
if (!isObject(clip.rect) && clip.box && clip.box !== 'border') {
|
|
2200
|
+
ctx.note(nodeId, `the overflow clip is declared on the ${clip.box} box, but the node carries no padding ` +
|
|
2201
|
+
'inset; it is applied to the border box.', 'emit.pdf.clip-inset-unknown', 'A')
|
|
2202
|
+
}
|
|
2203
|
+
inner.push('q')
|
|
2204
|
+
inner.push(shapePath(isObject(clip.rect) ? rectOf(clip.rect) : box,
|
|
2205
|
+
normalizeRadii(clip.radii || node.radii, box.w, box.h)))
|
|
2206
|
+
inner.push('W n')
|
|
2207
|
+
}
|
|
2208
|
+
for (const id of children) await emitNode(inner, ctx, id, ctx.nodes[id])
|
|
2209
|
+
if (clip) inner.push('Q')
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// Placement (computed at the top) is written outside the group, so the form's
|
|
2213
|
+
// BBox is in the node's own coordinates and the transform is applied once
|
|
2214
|
+
// either way.
|
|
2215
|
+
ctx.ctm = parentCtm
|
|
2216
|
+
|
|
2217
|
+
if (!inner.length) return
|
|
2218
|
+
|
|
2219
|
+
out.push('q')
|
|
2220
|
+
for (const op of placement) out.push(op)
|
|
2221
|
+
|
|
2222
|
+
if (grouped) {
|
|
2223
|
+
// A transparency group, not a bare `/ca`: `/ca` applies to every object in
|
|
2224
|
+
// the subtree SEPARATELY, so two overlapping children of a 50% group would
|
|
2225
|
+
// show their own seam through each other. The group composites the subtree
|
|
2226
|
+
// first and fades the result, which is what CSS `opacity` means.
|
|
2227
|
+
const extent = subtreeExtent(node, ctx)
|
|
2228
|
+
const bleed = shadowBleed([...drops, ...inners])
|
|
2229
|
+
const formId = ctx.pdf.reserve()
|
|
2230
|
+
ctx.forms.push({
|
|
2231
|
+
id: formId,
|
|
2232
|
+
body: inner.join('\n'),
|
|
2233
|
+
dict: '/Type /XObject /Subtype /Form /FormType 1 ' +
|
|
2234
|
+
`/BBox [${num(extent.x - bleed)} ${num(extent.y - bleed)} ` +
|
|
2235
|
+
`${num(extent.x + extent.w + bleed)} ${num(extent.y + extent.h + bleed)}] ` +
|
|
2236
|
+
'/Group << /Type /Group /S /Transparency /CS /DeviceRGB /I false /K false >> ' +
|
|
2237
|
+
`/Resources ${ctx.resourcesId} 0 R`,
|
|
2238
|
+
})
|
|
2239
|
+
const gs = gsFor(ctx, opacity, opacity, blend)
|
|
2240
|
+
if (gs) out.push(`/${gs} gs`)
|
|
2241
|
+
out.push(`/${ctx.res.xobject(formId, 'Fm')} Do`)
|
|
2242
|
+
} else {
|
|
2243
|
+
for (const op of inner) out.push(op)
|
|
2244
|
+
}
|
|
2245
|
+
out.push('Q')
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// ——— entry point ———
|
|
2249
|
+
|
|
2250
|
+
function readRootId (root) {
|
|
2251
|
+
if (typeof root === 'string' && root) return root
|
|
2252
|
+
if (isObject(root) && typeof root.$ref === 'string' && root.$ref) return root.$ref
|
|
2253
|
+
return null
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Render an SVD document as a vector PDF.
|
|
2258
|
+
*
|
|
2259
|
+
* Every box is a path, every gradient a shading, every line of text a `Tj` at the
|
|
2260
|
+
* baseline the browser measured. The only rasters in the output are the ones that
|
|
2261
|
+
* arrived as rasters in `doc.assets`.
|
|
2262
|
+
*
|
|
2263
|
+
* @param {object} doc an SVD document (`svd/1.0`)
|
|
2264
|
+
* @param {object} [options]
|
|
2265
|
+
* @param {boolean} [options.compress=true] Deflate the content streams. Turn it
|
|
2266
|
+
* off to read the operators with `strings`.
|
|
2267
|
+
* @param {number[]|null} [options.background=null] `[r, g, b]` (or `[r,g,b,a]`)
|
|
2268
|
+
* floats 0..1 painted under the page. The capture root usually has its own
|
|
2269
|
+
* background; this is for the ones that do not.
|
|
2270
|
+
* @param {number} [options.shadowLayers=24] Concentric copies per blurred
|
|
2271
|
+
* shadow. PDF has no blur; this is the knob on the approximation that replaces
|
|
2272
|
+
* it. 1 collapses every shadow to a hard offset shape.
|
|
2273
|
+
* @param {number} [options.quality=0.92] JPEG quality for raster assets that
|
|
2274
|
+
* have to be re-encoded (a non-JPEG asset, in a runtime with a canvas).
|
|
2275
|
+
* @param {(d: object) => void} [options.onDiagnostic] Called per approximation
|
|
2276
|
+
* as it happens.
|
|
2277
|
+
* @param {(msg: string) => void} [options.onWarn] Called with a one-line string
|
|
2278
|
+
* per approximation. `onDiagnostic` gets the structured record; this is the
|
|
2279
|
+
* channel a host that only owns a `console.warn` can read, and the raster plugin
|
|
2280
|
+
* uses it.
|
|
2281
|
+
* @param {Array<{size:[number,number], clip?:[number,number,number,number],
|
|
2282
|
+
* matrix:[number,number,number,number,number,number]}>} [options.pages] Finished
|
|
2283
|
+
* page geometry, one entry per page, from a caller that owns the pagination.
|
|
2284
|
+
* `size` is the MediaBox in points; `clip` is the printable area in PAGE space
|
|
2285
|
+
* (y UP) and nothing paints outside it; `matrix` is a PDF `cm` taking SVD user
|
|
2286
|
+
* space (css px, y DOWN, origin at the capture root's border-box top-left) onto
|
|
2287
|
+
* that page. The drawing is emitted ONCE as a form XObject and re-placed per
|
|
2288
|
+
* page, so N pages cost one content stream plus N tiny ones.
|
|
2289
|
+
* Omitted (the default) — one page at the capture's natural size, `PT` scale,
|
|
2290
|
+
* no margins: the emitter paginates nothing on its own.
|
|
2291
|
+
* @param {boolean} [options.links=true] Emit `/Link` annotations for text runs
|
|
2292
|
+
* that carry an `href`. Rects come from the run's own measured box mapped
|
|
2293
|
+
* through the node's accumulated CTM.
|
|
2294
|
+
* @returns {Promise<Uint8Array>} the PDF bytes. The array also carries a
|
|
2295
|
+
* non-enumerable `diagnostics` array — `{code, severity, message, grade, node?}`,
|
|
2296
|
+
* the shape `doc.diagnostics` holds — because an approximation that only
|
|
2297
|
+
* reaches a stream nobody opens is a silent degradation. The same list is
|
|
2298
|
+
* written into the file as an uncompressed object off the catalog, so
|
|
2299
|
+
* `strings out.pdf` finds it too.
|
|
2300
|
+
*/
|
|
2301
|
+
export async function svdToPdf (doc, options = {}) {
|
|
2302
|
+
if (!isObject(doc)) throw new TypeError('svdToPdf: doc must be an SVD document object')
|
|
2303
|
+
const {
|
|
2304
|
+
compress = true,
|
|
2305
|
+
background = null,
|
|
2306
|
+
shadowLayers = SHADOW_LAYERS,
|
|
2307
|
+
quality = 0.92,
|
|
2308
|
+
onDiagnostic = null,
|
|
2309
|
+
onWarn = null,
|
|
2310
|
+
pages = null,
|
|
2311
|
+
links = true,
|
|
2312
|
+
} = options
|
|
2313
|
+
const pageList = readPages(pages)
|
|
2314
|
+
|
|
2315
|
+
const nodes = isObject(doc.nodes) ? doc.nodes : {}
|
|
2316
|
+
const seen = new Set()
|
|
2317
|
+
const diagnostics = []
|
|
2318
|
+
const pdf = createPdfDoc()
|
|
2319
|
+
const catalogId = pdf.reserve()
|
|
2320
|
+
const pagesId = pdf.reserve()
|
|
2321
|
+
const resourcesId = pdf.reserve()
|
|
2322
|
+
const res = createResources(pdf)
|
|
2323
|
+
|
|
2324
|
+
const ctx = {
|
|
2325
|
+
/** The SVD document. */
|
|
2326
|
+
doc,
|
|
2327
|
+
/** The PDF writer. Two different things, two different names. */
|
|
2328
|
+
pdf,
|
|
2329
|
+
nodes,
|
|
2330
|
+
res,
|
|
2331
|
+
resourcesId,
|
|
2332
|
+
forms: [],
|
|
2333
|
+
images: new Map(),
|
|
2334
|
+
visited: new Set(),
|
|
2335
|
+
fontNotes: new Set(),
|
|
2336
|
+
unicodeFont: createUnicodeFont(pdf),
|
|
2337
|
+
shadowLayers: Math.max(1, Math.round(finite(shadowLayers, SHADOW_LAYERS))),
|
|
2338
|
+
quality,
|
|
2339
|
+
/**
|
|
2340
|
+
* The transform from the node currently being emitted to SVD user space.
|
|
2341
|
+
* Maintained by `emitNode` alongside the `cm` it writes, because an
|
|
2342
|
+
* annotation rect is not in the content stream — it is in the page
|
|
2343
|
+
* dictionary, where no CTM reaches it, so the only way to place one is to
|
|
2344
|
+
* have carried the matrix here.
|
|
2345
|
+
*/
|
|
2346
|
+
ctm: IDENTITY,
|
|
2347
|
+
/** `{rect: [x0,y0,x1,y1] in SVD user space, href}` — see `links`. */
|
|
2348
|
+
links: [],
|
|
2349
|
+
wantLinks: links !== false,
|
|
2350
|
+
/**
|
|
2351
|
+
* One approximation, in every channel it has: the returned `diagnostics`,
|
|
2352
|
+
* the object embedded in the file, and the caller's own hook.
|
|
2353
|
+
*
|
|
2354
|
+
* @param {string|null} nodeId the node it happened on, null for the document
|
|
2355
|
+
* @param {string} message prose, for a human reading the file
|
|
2356
|
+
* @param {string} [code] stable id, so nobody has to regex the prose
|
|
2357
|
+
* @param {string} [grade] the SVD fidelity grade this implies
|
|
2358
|
+
*/
|
|
2359
|
+
note (nodeId, message, code = 'emit.pdf.approximation', grade = 'A') {
|
|
2360
|
+
const key = `${nodeId || ''}::${message}`
|
|
2361
|
+
if (seen.has(key)) return
|
|
2362
|
+
seen.add(key)
|
|
2363
|
+
const entry = { code, severity: SEVERITY_OF[grade] || 'warn', message, grade }
|
|
2364
|
+
if (nodeId) entry.node = nodeId
|
|
2365
|
+
diagnostics.push(entry)
|
|
2366
|
+
if (onDiagnostic) onDiagnostic(entry)
|
|
2367
|
+
// Two channels, one event. A host that has a console and no place to put a
|
|
2368
|
+
// structured record still hears it — that is the whole point of onWarn.
|
|
2369
|
+
if (onWarn) onWarn(`[emit/pdf] ${grade} ${code}${nodeId ? ` [${nodeId}]` : ''}: ${message}`)
|
|
2370
|
+
},
|
|
2371
|
+
}
|
|
2372
|
+
const rootId = readRootId(doc.root)
|
|
2373
|
+
const root = rootId !== null ? nodes[rootId] : null
|
|
2374
|
+
|
|
2375
|
+
const capture = isObject(doc.capture) ? doc.capture : {}
|
|
2376
|
+
const captureRoot = isObject(capture.root) ? capture.root : null
|
|
2377
|
+
const rootFrame = isObject(root) ? frameOf(root) : { x: 0, y: 0, w: 0, h: 0 }
|
|
2378
|
+
const width = captureRoot && finite(captureRoot.w) > 0 ? finite(captureRoot.w) : rootFrame.w
|
|
2379
|
+
const height = captureRoot && finite(captureRoot.h) > 0 ? finite(captureRoot.h) : rootFrame.h
|
|
2380
|
+
if (!(width > 0) || !(height > 0)) {
|
|
2381
|
+
ctx.note(null, 'the capture has no width or height; a 1x1 point page is emitted.',
|
|
2382
|
+
'emit.pdf.empty-page', 'O')
|
|
2383
|
+
}
|
|
2384
|
+
const pageW = Math.max(1, width * PT)
|
|
2385
|
+
const pageH = Math.max(1, height * PT)
|
|
2386
|
+
|
|
2387
|
+
// The drawing, once, in SVD user space (css px, y DOWN, origin at the capture
|
|
2388
|
+
// root's top-left). It is deliberately NOT wrapped in the page flip here: a
|
|
2389
|
+
// caller that supplies `pages` places this same body on every page under its
|
|
2390
|
+
// own matrix, and the emitter must not have baked one page's geometry into it.
|
|
2391
|
+
const drawing = []
|
|
2392
|
+
if (!isObject(root)) {
|
|
2393
|
+
ctx.note(null, `root ${rootId === null ? 'is missing' : `"${rootId}" does not resolve to a node`}; the ` +
|
|
2394
|
+
'document is emitted empty.', 'emit.pdf.root-missing', 'O')
|
|
2395
|
+
} else {
|
|
2396
|
+
await emitNode(drawing, ctx, rootId, root)
|
|
2397
|
+
const orphans = Object.keys(nodes).filter((id) => !ctx.visited.has(id))
|
|
2398
|
+
if (orphans.length) {
|
|
2399
|
+
ctx.note(null, `${orphans.length} node(s) are not reachable from the root and are not emitted: ` +
|
|
2400
|
+
`${orphans.slice(0, 8).join(', ')}${orphans.length > 8 ? ', …' : ''}.`,
|
|
2401
|
+
'emit.pdf.orphan-nodes', 'O')
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* The page geometry actually used. Without `pages` this is the single page at
|
|
2407
|
+
* the capture's natural size and the classic y-flip — the behaviour every
|
|
2408
|
+
* caller had before the option existed, reproduced operator for operator.
|
|
2409
|
+
*/
|
|
2410
|
+
const sheets = pageList || [{
|
|
2411
|
+
size: [pageW, pageH],
|
|
2412
|
+
matrix: [PT, 0, 0, -PT, 0, pageH],
|
|
2413
|
+
clip: null,
|
|
2414
|
+
}]
|
|
2415
|
+
if (pageList) {
|
|
2416
|
+
ctx.note(null, `the caller supplied ${pageList.length} page geometr${pageList.length === 1 ? 'y' : 'ies'}; ` +
|
|
2417
|
+
'the drawing is emitted once and placed under each page\'s own matrix, so the capture\'s natural size and ' +
|
|
2418
|
+
'this backend\'s own y-flip are not used.', 'emit.pdf.caller-pagination', 'A')
|
|
2419
|
+
if (pageList.length > 1) {
|
|
2420
|
+
// Measured, not assumed: pdf.js's getTextContent returns only the slice
|
|
2421
|
+
// that lands on each page, and every renderer clips to the MediaBox — but
|
|
2422
|
+
// the OPERATORS for the whole document are on every page, off-media, and a
|
|
2423
|
+
// search index that reads the stream rather than the geometry will find
|
|
2424
|
+
// each word once per page. Fixing it means walking the tree once per page
|
|
2425
|
+
// with a y-window, which this backend does not do.
|
|
2426
|
+
ctx.note(null, `the drawing is one form XObject shared by all ${pageList.length} pages, so each page's ` +
|
|
2427
|
+
'content stream references the whole document and only the matrix decides which slice lands on the ' +
|
|
2428
|
+
'paper. Rendering, clipping and text extraction are correct; a raw-stream text scraper will see every ' +
|
|
2429
|
+
'word once per page.', 'emit.pdf.pagination-shared-drawing', 'O')
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
ctx.unicodeFont.finalize()
|
|
2434
|
+
|
|
2435
|
+
const deflated = compress && canDeflate
|
|
2436
|
+
const streamDict = deflated ? '/Filter /FlateDecode' : ''
|
|
2437
|
+
if (compress && !canDeflate) {
|
|
2438
|
+
ctx.note(null, 'this runtime has no CompressionStream, so the content streams are written uncompressed.',
|
|
2439
|
+
'emit.pdf.no-deflate', 'E')
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* The painted background, in the space the sheet is drawn in.
|
|
2444
|
+
*
|
|
2445
|
+
* One page gets it in SVD space over the capture rect (where the drawing is);
|
|
2446
|
+
* a paginated run gets it in PAGE space over the printable area, because that
|
|
2447
|
+
* is what "the paper is this colour" means once there is paper.
|
|
2448
|
+
*/
|
|
2449
|
+
const backgroundOps = (x, y, w, h) => {
|
|
2450
|
+
if (!Array.isArray(background) || background.length < 3) return []
|
|
2451
|
+
const alpha = alphaOf(background)
|
|
2452
|
+
const gs = gsFor(ctx, alpha, alpha)
|
|
2453
|
+
return [...(gs ? [`/${gs} gs`] : []), fillColorOp(background), rectPath(x, y, w, h), 'f']
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// The drawing goes into a form XObject only when there is more than one place
|
|
2457
|
+
// to put it. For the single-page default it is inlined, so the content stream
|
|
2458
|
+
// is exactly what it has always been.
|
|
2459
|
+
let drawingRef = null
|
|
2460
|
+
if (pageList) {
|
|
2461
|
+
// A shadow, an outline or a negative-offset child paints outside the capture
|
|
2462
|
+
// rect; the BBox must not be the thing that clips it — the page's own `clip`
|
|
2463
|
+
// is. One capture-size ring of bleed is past any shadow this backend draws.
|
|
2464
|
+
const bleed = Math.max(width, height, 1)
|
|
2465
|
+
const formId = pdf.reserve()
|
|
2466
|
+
ctx.forms.push({
|
|
2467
|
+
id: formId,
|
|
2468
|
+
body: drawing.join('\n'),
|
|
2469
|
+
dict: '/Type /XObject /Subtype /Form /FormType 1 ' +
|
|
2470
|
+
`/BBox [${num(-bleed)} ${num(-bleed)} ${num(width + bleed)} ${num(height + bleed)}] ` +
|
|
2471
|
+
'/Group << /Type /Group /S /Transparency /CS /DeviceRGB >> ' +
|
|
2472
|
+
`/Resources ${resourcesId} 0 R`,
|
|
2473
|
+
})
|
|
2474
|
+
drawingRef = ctx.res.xobject(formId, 'Fm')
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
const contentIds = sheets.map((sheet) => {
|
|
2478
|
+
const ops = ['q']
|
|
2479
|
+
if (sheet.clip) {
|
|
2480
|
+
ops.push(rectPath(sheet.clip[0], sheet.clip[1], sheet.clip[2], sheet.clip[3]))
|
|
2481
|
+
ops.push('W n')
|
|
2482
|
+
ops.push(...backgroundOps(sheet.clip[0], sheet.clip[1], sheet.clip[2], sheet.clip[3]))
|
|
2483
|
+
} else if (pageList) {
|
|
2484
|
+
ops.push(...backgroundOps(0, 0, sheet.size[0], sheet.size[1]))
|
|
2485
|
+
}
|
|
2486
|
+
// The natural-size default takes neither branch: it has no printable area,
|
|
2487
|
+
// the sheet IS the capture box, and its background is painted in SVD space
|
|
2488
|
+
// below so the operators stay what they have always been.
|
|
2489
|
+
// After this `cm` every coordinate is a CSS px in the SVD's own y-down space
|
|
2490
|
+
// with the origin at the capture root's top-left. Nothing below ever thinks
|
|
2491
|
+
// about PDF's y again — except text, which counter-flips its own matrix so
|
|
2492
|
+
// the glyphs stand up.
|
|
2493
|
+
ops.push(matrixOp(sheet.matrix))
|
|
2494
|
+
if (drawingRef) {
|
|
2495
|
+
ops.push(`/${drawingRef} Do`)
|
|
2496
|
+
} else {
|
|
2497
|
+
ops.push(...backgroundOps(0, 0, width, height))
|
|
2498
|
+
ops.push(...drawing)
|
|
2499
|
+
}
|
|
2500
|
+
ops.push('Q')
|
|
2501
|
+
const bodyText = ops.join('\n')
|
|
2502
|
+
return { id: null, bodyText }
|
|
2503
|
+
})
|
|
2504
|
+
|
|
2505
|
+
// Awaited outside the map: deflate is async and `map` would hand back promises.
|
|
2506
|
+
for (const entry of contentIds) {
|
|
2507
|
+
entry.id = pdf.addStream(streamDict, deflated ? await deflate(entry.bodyText) : entry.bodyText)
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// Forms are filled last: their ids were reserved while the tree was walked.
|
|
2511
|
+
for (const form of ctx.forms) {
|
|
2512
|
+
pdf.fillStream(form.id, `${form.dict}${deflated ? ' /Filter /FlateDecode' : ''}`,
|
|
2513
|
+
deflated ? await deflate(form.body) : form.body)
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
res.finalizeSymbols()
|
|
2517
|
+
pdf.fill(resourcesId, res.body())
|
|
2518
|
+
|
|
2519
|
+
const pageIds = sheets.map((sheet, i) => {
|
|
2520
|
+
const annots = []
|
|
2521
|
+
for (const link of ctx.links) {
|
|
2522
|
+
const [x0, y0, x1, y1] = transformBox(sheet.matrix,
|
|
2523
|
+
link.rect[0], link.rect[1], link.rect[2], link.rect[3])
|
|
2524
|
+
const box = sheet.clip
|
|
2525
|
+
? [Math.max(x0, sheet.clip[0]), Math.max(y0, sheet.clip[1]),
|
|
2526
|
+
Math.min(x1, sheet.clip[0] + sheet.clip[2]), Math.min(y1, sheet.clip[1] + sheet.clip[3])]
|
|
2527
|
+
: [x0, y0, x1, y1]
|
|
2528
|
+
// A run that this page's slice does not contain is not this page's link.
|
|
2529
|
+
if (!(box[2] > box[0]) || !(box[3] > box[1])) continue
|
|
2530
|
+
const uri = pdf.add(`<< /Type /Annot /Subtype /Link /Rect [${box.map(num).join(' ')}] ` +
|
|
2531
|
+
`/Border [0 0 0] /A << /Type /Action /S /URI /URI ${pdfString(link.href)} >> >>`)
|
|
2532
|
+
annots.push(`${uri} 0 R`)
|
|
2533
|
+
}
|
|
2534
|
+
if (annots.length) {
|
|
2535
|
+
ctx.note(null, `${annots.length} link annotation(s) on page ${i + 1}, from text runs carrying an href. A ` +
|
|
2536
|
+
'link wrapped around an image or a whole box has no text run to carry it and gets none.',
|
|
2537
|
+
'emit.pdf.text-links', 'A')
|
|
2538
|
+
}
|
|
2539
|
+
return pdf.add(
|
|
2540
|
+
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${num(sheet.size[0])} ${num(sheet.size[1])}] ` +
|
|
2541
|
+
`/Resources ${resourcesId} 0 R /Contents ${contentIds[i].id} 0 R ` +
|
|
2542
|
+
(annots.length ? `/Annots [${annots.join(' ')}] ` : '') +
|
|
2543
|
+
'/Group << /Type /Group /S /Transparency /CS /DeviceRGB >> >>'
|
|
2544
|
+
)
|
|
2545
|
+
})
|
|
2546
|
+
pdf.fill(pagesId, `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] ` +
|
|
2547
|
+
`/Count ${pageIds.length} >>`)
|
|
2548
|
+
|
|
2549
|
+
// Left uncompressed on purpose: `strings out.pdf` has to find them. The
|
|
2550
|
+
// trailer `createPdfDoc` writes carries /Size and /Root only, so there is no
|
|
2551
|
+
// /Info dictionary to hang provenance off — it rides in this stream's header
|
|
2552
|
+
// instead. (A /Info would need a change in `../writer/`.)
|
|
2553
|
+
const generator = isObject(doc.generator) ? doc.generator : {}
|
|
2554
|
+
const header = [
|
|
2555
|
+
`${generator.name || '@zumer/snapdom-vector'} ${generator.version || ''} · emit/pdf · ` +
|
|
2556
|
+
`${doc.schema || 'svd'}`,
|
|
2557
|
+
`${capture.selector || ''} ${capture.url || ''} ${capture.capturedAt || ''}`.trim(),
|
|
2558
|
+
`${diagnostics.length} approximation(s) — nothing here is silent.`,
|
|
2559
|
+
]
|
|
2560
|
+
const notesId = pdf.addStream('/Type /SnapdomDiagnostics',
|
|
2561
|
+
header.concat(diagnostics.map((d) =>
|
|
2562
|
+
`· ${d.severity} ${d.grade} ${d.code}${d.node ? ` [${d.node}]` : ''}: ${d.message}`)).join('\n'))
|
|
2563
|
+
|
|
2564
|
+
pdf.fill(catalogId, `<< /Type /Catalog /Pages ${pagesId} 0 R /SnapdomDiagnostics ${notesId} 0 R >>`)
|
|
2565
|
+
|
|
2566
|
+
const bytes = pdf.build(catalogId)
|
|
2567
|
+
Object.defineProperty(bytes, 'diagnostics', { value: diagnostics, enumerable: false })
|
|
2568
|
+
return bytes
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
export default svdToPdf
|