@zumer/snapdom-plugins 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +16 -5
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,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
|
+
}
|