@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  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 +17 -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,200 @@
1
+ /**
2
+ * Front and back matter: a cover, a generated index, a closing page.
3
+ *
4
+ * These are the pages that are NOT the capture. A cover and a colophon are
5
+ * captured elements of your own — same engine, same fidelity, whatever CSS you
6
+ * put in them — placed one to a page. The index is the opposite: it is generated,
7
+ * because its content does not exist anywhere until pagination has happened, and
8
+ * it is drawn as REAL PDF TEXT so its links are live, its type is crisp at any
9
+ * zoom and it costs a few hundred bytes rather than another raster.
10
+ *
11
+ * The index is only possible at all because `nav.js` already had to find the
12
+ * headings and `index.js` already had to place them on pages. Nothing here reads
13
+ * the DOM: it is handed finished entries and lays them out.
14
+ *
15
+ * Front matter is deliberately OUTSIDE the page numbering. A cover that says
16
+ * "1 / 14" is a cover nobody would ship, and an index that counts itself makes
17
+ * every number in it disagree with the number printed on the page it names.
18
+ */
19
+
20
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
21
+
22
+ function rgb(colour) {
23
+ const m = HEX.exec(String(colour || '').trim())
24
+ if (!m) return null
25
+ let h = m[1]
26
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
27
+ const n = parseInt(h, 16)
28
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]
29
+ }
30
+
31
+ /**
32
+ * Content-stream ops that place a captured page image inside the page box:
33
+ * scaled to fit and centred, aspect preserved, margins ignored.
34
+ *
35
+ * Fit rather than fill, so nothing is ever cropped — a cover whose title is cut
36
+ * off is a worse failure than one with a band of paper down its side. Design the
37
+ * element at the page's aspect ratio and fit IS full bleed.
38
+ *
39
+ * @param {{name: string, pageW: number, pageH: number, aspect: number}} args
40
+ * `aspect` is height / width of the captured element.
41
+ */
42
+ export function fullPageOps({ name, pageW, pageH, aspect }) {
43
+ const ratio = aspect > 0 ? aspect : 1
44
+ let w = pageW
45
+ let h = w * ratio
46
+ if (h > pageH) { h = pageH; w = h / ratio }
47
+ return [
48
+ 'q',
49
+ `${w.toFixed(3)} 0 0 ${h.toFixed(3)} ${((pageW - w) / 2).toFixed(3)} ${((pageH - h) / 2).toFixed(3)} cm`,
50
+ `/${name} Do`,
51
+ 'Q',
52
+ ]
53
+ }
54
+
55
+ /**
56
+ * Normalise the `toc` option. `true` has to be the whole feature, because for
57
+ * most documents "put an index in" is the entire thought.
58
+ *
59
+ * @returns {{title: string, levels: number, size: number, font: string,
60
+ * indent: number, dots: boolean, colour: number[], leader: number[]}|null}
61
+ */
62
+ export function tocSpec(option) {
63
+ if (!option) return null
64
+ const o = option === true ? {} : (typeof option === 'string' ? { title: option } : option)
65
+ return {
66
+ title: o.title == null ? 'Contents' : String(o.title),
67
+ // Depth, not heading level: an index of a document that starts at h2 should
68
+ // still show two tiers, and depth is what the outline already nests by.
69
+ levels: Number.isFinite(o.levels) && o.levels > 0 ? Math.floor(o.levels) : 3,
70
+ size: Number.isFinite(o.size) && o.size > 0 ? o.size : 10,
71
+ font: o.font === 'serif' || o.font === 'mono' || o.font === 'sans'
72
+ ? o.font
73
+ // The document's own embedded face when the export has one; an explicit
74
+ // base-14 request keeps meaning exactly what it says.
75
+ : 'document',
76
+ indent: Number.isFinite(o.indent) ? o.indent : 14,
77
+ // The number as the index PRINTS it. Plain by default, which is the numeral
78
+ // the standard "3 / 12" page number carries — override it in step with
79
+ // `pageNumbers.format` if you renumber, or the two will disagree in print.
80
+ label: typeof o.label === 'function' ? o.label : (n) => String(n),
81
+ dots: o.dots !== false,
82
+ colour: rgb(o.color ?? o.colour) || [0.1, 0.11, 0.13],
83
+ leader: rgb(o.leaderColor ?? o.leaderColour) || [0.72, 0.74, 0.78],
84
+ }
85
+ }
86
+
87
+ /** Cut a title down until it fits, and say so with an ellipsis. */
88
+ export function fitText(text, room, measure) {
89
+ if (measure(text) <= room) return text
90
+ let lo = 0
91
+ let hi = text.length
92
+ while (lo < hi) {
93
+ const mid = Math.ceil((lo + hi) / 2)
94
+ if (measure(text.slice(0, mid) + '…') <= room) lo = mid
95
+ else hi = mid - 1
96
+ }
97
+ return lo > 0 ? text.slice(0, lo) + '…' : ''
98
+ }
99
+
100
+ /**
101
+ * Lay the index out into pages.
102
+ *
103
+ * @param {object} args
104
+ * @param {Array<{depth:number, title:string, dest:string, label:string}>} args.entries
105
+ * `dest` is a finished PDF destination array string and `label` the page number
106
+ * as it is PRINTED on the page it names — this file never computes either, so an
107
+ * index can never disagree with the page it points at.
108
+ * @param {(text: string, key: string, size: number) => ({str, font, width}|null)} args.pdfText
109
+ * @returns {Array<{ops: string[], annots: Array<{rect: number[], dest: string}>, fonts: Set, xobjects: Set}>}
110
+ * one entry per index page, in order.
111
+ */
112
+ export function tocLayout({ entries, spec, pdfText, drawTitle, marginX, availW, contentTop, availH }) {
113
+ const line = spec.size * 1.75
114
+ const headSize = spec.size * 1.8
115
+ const headRoom = spec.title.trim() ? headSize * 2.4 : 0
116
+
117
+ const pages = []
118
+ let page = null
119
+ let y = 0
120
+ let room = 0
121
+
122
+ const open = (first) => {
123
+ page = { ops: ['q'], annots: [], fonts: new Set(), xobjects: new Set() }
124
+ pages.push(page)
125
+ y = contentTop
126
+ room = availH
127
+ if (first && headRoom) {
128
+ const head = pdfText(spec.title, `${spec.font}-bold`, headSize)
129
+ if (head) {
130
+ page.fonts.add(head.font)
131
+ page.ops.push(`${spec.colour.map(c => c.toFixed(3)).join(' ')} rg`)
132
+ page.ops.push(`BT 0 Tr /${head.font.name} ${headSize.toFixed(3)} Tf 1 0 0 1 ` +
133
+ `${marginX.toFixed(3)} ${(y - headSize).toFixed(3)} Tm ${head.str} Tj ET`)
134
+ }
135
+ y -= headRoom
136
+ room -= headRoom
137
+ }
138
+ }
139
+ open(true)
140
+
141
+ for (const entry of entries) {
142
+ if (room < line) open(false)
143
+ const baseline = y - spec.size
144
+ const key = entry.depth === 0 ? `${spec.font}-bold` : spec.font
145
+ const label = pdfText(entry.label, spec.font, spec.size)
146
+ const labelW = label ? label.width : 0
147
+ const indent = entry.depth * spec.indent
148
+ const left = marginX + indent
149
+ // Room for the title is everything the number and one em of breathing space
150
+ // do not already claim.
151
+ const titleRoom = availW - indent - labelW - spec.size * 2
152
+
153
+ // How a title gets onto the page is the CALLER's problem, because the answer
154
+ // depends on the script: base-14 text where WinAnsi can address it, and a crop
155
+ // of the heading's own pixels where it cannot. This file owns the layout and
156
+ // knows nothing about rasters.
157
+ const drawn = titleRoom > 0
158
+ ? drawTitle({ entry, key, size: spec.size, x: left, baseline, room: titleRoom, colour: spec.colour })
159
+ : null
160
+ if (drawn) {
161
+ for (const f of drawn.fonts || []) page.fonts.add(f)
162
+ // A title drawn as a CROP of the page raster needs that raster in the index
163
+ // page's own resources — the fonts are not the only thing a line can borrow.
164
+ for (const x of drawn.xobjects || []) page.xobjects.add(x)
165
+ page.ops.push(...drawn.ops)
166
+ }
167
+ if (label) {
168
+ page.fonts.add(label.font)
169
+ page.ops.push(`BT 0 Tr /${label.font.name} ${spec.size} Tf 1 0 0 1 ` +
170
+ `${(marginX + availW - labelW).toFixed(3)} ${baseline.toFixed(3)} Tm ${label.str} Tj ET`)
171
+ }
172
+ // Leaders as a dashed rule rather than a run of full stops: a row of '.'
173
+ // glyphs is a row of glyphs an extractor returns as text, and an index whose
174
+ // copy-paste is "Introduction......3" is worse than one with no leader.
175
+ if (spec.dots && drawn && drawn.width > 0 && label) {
176
+ const from = left + drawn.width + spec.size * 0.6
177
+ const to = marginX + availW - labelW - spec.size * 0.6
178
+ if (to - from > spec.size) {
179
+ page.ops.push('q', `${spec.leader.map(c => c.toFixed(3)).join(' ')} RG`,
180
+ `0.7 w [0.7 ${(spec.size * 0.28).toFixed(2)}] 0 d 1 J`,
181
+ `${from.toFixed(3)} ${(baseline + spec.size * 0.25).toFixed(3)} m ` +
182
+ `${to.toFixed(3)} ${(baseline + spec.size * 0.25).toFixed(3)} l S`, 'Q')
183
+ }
184
+ }
185
+ // The whole line is the target, not just the words: an index nobody can hit
186
+ // without aiming is an index people scroll past.
187
+ if (entry.dest) {
188
+ page.annots.push({
189
+ rect: [marginX, baseline - spec.size * 0.3, marginX + availW, baseline + spec.size],
190
+ dest: entry.dest,
191
+ })
192
+ }
193
+
194
+ y -= line
195
+ room -= line
196
+ }
197
+
198
+ for (const p of pages) p.ops.push('Q')
199
+ return pages
200
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Page furniture: the header and footer bands, and the page number.
3
+ *
4
+ * The two are drawn by different means on purpose. A header or footer is a piece
5
+ * of your page, so it is CAPTURED — same engine, same fidelity, whatever CSS you
6
+ * put in it. A page number is one short string that changes on every page, so it
7
+ * is drawn as real PDF text: crisp at any zoom, a few bytes, and selectable and
8
+ * searchable like the rest of the layer.
9
+ *
10
+ * Element bands are captured once before pagination. Their authoritative height
11
+ * comes from the final artifact's aspect ratio, so effects and render hooks cannot
12
+ * make the reserved strip disagree with what is drawn.
13
+ */
14
+ import { snapdom } from '@zumer/snapdom'
15
+
16
+ const positive = (value, fallback) => Number.isFinite(value) && value > 0 ? value : fallback
17
+ const nonNegative = (value, fallback) => Number.isFinite(value) && value >= 0 ? value : fallback
18
+
19
+ /**
20
+ * Cheap standalone estimate from an element's current box. The PDF exporter does
21
+ * not use this for pagination; captured artifact geometry is authoritative there.
22
+ *
23
+ * @param {Element|null} el
24
+ * @param {number} availW printable width in points
25
+ * @returns {number} height in points, 0 when there is no band
26
+ */
27
+ export function bandHeight(el, availW) {
28
+ if (!el || typeof el.getBoundingClientRect !== 'function' ||
29
+ !Number.isFinite(availW) || availW <= 0) return 0
30
+ const r = el.getBoundingClientRect()
31
+ if (!r.width || !r.height) return 0
32
+ return r.height * (availW / r.width)
33
+ }
34
+
35
+ /**
36
+ * Capture a band and hand back what `addStream` needs. Its height is derived from
37
+ * the FINAL artifact, not measured again from the live element: index.js captures
38
+ * bands before pageBox, and render hooks may change the artifact's aspect.
39
+ *
40
+ * The band rides the SAME scale and dpr as the page it sits on, so a header never
41
+ * looks softer or sharper than the content under it.
42
+ *
43
+ * @param {Element} el
44
+ * @param {{scale: number, dpr: number, quality: number,
45
+ * backgroundColor: string|null, availW: number}} opts
46
+ */
47
+ export async function captureBand(el, options, encodeImage) {
48
+ const img = await captureElement(el, options, encodeImage)
49
+ const availW = positive(options.availW, 0)
50
+ const aspect = positive(img.aspect, 0)
51
+ return { ...img, heightPt: availW * aspect }
52
+ }
53
+
54
+ /**
55
+ * Capture any element as an image XObject, plus its own aspect ratio — which is
56
+ * all a cover, a closing page or an element watermark needs, none of which is
57
+ * bound to the printable width the way a band is.
58
+ *
59
+ * @returns {Promise<object>} what `addStream` needs, plus `aspect` (height/width).
60
+ */
61
+ export async function captureElement(el, {
62
+ scale, dpr, quality, backgroundColor, codec, captureOptions = {}, image = true,
63
+ }, encodeImage) {
64
+ if (!el || el.nodeType !== 1) throw new TypeError('captureElement: expected an Element')
65
+ if (image && typeof encodeImage !== 'function') throw new TypeError('captureElement: encodeImage must be a function')
66
+ const rasterScale = positive(scale, 1)
67
+ const rasterDpr = positive(dpr, 1)
68
+ const source = {
69
+ baseURL: String(el.ownerDocument?.baseURI || document.baseURI),
70
+ documentURL: String(el.ownerDocument?.location?.href || el.ownerDocument?.baseURI || location.href),
71
+ documentLang: String(el.ownerDocument?.documentElement?.lang || ''),
72
+ }
73
+ const result = await snapdom(el, {
74
+ ...captureOptions,
75
+ outerShadows: captureOptions.outerShadows ?? false,
76
+ outerTransforms: captureOptions.outerTransforms ?? true,
77
+ scale: rasterScale, dpr: rasterDpr,
78
+ ...(backgroundColor ? { backgroundColor } : {}),
79
+ })
80
+ const meta = result.meta
81
+ if (!meta || !Number.isFinite(meta.vbW) || !Number.isFinite(meta.vbH)) {
82
+ throw new Error('captureElement: snapdom did not expose final render geometry')
83
+ }
84
+
85
+ const base = {
86
+ url: result.url,
87
+ meta,
88
+ vbW: meta.vbW,
89
+ vbH: meta.vbH,
90
+ offX: meta.contentX,
91
+ offY: meta.contentY,
92
+ aspect: meta.vbW > 0 ? meta.vbH / meta.vbW : 1,
93
+ source,
94
+ }
95
+ if (!image) return base
96
+
97
+ const canvas = await result.toCanvas({ backgroundColor: null })
98
+ const img = await encodeImage(canvas, { quality, background: backgroundColor, codec })
99
+
100
+ // Geometry is the immutable render contract returned by SnapDOM, so asymmetric
101
+ // bleed and transformed roots need no canvas-size heuristic here.
102
+ return {
103
+ ...img,
104
+ ...base,
105
+ }
106
+ }
107
+
108
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
109
+
110
+ /** `rg` wants three 0–1 components; anything unparseable stays the caller's grey. */
111
+ function rgb(colour) {
112
+ const m = HEX.exec(String(colour || '').trim())
113
+ if (!m) return null
114
+ let h = m[1]
115
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
116
+ const n = parseInt(h, 16)
117
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]
118
+ }
119
+
120
+ /**
121
+ * Normalise the `pageNumbers` option. `true` is the whole feature for most
122
+ * callers, so it has to mean something complete on its own.
123
+ */
124
+ export function numberSpec(option) {
125
+ if (!option) return null
126
+ const o = option === true ? {} : option
127
+ return {
128
+ format: typeof o.format === 'function' ? o.format : (page, pages) => `${page} / ${pages}`,
129
+ align: o.align === 'left' || o.align === 'right' ? o.align : 'center',
130
+ size: positive(o.size, 9),
131
+ font: o.font === 'serif' || o.font === 'mono' || o.font === 'sans'
132
+ ? o.font
133
+ // The document's own embedded face when the export has one; an explicit
134
+ // base-14 request keeps meaning exactly what it says.
135
+ : 'document',
136
+ colour: rgb(o.color ?? o.colour) || [0.36, 0.39, 0.47],
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Content-stream ops for one page's number, drawn in the bottom margin — below a
142
+ * footer band if there is one, so the two never fight over the same strip.
143
+ *
144
+ * @returns {{ops: string[], font: object}|null} null when the string is empty or
145
+ * unrepresentable, which is the caller's cue not to add a font resource.
146
+ */
147
+ export function numberOps({ spec, page, pages, pageW, marginX, marginY, availW, pdfText }) {
148
+ const str = String(spec.format(page, pages) ?? '')
149
+ if (!str.trim()) return null
150
+ const drawn = pdfText(str, spec.font, spec.size)
151
+ if (!drawn) return null
152
+ const { str: encoded, font, width } = drawn
153
+
154
+ const x = spec.align === 'left' ? marginX
155
+ : spec.align === 'right' ? marginX + availW - width
156
+ : marginX + (availW - width) / 2
157
+ // Sat in the middle of the bottom margin, on its own baseline.
158
+ const y = Math.max(2, marginY / 2 - spec.size * 0.35)
159
+
160
+ return {
161
+ font,
162
+ ops: [
163
+ 'q',
164
+ `${spec.colour.map(c => c.toFixed(3)).join(' ')} rg`,
165
+ // `0 Tr` is not redundant: the render mode is graphics state and survives ET,
166
+ // so the invisible layer drawn above would otherwise make this ghost too —
167
+ // present in the file, extractable, and painted by nobody.
168
+ `BT 0 Tr /${font.name} ${spec.size} Tf 1 0 0 1 ${x.toFixed(3)} ${y.toFixed(3)} Tm ${encoded} Tj ET`,
169
+ 'Q',
170
+ ],
171
+ }
172
+ }
173
+
174
+
175
+ /**
176
+ * Normalise a header or footer into one of two shapes.
177
+ *
178
+ * A string or an object of segments becomes REAL PDF TEXT — selectable, searchable
179
+ * and crisp at any zoom, which is what a running head almost always wants. An
180
+ * Element is captured instead, for the cases text cannot express: a logo, a rule in
181
+ * brand colours, a signature block.
182
+ *
183
+ * @returns {{kind:'text', …}|{kind:'element', el:Element}|null}
184
+ */
185
+ export function bandSpec(band) {
186
+ if (!band) return null
187
+ if (typeof band === 'object' && band.nodeType === 1) return { kind: 'element', el: band }
188
+ if (typeof band !== 'string' && typeof band !== 'function' && typeof band !== 'object') return null
189
+
190
+ const o = typeof band === 'string' || typeof band === 'function' ? { center: band } : band
191
+ const seg = (v) => (typeof v === 'function' ? v : v == null ? null : () => String(v))
192
+ const spec = {
193
+ kind: 'text',
194
+ left: seg(o.left), center: seg(o.center), right: seg(o.right),
195
+ size: positive(o.size, 9),
196
+ font: o.font === 'serif' || o.font === 'mono' || o.font === 'sans'
197
+ ? o.font
198
+ // The document's own embedded face when the export has one; an explicit
199
+ // base-14 request keeps meaning exactly what it says.
200
+ : 'document',
201
+ colour: rgb(o.color ?? o.colour) || [0.36, 0.39, 0.47],
202
+ rule: o.rule !== false,
203
+ gap: nonNegative(o.gap, 10),
204
+ }
205
+ return spec.left || spec.center || spec.right ? spec : null
206
+ }
207
+
208
+ /** How much of the page a text band takes, in points. */
209
+ export function textBandHeight(spec) {
210
+ return positive(spec && spec.size, 9) * 1.4 + nonNegative(spec && spec.gap, 10) + (spec && spec.rule ? 1 : 0)
211
+ }
212
+
213
+ /**
214
+ * Content-stream ops for a text band. The three segments are independent, so a
215
+ * left-aligned title and a right-aligned date never collide the way one centred
216
+ * string with padding would.
217
+ */
218
+ export function textBandOps({ spec, where, page, pages, pageH, marginX, marginY, availW, pdfText }) {
219
+ const height = textBandHeight(spec)
220
+ // The band occupies a strip against its margin; the rule always sits on the
221
+ // CONTENT side of the text, with the gap between them. Getting this the same way
222
+ // round for both put the footer's rule straight through its own words.
223
+ const top = where === 'header' ? pageH - marginY : marginY + height
224
+ const baseline = where === 'header'
225
+ ? top - spec.size
226
+ : top - spec.gap - spec.size * 0.8
227
+ const ruleY = where === 'header' ? top - height + spec.gap : top - 0.5
228
+
229
+ const ops = ['q', `${spec.colour.map(c => c.toFixed(3)).join(' ')} rg`]
230
+ const fonts = new Set()
231
+ for (const [key, align] of [['left', 'left'], ['center', 'center'], ['right', 'right']]) {
232
+ if (!spec[key]) continue
233
+ const str = String(spec[key](page, pages) ?? '')
234
+ if (!str.trim()) continue
235
+ const drawn = pdfText(str, spec.font, spec.size)
236
+ if (!drawn) continue
237
+ const { str: encoded, font, width } = drawn
238
+ const x = align === 'left' ? marginX
239
+ : align === 'right' ? marginX + availW - width
240
+ : marginX + (availW - width) / 2
241
+ // `0 Tr` explicitly: render mode is graphics state and survives ET, so the
242
+ // invisible layer drawn earlier would otherwise make this a ghost.
243
+ ops.push(`BT 0 Tr /${font.name} ${spec.size} Tf 1 0 0 1 ${x.toFixed(3)} ${baseline.toFixed(3)} Tm ${encoded} Tj ET`)
244
+ fonts.add(font)
245
+ }
246
+ if (!fonts.size) return null
247
+
248
+ if (spec.rule) {
249
+ ops.push(`${spec.colour.map(c => (c * 0.35 + 0.65).toFixed(3)).join(' ')} RG`,
250
+ '0.5 w', `${marginX} ${ruleY.toFixed(3)} m ${(marginX + availW).toFixed(3)} ${ruleY.toFixed(3)} l S`)
251
+ }
252
+ ops.push('Q')
253
+ return { ops, fonts: [...fonts] }
254
+ }
255
+
256
+ // ——— watermark ———
257
+ //
258
+ // The one piece of furniture that goes OVER the content rather than beside it,
259
+ // which is the whole point: a watermark under the page image would be invisible
260
+ // on any page with a background, and a watermark that can be covered is not one.
261
+
262
+ const clamp01 = (n, fallback) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback)
263
+
264
+ /**
265
+ * Normalise the `watermark` option. A bare string is the common case and has to
266
+ * mean something complete: grey, translucent, across the diagonal, sized to the
267
+ * page.
268
+ *
269
+ * @returns {{kind:'text'|'element', …}|null}
270
+ */
271
+ export function watermarkSpec(option) {
272
+ if (!option) return null
273
+ if (typeof option === 'object' && option.nodeType === 1) {
274
+ return { kind: 'element', el: option, opacity: 0.12, angle: 0, fit: 0.6 }
275
+ }
276
+ const o = typeof option === 'string' ? { text: option } : (option || {})
277
+ if (o.element && o.element.nodeType === 1) {
278
+ return {
279
+ kind: 'element', el: o.element,
280
+ opacity: clamp01(o.opacity, 0.12),
281
+ angle: Number.isFinite(o.angle) ? o.angle : 0,
282
+ fit: clamp01(o.fit, 0.6) || 0.6,
283
+ }
284
+ }
285
+ const text = o.text == null ? '' : String(o.text)
286
+ if (!text.trim()) return null
287
+ return {
288
+ kind: 'text', text,
289
+ // 0 means "as wide as the page will take", which is what a watermark almost
290
+ // always wants and what nobody wants to compute by hand for every page size.
291
+ size: Number.isFinite(o.size) && o.size > 0 ? o.size : 0,
292
+ angle: Number.isFinite(o.angle) ? o.angle : 45,
293
+ opacity: clamp01(o.opacity, 0.12),
294
+ colour: rgb(o.color ?? o.colour) || [0.45, 0.45, 0.5],
295
+ font: o.font === 'serif' || o.font === 'mono' || o.font === 'sans'
296
+ ? o.font
297
+ // The document's own embedded face when the export has one; an explicit
298
+ // base-14 request keeps meaning exactly what it says.
299
+ : 'document',
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Content-stream ops for the watermark, centred on the whole page — the MEDIA
305
+ * box, not the printable area: a watermark inside the margins reads as content.
306
+ *
307
+ * @param {object} args
308
+ * @param {string} args.gs name of the ExtGState carrying the opacity
309
+ * @param {string} [args.image] name of the XObject, for an element watermark
310
+ * @param {(text: string, key: string, size: number) => ({str, font, width}|null)} [args.pdfText]
311
+ * @returns {{ops: string[], font: object|null}|null}
312
+ */
313
+ export function watermarkOps({ spec, pageW, pageH, gs, image, imageAspect, pdfText }) {
314
+ const cx = pageW / 2
315
+ const cy = pageH / 2
316
+ const rad = (spec.angle * Math.PI) / 180
317
+ const cos = Math.cos(rad)
318
+ const sin = Math.sin(rad)
319
+
320
+ if (spec.kind === 'element') {
321
+ if (!image) return null
322
+ // Fit inside the page at `fit` of its smaller dimension, aspect preserved, so
323
+ // a wide stamp on a portrait page is not squashed to fit the width.
324
+ const w = Math.min(pageW, pageH / (imageAspect || 1)) * spec.fit
325
+ const h = w * (imageAspect || 1)
326
+ // A form XObject's unit square maps through `cm`, so the rotation has to be
327
+ // composed with the scale by hand rather than left to a text matrix.
328
+ return {
329
+ font: null,
330
+ ops: [
331
+ 'q', `/${gs} gs`,
332
+ `1 0 0 1 ${cx.toFixed(3)} ${cy.toFixed(3)} cm`,
333
+ `${cos.toFixed(5)} ${sin.toFixed(5)} ${(-sin).toFixed(5)} ${cos.toFixed(5)} 0 0 cm`,
334
+ `${w.toFixed(3)} 0 0 ${h.toFixed(3)} ${(-w / 2).toFixed(3)} ${(-h / 2).toFixed(3)} cm`,
335
+ `/${image} Do`, 'Q',
336
+ ],
337
+ }
338
+ }
339
+
340
+ // Auto size: the largest the string can be with its ROTATED extent still inside
341
+ // the page, which is what makes one option work on A4, on a receipt roll and on
342
+ // a landscape slide alike.
343
+ //
344
+ // Not the page's diagonal LENGTH — that is the trap. A 486 × 1938 page has a
345
+ // long diagonal and almost no width, and a string sized to that diagonal but
346
+ // laid at 45° runs off both sides: a rotated string of advance W spans W·|cos θ|
347
+ // across and W·|sin θ| down, so each axis caps W on its own and the smaller cap
348
+ // is the one that decides.
349
+ const unit = pdfText(spec.text, spec.font, 1)
350
+ if (!unit) return null
351
+ let size = spec.size
352
+ if (!size) {
353
+ const acrossX = Math.abs(cos) > 1e-6 ? (pageW * 0.9) / Math.abs(cos) : Infinity
354
+ const acrossY = Math.abs(sin) > 1e-6 ? (pageH * 0.9) / Math.abs(sin) : Infinity
355
+ const room = Math.min(acrossX, acrossY)
356
+ size = unit.width > 0 && Number.isFinite(room) ? room / unit.width : 24
357
+ }
358
+ const drawn = pdfText(spec.text, spec.font, size)
359
+ if (!drawn) return null
360
+ const width = drawn.width
361
+
362
+ // Centre the STRING on the page centre: back off half an advance along the
363
+ // rotated baseline, and about a third of a size along the rotated up-vector,
364
+ // which puts the x-height band on the centre rather than the baseline.
365
+ const x = cx - (width / 2) * cos + 0.35 * size * sin
366
+ const y = cy - (width / 2) * sin - 0.35 * size * cos
367
+
368
+ return {
369
+ font: drawn.font,
370
+ ops: [
371
+ 'q', `/${gs} gs`,
372
+ `${spec.colour.map(c => c.toFixed(3)).join(' ')} rg`,
373
+ // `0 Tr` explicitly — render mode survives ET, and the invisible text layer
374
+ // drawn before this would otherwise make the watermark a ghost too.
375
+ `BT 0 Tr /${drawn.font.name} ${size.toFixed(3)} Tf ` +
376
+ `${cos.toFixed(5)} ${sin.toFixed(5)} ${(-sin).toFixed(5)} ${cos.toFixed(5)} ` +
377
+ `${x.toFixed(3)} ${y.toFixed(3)} Tm ${drawn.str} Tj ET`,
378
+ 'Q',
379
+ ],
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Content-stream ops that place a captured band across the printable width.
385
+ *
386
+ * @param {'header'|'footer'} where
387
+ */
388
+ export function bandOps({ where, name, heightPt, pageH, marginX, marginY, availW }) {
389
+ const bottom = where === 'header'
390
+ ? pageH - marginY - heightPt
391
+ : marginY
392
+ return [
393
+ 'q',
394
+ `${availW.toFixed(3)} 0 0 ${heightPt.toFixed(3)} ${marginX.toFixed(3)} ${bottom.toFixed(3)} cm`,
395
+ `/${name} Do`,
396
+ 'Q',
397
+ ]
398
+ }