@zumer/snapdom-plugins 3.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +22 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. 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