@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.
- package/CHANGELOG.md +21 -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 +22 -6
- 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
package/pdf/src/index.js
ADDED
|
@@ -0,0 +1,2415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnapDOM — PDF export: snapdom's raster plus an invisible text layer.
|
|
3
|
+
*
|
|
4
|
+
* The page image is snapdom's own capture, and over it goes an INVISIBLE text
|
|
5
|
+
* layer (Tr 3) positioned from snapdom's final serialized clone, which makes the file
|
|
6
|
+
* selectable, searchable and copy-pasteable, plus real link annotations.
|
|
7
|
+
*
|
|
8
|
+
* The raster is the path because fidelity is not approximated here: the pixels
|
|
9
|
+
* ARE snapdom's, so nothing in the file can disagree with what was on screen —
|
|
10
|
+
* nothing is re-drawn, no CSS is re-modelled, no font is substituted and no
|
|
11
|
+
* layout is recomputed. The text layer buys back everything a picture loses,
|
|
12
|
+
* without touching a pixel of it.
|
|
13
|
+
*
|
|
14
|
+
* This is a snapdom PLUGIN, so a PDF is one more thing a capture can be:
|
|
15
|
+
*
|
|
16
|
+
* snapdom.plugins(pdf())
|
|
17
|
+
* const shot = await snapdom(el)
|
|
18
|
+
* await shot.toPdf({ page: 'a4' }) // or shot.to('pdf', …)
|
|
19
|
+
*
|
|
20
|
+
* The split between `pdf(…)` and `toPdf(…)` is the engine's own split, not a
|
|
21
|
+
* quirk: the final capture artifact is MEASURED lazily and the page is DRAWN at export.
|
|
22
|
+
* Anything that changes what gets measured belongs in `pdf(…)`; anything about
|
|
23
|
+
* the paper belongs in `toPdf(…)`. Passing a capture-time option at export time
|
|
24
|
+
* is reported rather than silently ignored.
|
|
25
|
+
*
|
|
26
|
+
* What is about the PAGE rather than the paint is kept out of the drawing code:
|
|
27
|
+
* the coordinate contract (`PT`, the element's border box as content origin),
|
|
28
|
+
* `pageBox`, and `collectBlocks` + `paginate`, which read that isolated clone and know
|
|
29
|
+
* nothing about how the page is painted.
|
|
30
|
+
*
|
|
31
|
+
* Requires the SnapDOM plugin contract that exposes immutable capture metadata,
|
|
32
|
+
* raw export options and pre-decode canvas crops (SnapDOM v3).
|
|
33
|
+
*/
|
|
34
|
+
// `src/writer/` is the byte-level half, and it is this product's own source. It
|
|
35
|
+
// was a package shared with a sibling product once, which is why it still knows
|
|
36
|
+
// nothing about text layers or page models and why `setWarnPrefix` exists — that
|
|
37
|
+
// is what makes a warning raised down there say `[snapdom-pdf]` to the person who
|
|
38
|
+
// bought this. Nothing of it reaches the buyer as a separate file; it is bundled.
|
|
39
|
+
import {
|
|
40
|
+
createPdfDoc, pdfString, pdfTextString, BASE14,
|
|
41
|
+
deflate, canDeflate, encodeImage, createUnicodeFont, setWarnPrefix,
|
|
42
|
+
writeOutlines, writeNameTree, writeStructTree,
|
|
43
|
+
} from './writer/index.js'
|
|
44
|
+
import { collectTextLayer, measureBase14 } from './text-layer.js'
|
|
45
|
+
import { collectNav, outlineTree, fragmentOf } from './nav.js'
|
|
46
|
+
import { buildStructure } from './tagged.js'
|
|
47
|
+
import { createEmbeddedFont } from './writer/font.js'
|
|
48
|
+
import { metaSpec, infoDict, xmpPacket } from './mega/meta.js'
|
|
49
|
+
import { collectFaceRules, resolveFaces } from './mega/fonts.js'
|
|
50
|
+
import { buildReport, reportSpec } from './mega/diag.js'
|
|
51
|
+
import { flowSpec } from './mega/flow.js'
|
|
52
|
+
import { pageLabels } from './mega/labels.js'
|
|
53
|
+
import { planFields, createFieldWriter, wantsCropAP } from './mega/forms.js'
|
|
54
|
+
import { collectBlocks, collectRepeats, paginate, headerReserve, repeatsAt } from './paginate.js'
|
|
55
|
+
import {
|
|
56
|
+
captureBand, captureElement, numberSpec, numberOps, bandOps,
|
|
57
|
+
bandSpec, textBandHeight, textBandOps, watermarkSpec, watermarkOps,
|
|
58
|
+
} from './furniture.js'
|
|
59
|
+
import { fullPageOps, tocSpec, tocLayout, fitText } from './frontmatter.js'
|
|
60
|
+
|
|
61
|
+
// Module-level, and safe because each product is bundled on its own: the writer
|
|
62
|
+
// inlined here is this build's private copy, so a page running both plugins has
|
|
63
|
+
// two of them and neither can rename the other's warnings. In the workspace,
|
|
64
|
+
// where Node resolves one shared module, the last plugin loaded wins the prefix —
|
|
65
|
+
// that is a harness cosmetic and it is the only place it can happen.
|
|
66
|
+
setWarnPrefix('[snapdom-pdf]')
|
|
67
|
+
|
|
68
|
+
/** CSS px → PDF points (96dpi → 72dpi), so printed size matches on-screen size. */
|
|
69
|
+
const PT = 0.75
|
|
70
|
+
|
|
71
|
+
/** Browser bitmap ceilings mirrored from SnapDOM's canvas exporter. */
|
|
72
|
+
const MAX_RASTER_SIDE = 16384
|
|
73
|
+
const MAX_RASTER_AREA = MAX_RASTER_SIDE * MAX_RASTER_SIDE
|
|
74
|
+
|
|
75
|
+
/** Points, portrait. ISO A and B, plus the North American sizes people actually ask for. */
|
|
76
|
+
const PAGE_SIZES = {
|
|
77
|
+
a3: [841.89, 1190.55],
|
|
78
|
+
a4: [595.28, 841.89],
|
|
79
|
+
a5: [419.53, 595.28],
|
|
80
|
+
a6: [297.64, 419.53],
|
|
81
|
+
b4: [708.66, 1000.63],
|
|
82
|
+
b5: [498.9, 708.66],
|
|
83
|
+
letter: [612, 792],
|
|
84
|
+
legal: [612, 1008],
|
|
85
|
+
tabloid: [792, 1224],
|
|
86
|
+
executive: [521.86, 756],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Options that decide what gets MEASURED, so they are read when the capture runs
|
|
91
|
+
* and cannot be changed at export time. Naming them lets the exporter say so
|
|
92
|
+
* instead of accepting them and quietly doing something else.
|
|
93
|
+
*/
|
|
94
|
+
const CAPTURE_TIME = ['formValues', 'shadow', 'breakAvoid', 'outline', 'forms']
|
|
95
|
+
|
|
96
|
+
const warn = (msg) => console.warn(`[snapdom-pdf] ${msg}`)
|
|
97
|
+
|
|
98
|
+
/** Snapshot declarative option containers while preserving identity-bearing DOM
|
|
99
|
+
* Elements and callbacks. Defaults are read lazily, so freezing only their top
|
|
100
|
+
* level would still let `page`, `toc`, bands or watermarks change after pdf(). */
|
|
101
|
+
function snapshotOptions(value, seen = new WeakMap()) {
|
|
102
|
+
if (!value || typeof value !== 'object') return value
|
|
103
|
+
const proto = Object.getPrototypeOf(value)
|
|
104
|
+
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value
|
|
105
|
+
if (seen.has(value)) return seen.get(value)
|
|
106
|
+
const copy = Array.isArray(value) ? [] : Object.create(proto)
|
|
107
|
+
seen.set(value, copy)
|
|
108
|
+
for (const key of Object.keys(value)) copy[key] = snapshotOptions(value[key], seen)
|
|
109
|
+
return Object.freeze(copy)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `page` is a keyword, `'fit'`, or a `[width, height]` pair in points — the escape
|
|
114
|
+
* hatch for the sizes nobody standardised, like a receipt roll or a card.
|
|
115
|
+
*/
|
|
116
|
+
function pageSize(page, report) {
|
|
117
|
+
if (Array.isArray(page)) {
|
|
118
|
+
const [w, h] = page
|
|
119
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return [w, h]
|
|
120
|
+
report(`page [${page}] is not a usable size in points — falling back to a4.`)
|
|
121
|
+
return PAGE_SIZES.a4
|
|
122
|
+
}
|
|
123
|
+
if (PAGE_SIZES[page]) return PAGE_SIZES[page]
|
|
124
|
+
report(`unknown page size "${page}" — falling back to a4.`)
|
|
125
|
+
return PAGE_SIZES.a4
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where the element's border box sits inside the capture's viewBox, in CSS px.
|
|
130
|
+
*
|
|
131
|
+
* snapdom sizes its raster from a viewBox that can be larger than the element —
|
|
132
|
+
* blur bleed, a rotated bbox, the anti-shaving pad. v3 publishes the exact
|
|
133
|
+
* logical origin; export validation requires those fields before any work.
|
|
134
|
+
*/
|
|
135
|
+
function bleedOffset(meta) {
|
|
136
|
+
return {
|
|
137
|
+
offX: meta.contentX,
|
|
138
|
+
offY: meta.contentY,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A diagnostic for roots whose own transform can make a text layer surprising.
|
|
144
|
+
* about a corner grows its bounding box on one side only, so half the difference
|
|
145
|
+
* is not where the element starts. Exact placement comes from `meta.contentX/Y`;
|
|
146
|
+
* this warning remains useful when inspecting the isolated final artifact.
|
|
147
|
+
*/
|
|
148
|
+
function offCentreTurn(element, rect) {
|
|
149
|
+
const style = getComputedStyle(element)
|
|
150
|
+
const turned = (style.transform && style.transform !== 'none') ||
|
|
151
|
+
(style.rotate && style.rotate !== 'none') ||
|
|
152
|
+
(style.scale && style.scale !== 'none')
|
|
153
|
+
if (!turned) return false
|
|
154
|
+
const [ox, oy] = String(style.transformOrigin || '').split(' ').map(parseFloat)
|
|
155
|
+
if (!Number.isFinite(ox) || !Number.isFinite(oy)) return false
|
|
156
|
+
return Math.abs(ox - rect.width / 2) > 0.5 || Math.abs(oy - rect.height / 2) > 0.5
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The page box, in points, and the factor that maps content space onto it.
|
|
161
|
+
*
|
|
162
|
+
* Kept apart from the drawing because it is about the PAPER: what an A4 page is
|
|
163
|
+
* and where its margins fall does not depend on how the page gets painted. The
|
|
164
|
+
* one thing a caller can get wrong here — a `page` keyword nothing knows — is
|
|
165
|
+
* reported rather than silently taken as A4.
|
|
166
|
+
*
|
|
167
|
+
* @returns {{pageW:number, pageH:number, marginX:number, marginY:number,
|
|
168
|
+
* drawScale:number, availW:number, availH:number, k:number, scaledH:number}}
|
|
169
|
+
* `k` is CSS px → page points including the fit scale; `scaledH` is the whole
|
|
170
|
+
* content height in page points, which is what `paginate` cuts.
|
|
171
|
+
*/
|
|
172
|
+
function pageBox({ page, orientation, margin, contentW, contentH, headerH = 0, footerH = 0, report }) {
|
|
173
|
+
if (![contentW, contentH, margin, headerH, footerH].every(Number.isFinite) ||
|
|
174
|
+
contentW <= 0 || contentH <= 0 || margin < 0 || headerH < 0 || footerH < 0) {
|
|
175
|
+
throw new RangeError('[snapdom-pdf] page geometry must use finite positive dimensions and non-negative margins')
|
|
176
|
+
}
|
|
177
|
+
let pageW, pageH, marginX, marginY, drawScale
|
|
178
|
+
if (page === 'fit') {
|
|
179
|
+
pageW = contentW
|
|
180
|
+
pageH = contentH
|
|
181
|
+
marginX = marginY = 0
|
|
182
|
+
drawScale = 1
|
|
183
|
+
} else {
|
|
184
|
+
const size = pageSize(page, report)
|
|
185
|
+
;[pageW, pageH] = orientation === 'landscape' ? [size[1], size[0]] : size
|
|
186
|
+
marginX = marginY = margin
|
|
187
|
+
drawScale = (pageW - marginX * 2) / contentW
|
|
188
|
+
}
|
|
189
|
+
const availW = pageW - marginX * 2
|
|
190
|
+
const availH = pageH - marginY * 2 - headerH - footerH
|
|
191
|
+
if (![pageW, pageH, availW, availH, drawScale].every(Number.isFinite) ||
|
|
192
|
+
pageW <= 0 || pageH <= 0 || availW <= 0 || availH <= 0 || drawScale <= 0) {
|
|
193
|
+
throw new RangeError('[snapdom-pdf] margins and page furniture leave no positive printable area')
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
pageW, pageH, marginX, marginY, drawScale,
|
|
197
|
+
availW,
|
|
198
|
+
// A header and a footer are not decoration over the page, they take room from
|
|
199
|
+
// it — so the cut height shrinks and the content starts lower.
|
|
200
|
+
availH,
|
|
201
|
+
contentTop: pageH - marginY - headerH,
|
|
202
|
+
k: PT * drawScale,
|
|
203
|
+
scaledH: contentH * drawScale,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Reject impossible work before mounting the snapshot, allocating a canvas or
|
|
208
|
+
* running an image codec. Unknown named paper still follows pageSize's documented
|
|
209
|
+
* a4 fallback, but malformed numeric geometry is never guessed. */
|
|
210
|
+
function validateExport(options, meta) {
|
|
211
|
+
for (const key of ['w0', 'h0', 'vbW', 'vbH', 'targetW', 'targetH', 'contentX', 'contentY']) {
|
|
212
|
+
if (!Number.isFinite(meta?.[key])) {
|
|
213
|
+
throw new RangeError(`[snapdom-pdf] snapdom capture metadata has no finite ${key}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (meta.w0 <= 0 || meta.h0 <= 0 || meta.vbW <= 0 || meta.vbH <= 0 ||
|
|
217
|
+
meta.targetW <= 0 || meta.targetH <= 0) {
|
|
218
|
+
throw new RangeError('[snapdom-pdf] snapdom capture dimensions must be positive')
|
|
219
|
+
}
|
|
220
|
+
if (!Number.isFinite(options.scale) || options.scale <= 0 ||
|
|
221
|
+
!Number.isFinite(options.dpr) || options.dpr <= 0) {
|
|
222
|
+
throw new RangeError('[snapdom-pdf] capture scale and dpr must be finite positive numbers')
|
|
223
|
+
}
|
|
224
|
+
const margin = options.margin ?? 24
|
|
225
|
+
if (!Number.isFinite(margin) || margin < 0) {
|
|
226
|
+
throw new RangeError('[snapdom-pdf] margin must be a finite non-negative number of points')
|
|
227
|
+
}
|
|
228
|
+
const orientation = options.orientation ?? 'portrait'
|
|
229
|
+
if (orientation !== 'portrait' && orientation !== 'landscape') {
|
|
230
|
+
throw new TypeError('[snapdom-pdf] orientation must be "portrait" or "landscape"')
|
|
231
|
+
}
|
|
232
|
+
const page = options.page ?? 'fit'
|
|
233
|
+
if (Array.isArray(page)) {
|
|
234
|
+
if (page.length !== 2 || !page.every(n => Number.isFinite(n) && n > 0)) {
|
|
235
|
+
throw new RangeError('[snapdom-pdf] a custom page must be [positiveWidth, positiveHeight] in points')
|
|
236
|
+
}
|
|
237
|
+
} else if (typeof page !== 'string') {
|
|
238
|
+
throw new TypeError('[snapdom-pdf] page must be a paper name, "fit", or [width, height]')
|
|
239
|
+
}
|
|
240
|
+
if (page !== 'fit') {
|
|
241
|
+
// Silent here on purpose: `pageBox` reports the a4 fallback when it takes it,
|
|
242
|
+
// and reporting from both put the same sentence in the console twice.
|
|
243
|
+
const size = pageSize(page, () => {})
|
|
244
|
+
const [w, h] = orientation === 'landscape' ? [size[1], size[0]] : size
|
|
245
|
+
if (margin * 2 >= w || margin * 2 >= h) {
|
|
246
|
+
throw new RangeError('[snapdom-pdf] margin leaves no positive page area')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (options.image !== false) {
|
|
250
|
+
const quality = options.quality ?? 0.92
|
|
251
|
+
if (!Number.isFinite(quality) || quality < 0 || quality > 1) {
|
|
252
|
+
throw new RangeError('[snapdom-pdf] quality must be between 0 and 1')
|
|
253
|
+
}
|
|
254
|
+
if (!['auto', 'jpeg', 'flate'].includes(options.codec ?? 'auto')) {
|
|
255
|
+
throw new TypeError('[snapdom-pdf] codec must be "auto", "jpeg" or "flate"')
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (options.encrypt != null) validateEncrypt(options.encrypt)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Everything about `encrypt` that can be wrong before any work happens.
|
|
263
|
+
*
|
|
264
|
+
* The empty user password is the one that gets its own error rather than a
|
|
265
|
+
* warning. A PDF whose user password is '' opens with no prompt at all: the
|
|
266
|
+
* file is encrypted, the bytes are unreadable to a text editor, and every
|
|
267
|
+
* viewer in the world walks straight in. That is the exact shape of a document
|
|
268
|
+
* somebody believes is protected and is not, so it is refused instead of
|
|
269
|
+
* produced. `permissions` alone is the same mistake wearing a different hat —
|
|
270
|
+
* a flag is a request, and there is nothing behind it without a password.
|
|
271
|
+
*/
|
|
272
|
+
function validateEncrypt(spec) {
|
|
273
|
+
if (typeof spec !== 'object' || Array.isArray(spec)) {
|
|
274
|
+
throw new TypeError('[snapdom-pdf] encrypt must be an object, ' +
|
|
275
|
+
'for example { userPassword: "…" }')
|
|
276
|
+
}
|
|
277
|
+
const { userPassword, ownerPassword, permissions } = spec
|
|
278
|
+
if (typeof userPassword !== 'string' || !userPassword) {
|
|
279
|
+
throw new TypeError('[snapdom-pdf] encrypt.userPassword must be a non-empty string — ' +
|
|
280
|
+
'a document with an empty user password is encrypted but opens without asking, ' +
|
|
281
|
+
'which protects nothing')
|
|
282
|
+
}
|
|
283
|
+
if (ownerPassword != null && typeof ownerPassword !== 'string') {
|
|
284
|
+
throw new TypeError('[snapdom-pdf] encrypt.ownerPassword must be a string')
|
|
285
|
+
}
|
|
286
|
+
if (permissions != null && (typeof permissions !== 'object' || Array.isArray(permissions))) {
|
|
287
|
+
throw new TypeError('[snapdom-pdf] encrypt.permissions must be an object of booleans')
|
|
288
|
+
}
|
|
289
|
+
for (const key of Object.keys(permissions || {})) {
|
|
290
|
+
if (typeof permissions[key] !== 'boolean') {
|
|
291
|
+
throw new TypeError(`[snapdom-pdf] encrypt.permissions.${key} must be a boolean`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Cut the content into page slices. Knows nothing about the paint either:
|
|
298
|
+
* `collectBlocks` reads the isolated clone and `paginate` is pure geometry in content
|
|
299
|
+
* points.
|
|
300
|
+
*/
|
|
301
|
+
function slicePages({ blocks, toContentY, scaledH, availH, single, report, reserve = null }) {
|
|
302
|
+
if (single) return [{ top: 0, height: scaledH, cut: 0 }]
|
|
303
|
+
const { slices, forcedIgnored } = paginate({
|
|
304
|
+
blocks: blocks.map(b => ({ ...b, top: toContentY(b.top), bottom: toContentY(b.bottom) })),
|
|
305
|
+
contentHeight: scaledH,
|
|
306
|
+
availHeight: availH,
|
|
307
|
+
reserve,
|
|
308
|
+
})
|
|
309
|
+
const cutRows = slices.reduce((n, s) => n + (s.cut || 0), 0)
|
|
310
|
+
if (cutRows) report(`${cutRows} block(s) had to be split across a page break.`)
|
|
311
|
+
if (forcedIgnored) report(`${forcedIgnored} declared page break(s) could not be honoured.`)
|
|
312
|
+
return slices
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A download is a side effect of `download`, never of producing the blob. */
|
|
316
|
+
function deliver(blob, name) {
|
|
317
|
+
if (!name) return blob
|
|
318
|
+
const file = name === true ? 'snapdom.pdf' : String(name)
|
|
319
|
+
const url = URL.createObjectURL(blob)
|
|
320
|
+
const a = document.createElement('a')
|
|
321
|
+
a.href = url
|
|
322
|
+
a.download = file.endsWith('.pdf') ? file : `${file}.pdf`
|
|
323
|
+
a.click()
|
|
324
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
325
|
+
return blob
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const rect = (left, top, width, height) => ({
|
|
329
|
+
left, top, width, height, right: left + width, bottom: top + height,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const overlaps = (a, b) =>
|
|
333
|
+
a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom
|
|
334
|
+
|
|
335
|
+
const contains = (outer, inner) =>
|
|
336
|
+
inner.left >= outer.left - 0.01 && inner.top >= outer.top - 0.01 &&
|
|
337
|
+
inner.right <= outer.right + 0.01 && inner.bottom <= outer.bottom + 0.01
|
|
338
|
+
|
|
339
|
+
/** Conservative AABB for one text run. A partly clipped word is dropped whole:
|
|
340
|
+
* keeping its full string would make characters outside a snapdom clip searchable. */
|
|
341
|
+
function runBox(run) {
|
|
342
|
+
const points = [[0, -run.ascent], [run.width, -run.ascent],
|
|
343
|
+
[0, run.height - run.ascent], [run.width, run.height - run.ascent]]
|
|
344
|
+
const m = run.matrix
|
|
345
|
+
const mapped = points.map(([x, y]) => ({
|
|
346
|
+
x: run.originX + (m ? m.a * x + m.c * y : x),
|
|
347
|
+
y: run.originY + (m ? m.b * x + m.d * y : y),
|
|
348
|
+
}))
|
|
349
|
+
const xs = mapped.map(p => p.x)
|
|
350
|
+
const ys = mapped.map(p => p.y)
|
|
351
|
+
return rect(Math.min(...xs), Math.min(...ys), Math.max(...xs) - Math.min(...xs),
|
|
352
|
+
Math.max(...ys) - Math.min(...ys))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Apply the capture window to every semantic surface, not only to pixels. */
|
|
356
|
+
function clipMeasurement(layer, blocks, repeats, nav, frame, notes) {
|
|
357
|
+
const runs = []
|
|
358
|
+
const owners = []
|
|
359
|
+
let clippedRuns = 0
|
|
360
|
+
for (let i = 0; i < layer.runs.length; i++) {
|
|
361
|
+
if (!contains(frame, runBox(layer.runs[i]))) { clippedRuns++; continue }
|
|
362
|
+
runs.push(layer.runs[i])
|
|
363
|
+
owners.push(layer.owners[i])
|
|
364
|
+
}
|
|
365
|
+
if (clippedRuns) {
|
|
366
|
+
notes.push(`${clippedRuns} text run(s) crossing the snapdom clip edge were omitted ` +
|
|
367
|
+
'from the searchable layer so clipped text cannot leak through extraction.')
|
|
368
|
+
}
|
|
369
|
+
const links = []
|
|
370
|
+
for (const link of layer.links) {
|
|
371
|
+
const box = rect(link.x, link.y, link.width, link.height)
|
|
372
|
+
if (!overlaps(frame, box)) continue
|
|
373
|
+
const left = Math.max(frame.left, box.left)
|
|
374
|
+
const top = Math.max(frame.top, box.top)
|
|
375
|
+
const right = Math.min(frame.right, box.right)
|
|
376
|
+
const bottom = Math.min(frame.bottom, box.bottom)
|
|
377
|
+
links.push({ ...link, x: left, y: top, width: right - left, height: bottom - top })
|
|
378
|
+
}
|
|
379
|
+
layer = { ...layer, runs, owners, links }
|
|
380
|
+
|
|
381
|
+
blocks = blocks.flatMap(b => {
|
|
382
|
+
const top = Math.max(frame.top, b.top)
|
|
383
|
+
const bottom = Math.min(frame.bottom, b.bottom)
|
|
384
|
+
return bottom >= top ? [{ ...b, top, bottom }] : []
|
|
385
|
+
})
|
|
386
|
+
repeats = repeats.filter(b => b.bottom > frame.top && b.top < frame.bottom)
|
|
387
|
+
|
|
388
|
+
const dests = new Map([...nav.dests].filter(([, at]) => at.top || (
|
|
389
|
+
at.x >= frame.left && at.x <= frame.right && at.y >= frame.top && at.y <= frame.bottom
|
|
390
|
+
)))
|
|
391
|
+
const outline = nav.outline.filter(item => !item.box || contains(frame, {
|
|
392
|
+
left: item.box.x, top: item.box.y, right: item.box.x + item.box.w,
|
|
393
|
+
bottom: item.box.y + item.box.h,
|
|
394
|
+
}))
|
|
395
|
+
return { layer, blocks, repeats, nav: { ...nav, dests, outline } }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function walkElements(root, visit) {
|
|
399
|
+
const stack = [root]
|
|
400
|
+
while (stack.length) {
|
|
401
|
+
const el = stack.pop()
|
|
402
|
+
visit(el)
|
|
403
|
+
if (el.shadowRoot) for (let i = el.shadowRoot.children.length - 1; i >= 0; i--) {
|
|
404
|
+
stack.push(el.shadowRoot.children[i])
|
|
405
|
+
}
|
|
406
|
+
for (let i = el.children.length - 1; i >= 0; i--) stack.push(el.children[i])
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Resolve fragments inside the mounted snapshot. ownerDocument.getElementById()
|
|
411
|
+
* cannot do this across the measurement ShadowRoot and may find the live twin. */
|
|
412
|
+
function addSnapshotDestinations(root, links, nav, frame, clipped, documentURL) {
|
|
413
|
+
const wanted = new Set()
|
|
414
|
+
for (const link of links) {
|
|
415
|
+
const name = fragmentOf(link.href, documentURL)
|
|
416
|
+
if (name !== null) wanted.add(name)
|
|
417
|
+
}
|
|
418
|
+
const targets = new Map()
|
|
419
|
+
walkElements(root, (el) => {
|
|
420
|
+
if (el.id && !targets.has(el.id)) targets.set(el.id, el)
|
|
421
|
+
if (el.tagName === 'A') {
|
|
422
|
+
const name = el.getAttribute('name')
|
|
423
|
+
if (name && !targets.has(name)) targets.set(name, el)
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
let unresolved = 0
|
|
427
|
+
for (const name of wanted) {
|
|
428
|
+
if (name === '') { nav.dests.set('', { x: frame.left, y: frame.top, top: true }); continue }
|
|
429
|
+
if (nav.dests.has(name)) continue
|
|
430
|
+
const el = targets.get(name)
|
|
431
|
+
if (!el) { unresolved++; continue }
|
|
432
|
+
const r = el.getBoundingClientRect()
|
|
433
|
+
const at = rect(r.left, r.top, r.width, r.height)
|
|
434
|
+
if (clipped && !overlaps(frame, at)) { unresolved++; continue }
|
|
435
|
+
nav.dests.set(name, { x: r.left, y: r.top })
|
|
436
|
+
}
|
|
437
|
+
if (unresolved) {
|
|
438
|
+
nav.warnings.push(`${unresolved} internal link(s) point outside the final captured tree or clip — ` +
|
|
439
|
+
'written with no annotation rather than a destination that could expose excluded content')
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Measure only the final serialized clone. Never fall back to the live source: a
|
|
444
|
+
* fallback would re-introduce excluded/redacted text into a nominally safe PDF. */
|
|
445
|
+
function measure(element, opts, frame, clipped = false, documentURL = location.href) {
|
|
446
|
+
const notes = []
|
|
447
|
+
const ownRect = frame || element.getBoundingClientRect()
|
|
448
|
+
if (!ownRect.width || !ownRect.height) {
|
|
449
|
+
notes.push('the capture element has no layout box — there is nothing to lay out a page from.')
|
|
450
|
+
return {
|
|
451
|
+
rect: ownRect, layer: { runs: [], owners: [], links: [], warnings: [] }, blocks: [], notes,
|
|
452
|
+
nav: { dests: new Map(), outline: [] }, repeats: [], controls: [],
|
|
453
|
+
structure: {
|
|
454
|
+
root: { type: 'Document', children: [] }, ofRun: [], ofLink: [], ofControl: [],
|
|
455
|
+
lang: null, warnings: [],
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (offCentreTurn(element, ownRect)) {
|
|
461
|
+
notes.push('the capture root is turned about an off-centre origin, so its bounding box grew ' +
|
|
462
|
+
'on one side only and the whole layer may be offset — capture an untransformed wrapper instead.')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const formValues = opts.formValues !== false
|
|
466
|
+
const shadow = opts.shadow !== false
|
|
467
|
+
const forms = opts.forms !== false
|
|
468
|
+
let layer = collectTextLayer(element, { formValues, shadow, documentURL, forms })
|
|
469
|
+
notes.push(...layer.warnings)
|
|
470
|
+
|
|
471
|
+
// Navigation reads the mounted final-artifact layout AFTER the links exist:
|
|
472
|
+
// the set of destinations worth resolving is exactly the set something points
|
|
473
|
+
// at, so the text layer's links are its input.
|
|
474
|
+
// Link targets are resolved locally below. Passing the links here would make
|
|
475
|
+
// getElementById find the live page's duplicate IDs outside this ShadowRoot.
|
|
476
|
+
let nav = collectNav(element, {
|
|
477
|
+
links: [],
|
|
478
|
+
shadow,
|
|
479
|
+
outline: opts.outline !== false,
|
|
480
|
+
headings: typeof opts.outline === 'string' ? opts.outline : null,
|
|
481
|
+
})
|
|
482
|
+
notes.push(...nav.warnings)
|
|
483
|
+
const navNotes = nav.warnings.length
|
|
484
|
+
|
|
485
|
+
// Block geometry only matters where a page break can happen, but the page size
|
|
486
|
+
// is not known yet — collecting is cheap next to being unable to.
|
|
487
|
+
let blocks = opts.breakAvoid === false ? [] : collectBlocks(element, { shadow })
|
|
488
|
+
// Always measured, never gated here: whether a header repeats is about the
|
|
489
|
+
// PAPER — it does nothing on `page: 'fit'` — so the decision belongs to the
|
|
490
|
+
// export, and measuring a document's theads costs one rect each.
|
|
491
|
+
let repeats = collectRepeats(element, { shadow })
|
|
492
|
+
addSnapshotDestinations(element, layer.links, nav, ownRect, clipped, documentURL)
|
|
493
|
+
notes.push(...nav.warnings.slice(navNotes))
|
|
494
|
+
if (clipped) {
|
|
495
|
+
const cut = clipMeasurement(layer, blocks, repeats, nav, ownRect, notes)
|
|
496
|
+
layer = cut.layer
|
|
497
|
+
blocks = cut.blocks
|
|
498
|
+
repeats = cut.repeats
|
|
499
|
+
nav = cut.nav
|
|
500
|
+
}
|
|
501
|
+
// The capture window applies to fields like everything semantic: a control the
|
|
502
|
+
// clip cuts must not become a widget reaching outside what the pixels show.
|
|
503
|
+
const controls = layer.controls || []
|
|
504
|
+
if (clipped) {
|
|
505
|
+
for (const c of controls) {
|
|
506
|
+
if (c.skip) continue
|
|
507
|
+
const box = rect(c.rect.left, c.rect.top, c.rect.width, c.rect.height)
|
|
508
|
+
if (!contains(ownRect, box)) c.skip = 'clipped'
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Which text-layer runs carry each control's VALUE — a control owns its value
|
|
512
|
+
// runs directly (see pushLocalRun), so index equality is the whole join. The
|
|
513
|
+
// field planner suppresses these from the invisible layer: a value must not
|
|
514
|
+
// extract twice, once from the field and once from under it.
|
|
515
|
+
const controlByEl = new Map(controls.map(c => [c.el, c]))
|
|
516
|
+
layer.owners.forEach((owner, i) => {
|
|
517
|
+
const c = controlByEl.get(owner)
|
|
518
|
+
if (c) c.runs.push(i)
|
|
519
|
+
})
|
|
520
|
+
// Built here for the same reason everything else is: the structure of a document
|
|
521
|
+
// is a fact about the DOM, and the DOM is only this document while the capture
|
|
522
|
+
// is running. Whether it reaches the FILE is the export's decision, so its
|
|
523
|
+
// warnings are held back rather than pushed into `notes`.
|
|
524
|
+
const structure = buildStructure(element, layer.owners, {
|
|
525
|
+
lang: opts.lang,
|
|
526
|
+
links: layer.links.map(l => l.el),
|
|
527
|
+
controls: controls.map(c => c.el),
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// buildStructure has already converted ownership to plain structure nodes.
|
|
531
|
+
// Keeping owner Elements here — or on a link or control record — would retain
|
|
532
|
+
// the isolated iframe Document through measuredPromise for the lifetime of the
|
|
533
|
+
// capture result.
|
|
534
|
+
layer = {
|
|
535
|
+
...layer,
|
|
536
|
+
owners: [],
|
|
537
|
+
links: layer.links.map(({ el, ...link }) => link),
|
|
538
|
+
controls: undefined,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
rect: ownRect, layer, blocks, nav, repeats, structure, notes, formValues, shadow,
|
|
543
|
+
documentURL,
|
|
544
|
+
controls: controls.map(({ el, ...control }) => control),
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function svgTextFromDataURL(url) {
|
|
549
|
+
if (typeof url !== 'string' || !/^data:image\/svg\+xml/i.test(url)) {
|
|
550
|
+
throw new Error('[snapdom-pdf] the final capture artifact is not an SVG data URL')
|
|
551
|
+
}
|
|
552
|
+
const comma = url.indexOf(',')
|
|
553
|
+
if (comma < 0) throw new Error('[snapdom-pdf] the final SVG data URL is malformed')
|
|
554
|
+
const header = url.slice(0, comma)
|
|
555
|
+
const payload = url.slice(comma + 1)
|
|
556
|
+
if (/;base64/i.test(header)) {
|
|
557
|
+
const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0))
|
|
558
|
+
return new TextDecoder().decode(bytes)
|
|
559
|
+
}
|
|
560
|
+
return decodeURIComponent(payload)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function measureArtifact(url, meta, opts, source = {}) {
|
|
564
|
+
const svgText = svgTextFromDataURL(url)
|
|
565
|
+
const parsed = new DOMParser().parseFromString(svgText, 'image/svg+xml')
|
|
566
|
+
if (parsed.querySelector('parsererror') || parsed.documentElement.localName !== 'svg') {
|
|
567
|
+
throw new Error('[snapdom-pdf] the final SVG artifact could not be parsed safely')
|
|
568
|
+
}
|
|
569
|
+
const viewBox = String(parsed.documentElement.getAttribute('viewBox') || '')
|
|
570
|
+
.trim().split(/[\s,]+/).map(Number)
|
|
571
|
+
const expectedViewBox = [0, 0, meta.vbW, meta.vbH]
|
|
572
|
+
if (viewBox.length !== 4 || !viewBox.every(Number.isFinite) ||
|
|
573
|
+
viewBox.some((n, i) => Math.abs(n - expectedViewBox[i]) > 1e-4)) {
|
|
574
|
+
// Metadata and pixels form one coordinate contract. Continuing after an
|
|
575
|
+
// afterRender hook windows only the SVG would let clipped pixels retain text,
|
|
576
|
+
// links or tags measured with the old frame.
|
|
577
|
+
throw new Error('[snapdom-pdf] final SVG viewBox no longer matches immutable capture metadata')
|
|
578
|
+
}
|
|
579
|
+
const svgRoot = parsed.documentElement
|
|
580
|
+
const allowedRootAttrs = new Set(['xmlns', 'width', 'height', 'viewBox', 'font-size'])
|
|
581
|
+
const unexpectedRootAttrs = [...svgRoot.attributes]
|
|
582
|
+
.map(attr => attr.name)
|
|
583
|
+
.filter(name => !allowedRootAttrs.has(name))
|
|
584
|
+
if (unexpectedRootAttrs.length) {
|
|
585
|
+
throw new Error('[snapdom-pdf] final SVG root has paint/layout attributes outside the capture contract: ' +
|
|
586
|
+
unexpectedRootAttrs.join(', '))
|
|
587
|
+
}
|
|
588
|
+
const intrinsicW = Number(svgRoot.getAttribute('width'))
|
|
589
|
+
const intrinsicH = Number(svgRoot.getAttribute('height'))
|
|
590
|
+
if (!(intrinsicW > 0) || !(intrinsicH > 0)) {
|
|
591
|
+
throw new Error('[snapdom-pdf] final SVG has no finite positive intrinsic dimensions')
|
|
592
|
+
}
|
|
593
|
+
const intrinsicAspect = intrinsicW / intrinsicH
|
|
594
|
+
const viewBoxAspect = meta.vbW / meta.vbH
|
|
595
|
+
if (!Number.isFinite(intrinsicAspect) ||
|
|
596
|
+
Math.abs(intrinsicAspect / viewBoxAspect - 1) > 1e-4) {
|
|
597
|
+
throw new Error('[snapdom-pdf] final SVG intrinsic aspect no longer matches its viewBox')
|
|
598
|
+
}
|
|
599
|
+
const doc = document
|
|
600
|
+
if (!doc.body) throw new Error('[snapdom-pdf] a document body is required to measure the final capture')
|
|
601
|
+
// Never reconnect serialized page markup to the application document. Event
|
|
602
|
+
// handlers, custom-element callbacks and loadable elements could otherwise run
|
|
603
|
+
// again merely because somebody asked for a PDF. A same-origin sandbox gives us
|
|
604
|
+
// layout access but, without allow-scripts, no script execution; CSP closes the
|
|
605
|
+
// remaining network/object/frame channels while permitting embedded assets.
|
|
606
|
+
const sandboxFrame = doc.createElement('iframe')
|
|
607
|
+
sandboxFrame.setAttribute('data-snapdom-pdf-measure', '')
|
|
608
|
+
sandboxFrame.setAttribute('sandbox', 'allow-same-origin')
|
|
609
|
+
sandboxFrame.style.cssText =
|
|
610
|
+
`position:fixed!important;left:0!important;top:0!important;opacity:0!important;` +
|
|
611
|
+
`width:${meta.vbW}px!important;height:${meta.vbH}px!important;` +
|
|
612
|
+
'display:block!important;border:0!important;pointer-events:none!important;z-index:-2147483648!important;'
|
|
613
|
+
const loaded = new Promise((resolve, reject) => {
|
|
614
|
+
sandboxFrame.onload = resolve
|
|
615
|
+
sandboxFrame.onerror = () => reject(new Error('[snapdom-pdf] isolated measurement frame failed to load'))
|
|
616
|
+
})
|
|
617
|
+
sandboxFrame.srcdoc = '<!doctype html><meta charset="utf-8">' +
|
|
618
|
+
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
|
|
619
|
+
'style-src \'unsafe-inline\'; img-src data:; font-src data:; media-src data:; ' +
|
|
620
|
+
'connect-src \'none\'; object-src \'none\'; frame-src \'none\'">' +
|
|
621
|
+
'<style>html,body{margin:0;padding:0;overflow:visible}</style><body></body>'
|
|
622
|
+
doc.body.appendChild(sandboxFrame)
|
|
623
|
+
try {
|
|
624
|
+
await loaded
|
|
625
|
+
const frameDoc = sandboxFrame.contentDocument
|
|
626
|
+
if (!frameDoc?.body) throw new Error('[snapdom-pdf] isolated measurement document is unavailable')
|
|
627
|
+
frameDoc.documentElement.lang = String(source.documentLang || '')
|
|
628
|
+
const base = frameDoc.createElement('base')
|
|
629
|
+
base.href = String(source.baseURL || source.documentURL || doc.baseURI)
|
|
630
|
+
frameDoc.head.prepend(base)
|
|
631
|
+
const svg = frameDoc.importNode(parsed.documentElement, true)
|
|
632
|
+
svg.style.cssText = `display:block;width:${meta.vbW}px;height:${meta.vbH}px;max-width:none;max-height:none;overflow:visible;`
|
|
633
|
+
frameDoc.body.appendChild(svg)
|
|
634
|
+
if (frameDoc.fonts?.ready) await frameDoc.fonts.ready
|
|
635
|
+
const fo = svg.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'foreignObject')[0] ||
|
|
636
|
+
svg.querySelector('foreignObject')
|
|
637
|
+
const container = fo && [...fo.children].find(el => el.localName === 'div')
|
|
638
|
+
const root = container?.firstElementChild
|
|
639
|
+
if (!root) throw new Error('[snapdom-pdf] the final SVG has no measurable capture root')
|
|
640
|
+
const svgRect = svg.getBoundingClientRect()
|
|
641
|
+
const contentFrame = rect(
|
|
642
|
+
svgRect.left + meta.contentX, svgRect.top + meta.contentY, meta.w0, meta.h0
|
|
643
|
+
)
|
|
644
|
+
const measured = measure(
|
|
645
|
+
root, opts, contentFrame, !!meta.clip,
|
|
646
|
+
String(source.documentURL || source.baseURL || doc.location?.href || doc.baseURI)
|
|
647
|
+
)
|
|
648
|
+
return {
|
|
649
|
+
...measured,
|
|
650
|
+
// The @font-face rules behind the text, read NOW: this CSSOM dies with the
|
|
651
|
+
// frame, and the export that wants the bytes runs after it is gone.
|
|
652
|
+
fontRules: collectFaceRules(frameDoc),
|
|
653
|
+
intrinsic: {
|
|
654
|
+
width: Number.isFinite(intrinsicW) && intrinsicW > 0 ? intrinsicW : meta.vbW,
|
|
655
|
+
height: Number.isFinite(intrinsicH) && intrinsicH > 0 ? intrinsicH : meta.vbH,
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
} finally {
|
|
659
|
+
sandboxFrame.remove()
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Produce the document from a finished capture: snapdom's raster, then the
|
|
665
|
+
* invisible text layer over it.
|
|
666
|
+
*
|
|
667
|
+
* @param {object} args
|
|
668
|
+
* @param {{rect: DOMRect, layer: object, blocks: Array}} args.measured
|
|
669
|
+
* @param {(crop?: object) => Promise<object>} [args.raster] lazy encoded capture regions
|
|
670
|
+
* @param {{w0:number, h0:number, vbW:number, vbH:number}} args.meta
|
|
671
|
+
* @param {object} args.options
|
|
672
|
+
* @param {(msg: string) => void} args.report
|
|
673
|
+
* @param {{check: Function, tick: Function}|null} [args.flow]
|
|
674
|
+
* @returns {Promise<{blob: Blob, stats: object}>}
|
|
675
|
+
*/
|
|
676
|
+
async function draw({ measured, raster, meta, options, report, flow = null }) {
|
|
677
|
+
const {
|
|
678
|
+
quality = 0.92,
|
|
679
|
+
page = 'fit',
|
|
680
|
+
orientation = 'portrait',
|
|
681
|
+
margin = 24,
|
|
682
|
+
text = true,
|
|
683
|
+
links = true,
|
|
684
|
+
image = true,
|
|
685
|
+
deflate: wantDeflate = true,
|
|
686
|
+
header = null,
|
|
687
|
+
footer = null,
|
|
688
|
+
pageNumbers = false,
|
|
689
|
+
debugText = false,
|
|
690
|
+
backgroundColor = null,
|
|
691
|
+
repeatHeaders = true,
|
|
692
|
+
tagged = true,
|
|
693
|
+
lang = null,
|
|
694
|
+
codec = 'auto',
|
|
695
|
+
cover = null,
|
|
696
|
+
back = null,
|
|
697
|
+
toc = null,
|
|
698
|
+
watermark = null,
|
|
699
|
+
fields = true,
|
|
700
|
+
embedFonts = true,
|
|
701
|
+
vectorText = false,
|
|
702
|
+
encrypt: encryptSpec = null,
|
|
703
|
+
meta: docMeta = null,
|
|
704
|
+
} = options
|
|
705
|
+
|
|
706
|
+
const { rect, layer, blocks, nav } = measured
|
|
707
|
+
const { offX, offY } = bleedOffset(meta)
|
|
708
|
+
const { vbW, vbH } = meta
|
|
709
|
+
|
|
710
|
+
// Content space: points from the element's border-box top-left. The raster can
|
|
711
|
+
// start above/left of that (bleed) and end below it, and pagination has to
|
|
712
|
+
// cover what is drawn, not just the element box.
|
|
713
|
+
const contentW = rect.width * PT
|
|
714
|
+
const contentH = (page === 'fit' ? rect.height : Math.max(rect.height, vbH - offY)) * PT
|
|
715
|
+
|
|
716
|
+
// Text bands have intrinsic metrics. Element bands are captured below before
|
|
717
|
+
// pagination; their final artifact aspect determines the strip they reserve.
|
|
718
|
+
if (page === 'fit' && (header || footer || pageNumbers)) {
|
|
719
|
+
report("header, footer and pageNumbers need a paginated page size — they do nothing on page:'fit'.")
|
|
720
|
+
}
|
|
721
|
+
// Orientation is a fact about the PAPER, and `page: 'fit'` has none — the page
|
|
722
|
+
// IS the element, whatever shape that element happens to be.
|
|
723
|
+
if (page === 'fit' && orientation === 'landscape') {
|
|
724
|
+
report("orientation does nothing on page:'fit' — the page is the element's own box, " +
|
|
725
|
+
'so there is no paper to turn.')
|
|
726
|
+
}
|
|
727
|
+
const rawBands = page === 'fit'
|
|
728
|
+
? { header: null, footer: null }
|
|
729
|
+
: { header: bandSpec(header), footer: bandSpec(footer) }
|
|
730
|
+
const bands = { ...rawBands }
|
|
731
|
+
if (!image) {
|
|
732
|
+
for (const where of ['header', 'footer']) {
|
|
733
|
+
if (bands[where]?.kind === 'element') {
|
|
734
|
+
report(`${where} element omitted because image:false forbids raster/XObject work.`)
|
|
735
|
+
bands[where] = null
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const provisional = page === 'fit' ? [0, 0] : pageSize(page, () => {})
|
|
740
|
+
const provisionalW = (orientation === 'landscape' ? provisional[1] : provisional[0]) - margin * 2
|
|
741
|
+
const encodeOpaque = (surface, encodeOptions) =>
|
|
742
|
+
encodeImage(surface, { ...encodeOptions, background: backgroundColor })
|
|
743
|
+
const capturedBands = new Map()
|
|
744
|
+
if (image) {
|
|
745
|
+
for (const [where, spec] of [['header', bands.header], ['footer', bands.footer]]) {
|
|
746
|
+
if (!spec || spec.kind !== 'element') continue
|
|
747
|
+
const band = await captureBand(spec.el, {
|
|
748
|
+
scale: options.scale, dpr: options.dpr, backgroundColor: null, quality, codec,
|
|
749
|
+
availW: provisionalW, captureOptions: options.captureOptions,
|
|
750
|
+
}, encodeOpaque)
|
|
751
|
+
capturedBands.set(where, band)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Final-artifact aspect is authoritative for element bands. The pageBox call
|
|
755
|
+
// below also catches a render hook that enlarged a band until no printable area
|
|
756
|
+
// remains.
|
|
757
|
+
const bandRoom = (b, where) => !b ? 0
|
|
758
|
+
: b.kind === 'text' ? textBandHeight(b) : capturedBands.get(where)?.heightPt || 0
|
|
759
|
+
const headerH = bandRoom(bands.header, 'header')
|
|
760
|
+
const footerH = bandRoom(bands.footer, 'footer')
|
|
761
|
+
|
|
762
|
+
const geo = pageBox({ page, orientation, margin, contentW, contentH, headerH, footerH, report })
|
|
763
|
+
const { pageW, pageH, marginX, marginY, availW, availH, contentTop, k, scaledH } = geo
|
|
764
|
+
|
|
765
|
+
// Viewport CSS px → content points.
|
|
766
|
+
const toContentX = (vx) => (vx - rect.left) * k
|
|
767
|
+
const toContentY = (vy) => (vy - rect.top) * k
|
|
768
|
+
|
|
769
|
+
// A repeated table header is not drawn over the page, it takes room from it, so
|
|
770
|
+
// pagination has to see it before it cuts anything. `page: 'fit'` has one page
|
|
771
|
+
// and nothing to repeat onto.
|
|
772
|
+
const repeatBands = (repeatHeaders !== false && page !== 'fit')
|
|
773
|
+
? (measured.repeats || []).map(b => ({
|
|
774
|
+
top: toContentY(b.top), bottom: toContentY(b.bottom), bodyBottom: toContentY(b.bodyBottom),
|
|
775
|
+
}))
|
|
776
|
+
: []
|
|
777
|
+
const maxRepeat = availH * 0.5
|
|
778
|
+
|
|
779
|
+
const slices = slicePages({
|
|
780
|
+
blocks, toContentY, scaledH, availH, report,
|
|
781
|
+
single: page === 'fit',
|
|
782
|
+
reserve: headerReserve(repeatBands, maxRepeat),
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
const doc = createPdfDoc()
|
|
786
|
+
const catalogId = doc.reserve()
|
|
787
|
+
const pagesId = doc.reserve()
|
|
788
|
+
|
|
789
|
+
// ——— fillable fields ———
|
|
790
|
+
//
|
|
791
|
+
// Planned before any page is drawn because the plan reaches BACKWARDS into the
|
|
792
|
+
// text layer: a value that lives in a field must leave the invisible layer, or
|
|
793
|
+
// every extraction returns it twice — once from the field, once from under it.
|
|
794
|
+
const fieldPlan = (fields !== false && (measured.controls || []).length)
|
|
795
|
+
? planFields(measured.controls, report)
|
|
796
|
+
: null
|
|
797
|
+
const fieldWriter = fieldPlan && fieldPlan.fields.length
|
|
798
|
+
? createFieldWriter(doc, { report })
|
|
799
|
+
: null
|
|
800
|
+
const suppressedRuns = fieldWriter ? fieldPlan.suppressed : new Set()
|
|
801
|
+
const docMetaSpec = metaSpec(docMeta, report)
|
|
802
|
+
|
|
803
|
+
// One encoded region may be painted more than once (repeated headers) but is
|
|
804
|
+
// registered as one PDF object. The raster provider itself caches canvas and
|
|
805
|
+
// codec work across repeated exports of this capture.
|
|
806
|
+
// Which candidate `codec: 'auto'` actually kept, per region. It decides by
|
|
807
|
+
// SIZE, so on a page of flat runs and sharp edges Flate wins and the JPEG —
|
|
808
|
+
// the only one `quality` has any say over — is discarded. Counted because a
|
|
809
|
+
// caller who lowers quality and gets a byte-identical file deserves to be told
|
|
810
|
+
// why rather than left to conclude the option is broken.
|
|
811
|
+
let jpegRegions = 0
|
|
812
|
+
let losslessRegions = 0
|
|
813
|
+
const imageObjects = new WeakMap()
|
|
814
|
+
const imageObject = async (crop = null) => {
|
|
815
|
+
if (!raster) throw new Error('[snapdom-pdf] raster requested while image:false')
|
|
816
|
+
const encoded = await raster(crop)
|
|
817
|
+
let saved = imageObjects.get(encoded)
|
|
818
|
+
if (saved) return saved
|
|
819
|
+
if (/DCTDecode/.test(encoded.dict)) jpegRegions++
|
|
820
|
+
else losslessRegions++
|
|
821
|
+
const smaskRef = encoded.smask
|
|
822
|
+
? ` /SMask ${doc.addStream(encoded.smask.dict, encoded.smask.bytes)} 0 R`
|
|
823
|
+
: ''
|
|
824
|
+
saved = { id: doc.addStream(encoded.dict + smaskRef, encoded.bytes), crop }
|
|
825
|
+
imageObjects.set(encoded, saved)
|
|
826
|
+
return saved
|
|
827
|
+
}
|
|
828
|
+
// Every body image is requested as a pre-decode region, including page:'fit'.
|
|
829
|
+
// A fit page may still be a very long document; it is composed from several
|
|
830
|
+
// bands on one MediaBox rather than allocating one document-height bitmap.
|
|
831
|
+
|
|
832
|
+
// Captured bands become one XObject each, reused by every page: a band is an
|
|
833
|
+
// element and an element does not change between pages. Text bands are drawn
|
|
834
|
+
// per page, because their segments may depend on the page number.
|
|
835
|
+
const furniture = []
|
|
836
|
+
for (const [where, spec, name] of [['header', bands.header, 'Ih'], ['footer', bands.footer, 'If']]) {
|
|
837
|
+
if (!spec || spec.kind !== 'element') continue
|
|
838
|
+
const band = capturedBands.get(where)
|
|
839
|
+
if (!band) continue
|
|
840
|
+
const mask = band.smask ? ` /SMask ${doc.addStream(band.smask.dict, band.smask.bytes)} 0 R` : ''
|
|
841
|
+
furniture.push({ where, name, id: doc.addStream(band.dict + mask, band.bytes), heightPt: band.heightPt })
|
|
842
|
+
}
|
|
843
|
+
const numbers = numberSpec(page === 'fit' ? false : pageNumbers)
|
|
844
|
+
|
|
845
|
+
// The watermark's translucency is graphics state, so it needs one ExtGState the
|
|
846
|
+
// pages share, and an element watermark needs its own XObject. Both are built
|
|
847
|
+
// once: a stamp does not change between pages.
|
|
848
|
+
let mark = watermarkSpec(watermark)
|
|
849
|
+
if (!image && mark?.kind === 'element') {
|
|
850
|
+
report('element watermark omitted because image:false forbids raster/XObject work.')
|
|
851
|
+
mark = null
|
|
852
|
+
}
|
|
853
|
+
let markGs = null
|
|
854
|
+
let markImage = null
|
|
855
|
+
let markAspect = 1
|
|
856
|
+
if (mark) {
|
|
857
|
+
markGs = { name: 'GSw', id: doc.add(`<< /Type /ExtGState /ca ${mark.opacity} /CA ${mark.opacity} >>`) }
|
|
858
|
+
if (mark.kind === 'element') {
|
|
859
|
+
const stamp = await captureElement(mark.el, {
|
|
860
|
+
scale: options.scale, dpr: options.dpr, backgroundColor: null, quality, codec,
|
|
861
|
+
captureOptions: options.captureOptions,
|
|
862
|
+
}, encodeImage)
|
|
863
|
+
const maskRef = stamp.smask ? ` /SMask ${doc.addStream(stamp.smask.dict, stamp.smask.bytes)} 0 R` : ''
|
|
864
|
+
markImage = { name: 'Iw', id: doc.addStream(stamp.dict + maskRef, stamp.bytes) }
|
|
865
|
+
markAspect = stamp.aspect
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* One page dictionary. Every page in the file — body, cover, index, colophon —
|
|
870
|
+
* goes through here, so a resource the watermark needs cannot be present on the
|
|
871
|
+
* pages somebody remembered and missing from the ones they did not.
|
|
872
|
+
*/
|
|
873
|
+
const pageDict = ({ contentId, annots = [], fonts, xobjects = [], structParents = null }) => {
|
|
874
|
+
const all = [...xobjects]
|
|
875
|
+
if (markImage) all.push(`/${markImage.name} ${markImage.id} 0 R`)
|
|
876
|
+
const res = [
|
|
877
|
+
all.length ? `/XObject << ${all.join(' ')} >>` : '',
|
|
878
|
+
fonts.size ? `/Font << ${[...fonts].map(f => `/${f.name} ${f.id} 0 R`).join(' ')} >>` : '',
|
|
879
|
+
markGs ? `/ExtGState << /${markGs.name} ${markGs.id} 0 R >>` : '',
|
|
880
|
+
].filter(Boolean).join(' ')
|
|
881
|
+
return `<< /Type /Page /Parent ${pagesId} 0 R ` +
|
|
882
|
+
`/MediaBox [0 0 ${pageW.toFixed(3)} ${pageH.toFixed(3)}] /Resources << ${res} >> ` +
|
|
883
|
+
(structParents === null ? '' : `/StructParents ${structParents} `) +
|
|
884
|
+
// Structure order is the tab order — the only honest /Tabs value here,
|
|
885
|
+
// since widgets and links join the tree through /OBJR.
|
|
886
|
+
(struct ? '/Tabs /S ' : '') +
|
|
887
|
+
`/Contents ${contentId} 0 R${annots.length ? ` /Annots [${annots.join(' ')}]` : ''} >>`
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The same stamp on every page, front matter included — including the cover. */
|
|
891
|
+
const markOps = (pageFonts) => {
|
|
892
|
+
if (!mark) return []
|
|
893
|
+
const out = watermarkOps({
|
|
894
|
+
spec: mark, pageW, pageH, gs: markGs.name,
|
|
895
|
+
image: markImage && markImage.name, imageAspect: markAspect, pdfText,
|
|
896
|
+
})
|
|
897
|
+
if (!out) return []
|
|
898
|
+
if (out.font) pageFonts.add(out.font)
|
|
899
|
+
return out.ops
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Only emit fonts the document actually uses.
|
|
903
|
+
const usedFonts = new Map()
|
|
904
|
+
const fontRef = (key) => {
|
|
905
|
+
if (!usedFonts.has(key)) {
|
|
906
|
+
const name = `F${usedFonts.size}`
|
|
907
|
+
const id = doc.add(
|
|
908
|
+
`<< /Type /Font /Subtype /Type1 /BaseFont /${BASE14[key] || BASE14.sans} /Encoding /WinAnsiEncoding >>`
|
|
909
|
+
)
|
|
910
|
+
usedFonts.set(key, { name, id })
|
|
911
|
+
}
|
|
912
|
+
return usedFonts.get(key)
|
|
913
|
+
}
|
|
914
|
+
// Registers no objects until a run needs it, so a Latin-only document is
|
|
915
|
+
// byte-identical to the base-14-only build.
|
|
916
|
+
const unicodeFont = createUnicodeFont(doc)
|
|
917
|
+
|
|
918
|
+
// ——— embedded fonts ———
|
|
919
|
+
//
|
|
920
|
+
// The faces behind the capture's own @font-face rules, resolved per font key
|
|
921
|
+
// and embedded WHOLE (see pdf-writer/font.js for what that buys and what it
|
|
922
|
+
// refuses). A run rides its embedded face only when the face's real cmap has
|
|
923
|
+
// every glyph the run paints; anything else keeps the base-14/Type0 path it
|
|
924
|
+
// always had, and is counted. Parsed binaries cache on the capture, so a
|
|
925
|
+
// second export re-encodes nothing.
|
|
926
|
+
const fontCache = measured.fontCache || (measured.fontCache = new Map())
|
|
927
|
+
const facesFor = (fontKeys, runs, fontRules) => {
|
|
928
|
+
if (!embedFonts || !fontKeys?.length || !fontRules?.length) return Promise.resolve([])
|
|
929
|
+
const points = fontKeys.map(() => new Set())
|
|
930
|
+
for (const run of runs) {
|
|
931
|
+
const set = run.fk >= 0 ? points[run.fk] : null
|
|
932
|
+
if (set) for (const ch of run.text) set.add(ch.codePointAt(0))
|
|
933
|
+
}
|
|
934
|
+
return resolveFaces(fontKeys, points, fontRules, fontCache, report)
|
|
935
|
+
}
|
|
936
|
+
const bodyFaces = await facesFor(layer.fontKeys, layer.runs, measured.fontRules)
|
|
937
|
+
// The page has loaded webfonts, but the capture carries no @font-face for the
|
|
938
|
+
// export to read: snapdom strips them unless the CAPTURE inlines them. Said
|
|
939
|
+
// here once, because the alternative is fonts that silently never embed.
|
|
940
|
+
if (embedFonts && !(measured.fontRules?.length) &&
|
|
941
|
+
typeof document !== 'undefined' && document.fonts?.size) {
|
|
942
|
+
report('the page declares webfonts but none reached the capture, so nothing can be embedded — ' +
|
|
943
|
+
'pass `embedFonts: true` to snapdom(…) so the capture carries its @font-face rules.')
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Keyed by the PARSED BINARY, not the face record: the cover and the body
|
|
947
|
+
// each resolve their own faces, and when both land on the same file — the
|
|
948
|
+
// cache hands both the same parsed object — the document must carry that
|
|
949
|
+
// program once, not once per page that asked.
|
|
950
|
+
const embeddedFonts = new Map()
|
|
951
|
+
const embeddedOf = (face) => {
|
|
952
|
+
let f = embeddedFonts.get(face.parsed)
|
|
953
|
+
if (!f) {
|
|
954
|
+
f = createEmbeddedFont(doc, face.parsed, face.sfnt, String(embeddedFonts.size),
|
|
955
|
+
{ subset: embedFonts !== 'full' })
|
|
956
|
+
f.label = face.label
|
|
957
|
+
f.wholeKB = Math.round(face.sfnt.length / 1024)
|
|
958
|
+
embeddedFonts.set(face.parsed, f)
|
|
959
|
+
}
|
|
960
|
+
return f
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// The document's OWN face: the one that painted the most body text. Furniture
|
|
964
|
+
// and generated pages default to it, so a running head is set in the same
|
|
965
|
+
// type as the report it runs over — and set in REAL embedded glyphs.
|
|
966
|
+
let primaryFace = null
|
|
967
|
+
{
|
|
968
|
+
const painted = new Map()
|
|
969
|
+
for (const run of layer.runs) {
|
|
970
|
+
const face = run.fk >= 0 ? bodyFaces[run.fk] : null
|
|
971
|
+
if (face) painted.set(face, (painted.get(face) || 0) + run.text.length)
|
|
972
|
+
}
|
|
973
|
+
for (const [face, n] of painted) {
|
|
974
|
+
if (!primaryFace || n > painted.get(primaryFace)) primaryFace = face
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
let coverageFallbacks = 0
|
|
978
|
+
const systemStacks = new Set()
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* One short string of the PLUGIN's own text — a page number, a running head, a
|
|
982
|
+
* watermark, a line of the generated index — encoded and measured.
|
|
983
|
+
*
|
|
984
|
+
* Base-14 where WinAnsi can address it; the document's Type0 font otherwise, so
|
|
985
|
+
* a Japanese heading reaches the index instead of being silently dropped the way
|
|
986
|
+
* a base-14-only path drops it. The width comes back with it because every one
|
|
987
|
+
* of these callers has to align or centre what it just encoded, and the standing
|
|
988
|
+
* `length × size × 0.5` guess puts a right-aligned string off its own margin.
|
|
989
|
+
*
|
|
990
|
+
* @returns {{str: string, font: object, width: number}|null}
|
|
991
|
+
*/
|
|
992
|
+
let unpaintable = 0
|
|
993
|
+
const pdfText = (text, key = 'sans', size = 10, { paint = true } = {}) => {
|
|
994
|
+
const body = String(text)
|
|
995
|
+
// 'document' is the body's own embedded face — real glyphs, any script its
|
|
996
|
+
// cmap reaches, PAINTABLE. It falls back to base-14 sans per string, so one
|
|
997
|
+
// heading the face lacks does not cost the rest of the furniture its type.
|
|
998
|
+
if (key === 'document') {
|
|
999
|
+
if (primaryFace) {
|
|
1000
|
+
const f = embeddedOf(primaryFace)
|
|
1001
|
+
const str = f.encode(body)
|
|
1002
|
+
if (str) {
|
|
1003
|
+
return { str, font: f, width: f.width(body, size), base14: false, paintable: true }
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
key = 'sans'
|
|
1007
|
+
}
|
|
1008
|
+
const str = pdfString(body)
|
|
1009
|
+
if (str) {
|
|
1010
|
+
return { str, font: fontRef(key), width: measureBase14(body, key, size), base14: true, paintable: true }
|
|
1011
|
+
}
|
|
1012
|
+
// The Type0 font carries NO GLYPHS — that is its whole design, and it is why
|
|
1013
|
+
// extraction can run off the CMap alone. So it may be written invisibly and
|
|
1014
|
+
// must never be PAINTED: a viewer with nothing to draw draws nothing, or
|
|
1015
|
+
// notdef boxes, and which of the two you get depends on the viewer. Refusing
|
|
1016
|
+
// here is what turns that into a warning instead of a blank line in somebody's
|
|
1017
|
+
// report.
|
|
1018
|
+
if (paint) { unpaintable++; return null }
|
|
1019
|
+
const encoded = unicodeFont.encode(body)
|
|
1020
|
+
if (!encoded) return null
|
|
1021
|
+
// A font with no glyphs has no metrics either: one em per codepoint is the
|
|
1022
|
+
// same estimate the run path uses for its own Tz denominator.
|
|
1023
|
+
return { str: encoded, font: unicodeFont, width: [...body].length * size, base14: false, paintable: false }
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Every run belongs to exactly ONE page. Choosing by baseline and then
|
|
1027
|
+
// clamping is what keeps a word that straddles a slice from vanishing off
|
|
1028
|
+
// both pages, which is how the fixed-band version lost content.
|
|
1029
|
+
const sliceOf = (cy) => {
|
|
1030
|
+
for (let i = 0; i < slices.length; i++) {
|
|
1031
|
+
if (cy < slices[i].top + slices[i].height) return i
|
|
1032
|
+
}
|
|
1033
|
+
return slices.length - 1
|
|
1034
|
+
}
|
|
1035
|
+
// The centre of the painted box, not the baseline: a line sitting on a slice
|
|
1036
|
+
// edge belongs to the page that shows most of its glyphs.
|
|
1037
|
+
const runPages = layer.runs.map((run) => {
|
|
1038
|
+
const dx = run.width / 2
|
|
1039
|
+
const dy = run.height / 2 - run.ascent
|
|
1040
|
+
const m = run.matrix
|
|
1041
|
+
const cy = toContentY(run.originY) + (m ? (m.b * dx + m.d * dy) : dy) * k
|
|
1042
|
+
return sliceOf(Math.max(0, cy))
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
// Reserved, not appended as each page finishes: a link on page 1 may point at
|
|
1046
|
+
// page 9, and a destination cannot name a page object that does not exist yet.
|
|
1047
|
+
const pageIds = slices.map(() => doc.reserve())
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* A measured point → the PDF destination that lands on it: `/XYZ`, so the
|
|
1051
|
+
* viewer keeps the reader's zoom and only scrolls. The point is the TOP-LEFT of
|
|
1052
|
+
* the target, which is what puts the heading itself at the top of the window
|
|
1053
|
+
* rather than just off it.
|
|
1054
|
+
*
|
|
1055
|
+
* Clamped to the page box because a destination outside it is not a scroll
|
|
1056
|
+
* position any viewer can honour — it silently becomes the origin, which sends
|
|
1057
|
+
* the reader to the wrong end of the page instead of the wrong place on it.
|
|
1058
|
+
*/
|
|
1059
|
+
// What each page gives up to repeated headers, and therefore where its own
|
|
1060
|
+
// content starts. Read once, here, by everything that places anything: the
|
|
1061
|
+
// destinations below, the page image, the runs and the links.
|
|
1062
|
+
const repeated = slices.map(s => repeatsAt(repeatBands, s.top, maxRepeat))
|
|
1063
|
+
const pageTopOf = (p) => contentTop - repeated[p].height
|
|
1064
|
+
if (repeated.some(r => r.refused)) {
|
|
1065
|
+
report('a table header is more than half a page tall — it is drawn once, where it is, ' +
|
|
1066
|
+
'and not repeated: a page that is mostly header carries no rows.')
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const destOf = (at) => {
|
|
1070
|
+
if (at.top) return { page: 0, ref: `[${pageIds[0]} 0 R /XYZ 0 ${pageH.toFixed(3)} null]` }
|
|
1071
|
+
const cy = Math.max(0, toContentY(at.y))
|
|
1072
|
+
const p = sliceOf(cy)
|
|
1073
|
+
const x = Math.min(Math.max(0, marginX + toContentX(at.x)), pageW)
|
|
1074
|
+
const y = Math.min(Math.max(0, pageTopOf(p) - (cy - slices[p].top)), pageH)
|
|
1075
|
+
return { page: p, ref: `[${pageIds[p]} 0 R /XYZ ${x.toFixed(3)} ${y.toFixed(3)} null]` }
|
|
1076
|
+
}
|
|
1077
|
+
const dests = new Map()
|
|
1078
|
+
/** Which BODY page a destination is on, which is the number the index prints. */
|
|
1079
|
+
const destPage = new Map()
|
|
1080
|
+
for (const [name, at] of (nav?.dests || new Map())) {
|
|
1081
|
+
const d = destOf(at)
|
|
1082
|
+
dests.set(name, d.ref)
|
|
1083
|
+
destPage.set(name, d.page)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
let dropped = 0
|
|
1087
|
+
let offPage = 0
|
|
1088
|
+
let unlinked = 0
|
|
1089
|
+
let vectorPainted = 0
|
|
1090
|
+
let vectorMissed = 0
|
|
1091
|
+
// Painted images nobody named, on body and matter pages alike. Any one of them
|
|
1092
|
+
// withholds the PDF/UA-1 claim.
|
|
1093
|
+
let unnamedImages = layer.unnamedImages
|
|
1094
|
+
|
|
1095
|
+
const actionFor = (href, documentURL = measured.documentURL) => {
|
|
1096
|
+
const name = fragmentOf(href, documentURL)
|
|
1097
|
+
if (name !== null) {
|
|
1098
|
+
if (!dests.has(name)) { unlinked++; return null }
|
|
1099
|
+
return `/Dest ${dests.get(name)}`
|
|
1100
|
+
}
|
|
1101
|
+
return `/A << /Type /Action /S /URI /URI ${pdfString(href) || '()'} >>`
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Annotations that belong to the structure tree — a link's `/Link`, a widget's
|
|
1106
|
+
* `/Form` — cannot be finished while pages are still being drawn: their
|
|
1107
|
+
* `/StructParent` keys must come AFTER every page's `/StructParents` key, and
|
|
1108
|
+
* pages (cover, contents) are still being created. So a structured annotation
|
|
1109
|
+
* reserves its id now and is filled in one pass before the tree is written.
|
|
1110
|
+
*/
|
|
1111
|
+
const annotStruct = []
|
|
1112
|
+
let annotCount = 0
|
|
1113
|
+
|
|
1114
|
+
const addLinkAnnot = (box, action, owner = null) => {
|
|
1115
|
+
const left = Math.max(0, box.left)
|
|
1116
|
+
const bottom = Math.max(0, box.bottom)
|
|
1117
|
+
const right = Math.min(pageW, box.right)
|
|
1118
|
+
const top = Math.min(pageH, box.top)
|
|
1119
|
+
if (![left, bottom, right, top].every(Number.isFinite) || right <= left || top <= bottom) return null
|
|
1120
|
+
annotCount++
|
|
1121
|
+
const dict =
|
|
1122
|
+
`<< /Type /Annot /Subtype /Link /Border [0 0 0] ` +
|
|
1123
|
+
`/Rect [${left.toFixed(3)} ${bottom.toFixed(3)} ${right.toFixed(3)} ${top.toFixed(3)}] ` +
|
|
1124
|
+
`${action}`
|
|
1125
|
+
if (struct && owner && owner.node) {
|
|
1126
|
+
const id = doc.reserve()
|
|
1127
|
+
annotStruct.push({ id, dict, node: owner.node, pageId: owner.pageId })
|
|
1128
|
+
return id
|
|
1129
|
+
}
|
|
1130
|
+
return doc.add(dict + ' >>')
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Where content y = 0 sits on the page, for one draw. Every placement below is
|
|
1135
|
+
* `origin - contentY`, so a repeated header band and the page's own slice differ
|
|
1136
|
+
* in exactly this number and in nothing else — which is why the header lands on
|
|
1137
|
+
* the same pixels twice instead of nearly the same.
|
|
1138
|
+
*/
|
|
1139
|
+
const originFor = (pageY, contentY) => pageY + contentY
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* The map `drawRun` takes for the CAPTURED BODY: content space is the element's
|
|
1143
|
+
* border box scaled by `k`, and `origin` is where content y = 0 sits.
|
|
1144
|
+
*/
|
|
1145
|
+
const bodyMap = (origin) => ({
|
|
1146
|
+
ax: marginX - rect.left * k,
|
|
1147
|
+
ay: origin + rect.top * k,
|
|
1148
|
+
s: k,
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
const cropFor = (top, height) => {
|
|
1152
|
+
const y = Math.max(0, Math.min(vbH, offY + top / k))
|
|
1153
|
+
const bottom = Math.max(y, Math.min(vbH, offY + (top + height) / k))
|
|
1154
|
+
if (!(bottom > y)) throw new RangeError('[snapdom-pdf] page slice maps outside the raster viewBox')
|
|
1155
|
+
return { x: 0, y, width: vbW, height: bottom - y }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Core crops before decode, then applies the capture scale and dpr to the
|
|
1159
|
+
// canvas. Bound both stages with the same side/area ceilings as toCanvas.
|
|
1160
|
+
const intrinsicW = measured.intrinsic?.width > 0 ? measured.intrinsic.width : vbW
|
|
1161
|
+
const intrinsicH = measured.intrinsic?.height > 0 ? measured.intrinsic.height : vbH
|
|
1162
|
+
const pixelFactor = options.scale * options.dpr
|
|
1163
|
+
const densityX = options.rasterDensity != null
|
|
1164
|
+
? Math.max(intrinsicW / vbW, options.rasterDensity * options.dpr)
|
|
1165
|
+
: intrinsicW / vbW * pixelFactor
|
|
1166
|
+
const densityY = options.rasterDensity != null
|
|
1167
|
+
? Math.max(intrinsicH / vbH, options.rasterDensity * options.dpr)
|
|
1168
|
+
: intrinsicH / vbH * pixelFactor
|
|
1169
|
+
const regionPixelW = vbW * densityX
|
|
1170
|
+
const maxRegionH = Math.max(1, Math.min(
|
|
1171
|
+
vbH,
|
|
1172
|
+
MAX_RASTER_SIDE / Math.max(densityY, Number.EPSILON),
|
|
1173
|
+
MAX_RASTER_AREA / Math.max(regionPixelW * densityY, Number.EPSILON)
|
|
1174
|
+
))
|
|
1175
|
+
if (image && regionPixelW > MAX_RASTER_SIDE) {
|
|
1176
|
+
report(`the capture is ${Math.ceil(regionPixelW)} raster pixels wide, above the browser's ` +
|
|
1177
|
+
`${MAX_RASTER_SIDE}px image limit — vertical regions avoid a giant document bitmap, but ` +
|
|
1178
|
+
'SnapDOM may still downscale their width. Lower scale/dpr for full resolution.')
|
|
1179
|
+
}
|
|
1180
|
+
const cropsFor = (top, height) => {
|
|
1181
|
+
const whole = cropFor(top, height)
|
|
1182
|
+
const out = []
|
|
1183
|
+
const end = whole.y + whole.height
|
|
1184
|
+
for (let y = whole.y; y < end - 1e-7;) {
|
|
1185
|
+
const h = Math.min(maxRegionH, end - y)
|
|
1186
|
+
out.push({ x: whole.x, y, width: whole.width, height: h })
|
|
1187
|
+
y += h
|
|
1188
|
+
}
|
|
1189
|
+
return out
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** A full image or a pre-decode SVG crop, clipped to the page's content band. */
|
|
1193
|
+
const drawImage = (ops, name, origin, top, height, crop = null) => {
|
|
1194
|
+
const box = crop || { x: 0, y: 0, width: vbW, height: vbH }
|
|
1195
|
+
const imgW = box.width * k
|
|
1196
|
+
const imgH = box.height * k
|
|
1197
|
+
const imageTop = origin + offY * k - box.y * k
|
|
1198
|
+
ops.push('q')
|
|
1199
|
+
ops.push(`${marginX} ${(top - height).toFixed(3)} ${availW} ${height.toFixed(3)} re W n`)
|
|
1200
|
+
ops.push(`${imgW.toFixed(3)} 0 0 ${imgH.toFixed(3)} ` +
|
|
1201
|
+
`${(marginX - offX * k + box.x * k).toFixed(3)} ${(imageTop - imgH).toFixed(3)} cm`)
|
|
1202
|
+
ops.push(`/${name} Do`)
|
|
1203
|
+
ops.push('Q')
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* One run, placed by an AFFINE MAP from viewport CSS px onto the page:
|
|
1208
|
+
* `x = ax + vx·s`, `y = ay − vy·s`. Three callers need three different maps —
|
|
1209
|
+
* the page's own slice, a repeated header band, and a cover or colophon drawn
|
|
1210
|
+
* at its own scale — and they differ in nothing but those three numbers.
|
|
1211
|
+
*
|
|
1212
|
+
* Returns false when the run carries a codepoint the Type0 font has no room
|
|
1213
|
+
* left for; the caller counts it.
|
|
1214
|
+
*/
|
|
1215
|
+
const drawRun = (ops, pageFonts, run, { ax, ay, s }, faces = bodyFaces) => {
|
|
1216
|
+
// The run's own embedded face first — real glyphs, real advances, so Tz is
|
|
1217
|
+
// correcting sub-pixel truth rather than substituting Helvetica's. Base-14
|
|
1218
|
+
// is the fallback for system-font runs; the Type0 font carries everything
|
|
1219
|
+
// WinAnsi cannot address. The three disagree on advance, which is why the
|
|
1220
|
+
// caller — not the text layer — computes Tz.
|
|
1221
|
+
let str = null
|
|
1222
|
+
let font, natural
|
|
1223
|
+
let embeddedUsed = false
|
|
1224
|
+
const face = run.fk >= 0 ? faces[run.fk]
|
|
1225
|
+
// A synthetic marker run has no style of its own; it rides the document's
|
|
1226
|
+
// face when one exists, so an otherwise fully-embedded export does not
|
|
1227
|
+
// drag Helvetica back in for a figure's placeholder space.
|
|
1228
|
+
: (run.fk === -1 && primaryFace ? primaryFace : null)
|
|
1229
|
+
if (face) {
|
|
1230
|
+
const f = embeddedOf(face)
|
|
1231
|
+
str = f.encode(run.text)
|
|
1232
|
+
if (str) {
|
|
1233
|
+
font = f
|
|
1234
|
+
natural = f.width(run.text, run.size)
|
|
1235
|
+
embeddedUsed = true
|
|
1236
|
+
} else {
|
|
1237
|
+
coverageFallbacks++
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (!str) {
|
|
1241
|
+
str = pdfString(run.text)
|
|
1242
|
+
if (str) {
|
|
1243
|
+
font = fontRef(run.font)
|
|
1244
|
+
natural = run.naturalWidth
|
|
1245
|
+
} else {
|
|
1246
|
+
str = unicodeFont.encode(run.text)
|
|
1247
|
+
if (!str) return false
|
|
1248
|
+
font = unicodeFont
|
|
1249
|
+
natural = [...run.text].length * run.size
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
pageFonts.add(font)
|
|
1253
|
+
|
|
1254
|
+
const px = ax + run.originX * s
|
|
1255
|
+
const py = ay - run.originY * s
|
|
1256
|
+
// Content painted outside the capture's own border box — a rotated leaf
|
|
1257
|
+
// overhanging its parent, say — lands off the MediaBox. It is written, but
|
|
1258
|
+
// no viewer will ever show it and no extractor will return it, so it is a
|
|
1259
|
+
// silent loss unless it is counted.
|
|
1260
|
+
if (px < 0 || px > pageW || py < 0 || py > pageH) offPage++
|
|
1261
|
+
const size = (run.size * s).toFixed(3)
|
|
1262
|
+
// CSS y grows down and PDF y grows up, so the linear part is conjugated
|
|
1263
|
+
// by the flip: b and c change sign, a and d do not.
|
|
1264
|
+
const m = run.matrix
|
|
1265
|
+
const tm = m
|
|
1266
|
+
? `${m.a.toFixed(5)} ${(-m.b).toFixed(5)} ${(-m.c).toFixed(5)} ${m.d.toFixed(5)}`
|
|
1267
|
+
: '1 0 0 1'
|
|
1268
|
+
// Vector text PAINTS the run as real filled glyphs from the embedded face,
|
|
1269
|
+
// instead of leaving it invisible over the raster. It is faithful only where
|
|
1270
|
+
// no shaping stands between codepoints and glyphs: a left-to-right run in an
|
|
1271
|
+
// embedded face, with a resolved, opaque colour. Everything else — RTL, a
|
|
1272
|
+
// face that did not cover the run, a translucent or unresolved colour, a
|
|
1273
|
+
// word-spacing Type0 cannot express — stays as it was, shown by the raster
|
|
1274
|
+
// or, in an image-free export, lost and counted.
|
|
1275
|
+
const paint = vectorText && embeddedUsed && run.color && !run.rtl
|
|
1276
|
+
let tr = debugText ? 0 : 3
|
|
1277
|
+
let colorOp = ''
|
|
1278
|
+
// Letter-spacing rides `Tc`, NOT Tz: folding it into the horizontal scale
|
|
1279
|
+
// would fatten every glyph (measured 15–31% at 1–4px). Only a painted run
|
|
1280
|
+
// applies it — an invisible run keeps its width-matching Tz untouched, so
|
|
1281
|
+
// selection geometry is unchanged. Tc is unscaled text space, hence ×s.
|
|
1282
|
+
let natEff = natural
|
|
1283
|
+
let tc = 0
|
|
1284
|
+
if (paint) {
|
|
1285
|
+
tr = 0
|
|
1286
|
+
colorOp = `${run.color.map(c => c.toFixed(3)).join(' ')} rg `
|
|
1287
|
+
if (run.tracking) {
|
|
1288
|
+
tc = run.tracking * s
|
|
1289
|
+
natEff = natural + [...run.text].length * run.tracking
|
|
1290
|
+
}
|
|
1291
|
+
vectorPainted++
|
|
1292
|
+
} else if (vectorText && !image && tr === 3) {
|
|
1293
|
+
// Nothing will show this run: no raster beneath it, and it was not painted.
|
|
1294
|
+
vectorMissed++
|
|
1295
|
+
}
|
|
1296
|
+
const tz = (natEff > 0 ? Math.max(1, Math.min(1000, (run.width / natEff) * 100)) : 100).toFixed(2)
|
|
1297
|
+
// Tc is written EVERY run, and BEFORE `BT` where it is still a legal text-
|
|
1298
|
+
// state op: it persists across runs in a stream, so a tracked run would
|
|
1299
|
+
// otherwise leak its spacing onto the next — including invisible ones, whose
|
|
1300
|
+
// selection width it would widen. Placing it before BT also keeps it out of
|
|
1301
|
+
// the `BT … Tr` shape the geometry oracle matches.
|
|
1302
|
+
ops.push(
|
|
1303
|
+
`${colorOp}${tc.toFixed(4)} Tc BT ${tr} Tr /${font.name} ${size} Tf ${tz} Tz ${tm} ` +
|
|
1304
|
+
`${px.toFixed(3)} ${py.toFixed(3)} Tm ${str} Tj ET`
|
|
1305
|
+
)
|
|
1306
|
+
return true
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Which runs live in which header band, so a repeated header carries the same
|
|
1310
|
+
// SELECTABLE text as the one it was cut from — a header that is only pixels on
|
|
1311
|
+
// page 2 is a header a search cannot find.
|
|
1312
|
+
const bandRuns = repeatBands.map((b) => {
|
|
1313
|
+
const list = []
|
|
1314
|
+
for (let i = 0; i < layer.runs.length; i++) {
|
|
1315
|
+
const cy = toContentY(layer.runs[i].originY)
|
|
1316
|
+
if (cy > b.top - 0.5 && cy <= b.bottom + 0.5) list.push(i)
|
|
1317
|
+
}
|
|
1318
|
+
return list
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
// ——— tagging ———
|
|
1322
|
+
//
|
|
1323
|
+
// The structure tree covers the TEXT LAYER and nothing else. Every pixel this
|
|
1324
|
+
// plugin draws is an /Artifact: the page image is a picture of the same words
|
|
1325
|
+
// the layer carries, so tagging it as content would have a screen reader
|
|
1326
|
+
// announce a full-page image over the text it duplicates — and a repeated table
|
|
1327
|
+
// header would be read once per page it repeats onto.
|
|
1328
|
+
const struct = tagged ? measured.structure : null
|
|
1329
|
+
if (struct) for (const note of struct.warnings) {
|
|
1330
|
+
// buildStructure is cached per capture while `lang` is an export option. A
|
|
1331
|
+
// per-export language fully satisfies the document-level requirement.
|
|
1332
|
+
if (lang && /no `lang`/i.test(note)) continue
|
|
1333
|
+
report(note)
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* `markOwners[key][mcid]` — what the parent tree is built from. Indexed by
|
|
1337
|
+
* /StructParents KEY, not by body page: a cover and an index are pages too, and
|
|
1338
|
+
* they are created after the body.
|
|
1339
|
+
*/
|
|
1340
|
+
const markOwners = slices.map(() => [])
|
|
1341
|
+
// Held beside the tree, never in it: `measured.structure` belongs to the
|
|
1342
|
+
// CAPTURE and a capture can be exported more than once. Writing marks into the
|
|
1343
|
+
// nodes made the second export inherit the first one's content — caught by the
|
|
1344
|
+
// plugin fixture, which exports the same capture twice and compares sizes.
|
|
1345
|
+
const marksOf = new Map()
|
|
1346
|
+
|
|
1347
|
+
const artifact = (ops, body) => {
|
|
1348
|
+
if (!body.length) return
|
|
1349
|
+
if (struct) ops.push('/Artifact BMC')
|
|
1350
|
+
ops.push(...body)
|
|
1351
|
+
if (struct) ops.push('EMC')
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Structure grafted in from pages that are not the capture. */
|
|
1355
|
+
const frontStruct = []
|
|
1356
|
+
const backStruct = []
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* A /StructParents key for a page built outside the body loop, plus the marker
|
|
1360
|
+
* its content goes through. Returns null when tagging is off, so a caller can
|
|
1361
|
+
* pass the result straight to `pageDict`.
|
|
1362
|
+
*/
|
|
1363
|
+
const matterMarks = () => {
|
|
1364
|
+
if (!struct) return null
|
|
1365
|
+
const key = markOwners.length
|
|
1366
|
+
markOwners.push([])
|
|
1367
|
+
return {
|
|
1368
|
+
key,
|
|
1369
|
+
mark: (node, pageId, body) => {
|
|
1370
|
+
const id = markOwners[key].length
|
|
1371
|
+
markOwners[key].push(node)
|
|
1372
|
+
const list = marksOf.get(node) || []
|
|
1373
|
+
list.push({ mcid: id, page: pageId })
|
|
1374
|
+
marksOf.set(node, list)
|
|
1375
|
+
return [`/${node.type} <</MCID ${id}>> BDC`, ...body, 'EMC']
|
|
1376
|
+
},
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Where each fillable control lands, computed once for two consumers that must
|
|
1382
|
+
* agree to the point: the page image, which STOPS painting the control's area,
|
|
1383
|
+
* and the annotation pass, which puts the widget on exactly that rectangle.
|
|
1384
|
+
*
|
|
1385
|
+
* The page image yields because two renderers exist. Viewers that honour a
|
|
1386
|
+
* widget's appearance stream (pdf.js, PDFium, Acrobat) draw our AP — the
|
|
1387
|
+
* control's own crop of the capture — so the field still looks pixel-exact.
|
|
1388
|
+
* Viewers that REGENERATE widget appearances from /V, /DA and /MK (macOS
|
|
1389
|
+
* Preview and everything on PDFKit) draw their own field instead; with the
|
|
1390
|
+
* raster still painting the old value underneath, every such viewer showed the
|
|
1391
|
+
* value twice, slightly offset — MEASURED with a headless PDFKit render. A
|
|
1392
|
+
* field's pixels must have one owner, and the field is it.
|
|
1393
|
+
*/
|
|
1394
|
+
const fieldPlacements = slices.map(() => [])
|
|
1395
|
+
if (fieldWriter) {
|
|
1396
|
+
for (const field of fieldPlan.fields) {
|
|
1397
|
+
for (const member of field.members) {
|
|
1398
|
+
const c = member.control
|
|
1399
|
+
const cy = toContentY(c.rect.top)
|
|
1400
|
+
const h = c.rect.height * k
|
|
1401
|
+
const w = c.rect.width * k
|
|
1402
|
+
const p = sliceOf(Math.max(0, cy + h / 2))
|
|
1403
|
+
const top = pageTopOf(p) - (cy - slices[p].top)
|
|
1404
|
+
const cx = toContentX(c.rect.left)
|
|
1405
|
+
if (cy < slices[p].top - 0.01 || cy + h > slices[p].top + slices[p].height + 0.01) {
|
|
1406
|
+
fieldWriter.straddle()
|
|
1407
|
+
}
|
|
1408
|
+
const llx = Math.max(0, Math.min(pageW, marginX + cx))
|
|
1409
|
+
const urx = Math.max(0, Math.min(pageW, marginX + cx + w))
|
|
1410
|
+
const ury = Math.max(0, Math.min(pageH, top))
|
|
1411
|
+
const lly = Math.max(0, Math.min(pageH, top - h))
|
|
1412
|
+
if (urx <= llx || ury <= lly) continue
|
|
1413
|
+
fieldPlacements[p].push({ field, member, rect: [llx, lly, urx, ury] })
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
for (let p = 0; p < slices.length; p++) {
|
|
1419
|
+
flow?.check()
|
|
1420
|
+
const slice = slices[p]
|
|
1421
|
+
const rep = repeated[p]
|
|
1422
|
+
const pageTop = pageTopOf(p)
|
|
1423
|
+
const ops = []
|
|
1424
|
+
const pageXObjects = furniture.map(f => `/${f.name} ${f.id} 0 R`)
|
|
1425
|
+
|
|
1426
|
+
/** One tagged run: marked content the structure tree can be walked back from. */
|
|
1427
|
+
const marked = (node, body) => {
|
|
1428
|
+
if (!node || !body.length) { ops.push(...body); return }
|
|
1429
|
+
const id = markOwners[p].length
|
|
1430
|
+
markOwners[p].push(node)
|
|
1431
|
+
const list = marksOf.get(node) || []
|
|
1432
|
+
list.push({ mcid: id, page: pageIds[p] })
|
|
1433
|
+
marksOf.set(node, list)
|
|
1434
|
+
ops.push(`/${node.type} <</MCID ${id}>> BDC`, ...body, 'EMC')
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Page image: drawn whole at 1:1 CSS px, clipped to this page's slice. The
|
|
1438
|
+
// bleed spills into the margin and the clip crops it.
|
|
1439
|
+
if (image) {
|
|
1440
|
+
const body = []
|
|
1441
|
+
const regions = cropsFor(slice.top, slice.height)
|
|
1442
|
+
for (let j = 0; j < regions.length; j++) {
|
|
1443
|
+
const crop = regions[j]
|
|
1444
|
+
const ref = await imageObject(crop)
|
|
1445
|
+
const name = page === 'fit'
|
|
1446
|
+
? `Im${j}`
|
|
1447
|
+
: `Ip${p}${j ? `_${j}` : ''}`
|
|
1448
|
+
pageXObjects.push(`/${name} ${ref.id} 0 R`)
|
|
1449
|
+
const contentY = (crop.y - offY) * k
|
|
1450
|
+
const regionTop = pageTop - (contentY - slice.top)
|
|
1451
|
+
drawImage(body, name, originFor(pageTop, slice.top), regionTop, crop.height * k, crop)
|
|
1452
|
+
}
|
|
1453
|
+
// A control that became a field leaves the page image: its area is filled
|
|
1454
|
+
// with the control's own background, so the widget — crop AP here, a
|
|
1455
|
+
// PDFKit-regenerated face on macOS — is the ONLY thing painting its value.
|
|
1456
|
+
for (const pl of fieldPlacements[p]) {
|
|
1457
|
+
const [llx, lly, urx, ury] = pl.rect
|
|
1458
|
+
const bg = pl.member.control.style.bg || [1, 1, 1]
|
|
1459
|
+
body.push(`${bg.map(v => v.toFixed(3)).join(' ')} rg ` +
|
|
1460
|
+
`${llx.toFixed(3)} ${lly.toFixed(3)} ${(urx - llx).toFixed(3)} ${(ury - lly).toFixed(3)} re f`)
|
|
1461
|
+
}
|
|
1462
|
+
artifact(ops, body)
|
|
1463
|
+
}
|
|
1464
|
+
if (debugText) ops.push('1 0 0 rg')
|
|
1465
|
+
|
|
1466
|
+
const pageFonts = new Set()
|
|
1467
|
+
|
|
1468
|
+
// Repeated headers, stacked down from the top margin — the same pixels of the
|
|
1469
|
+
// same raster, re-stamped. Nothing is re-drawn and nothing is synthesized:
|
|
1470
|
+
// what a reader sees at the top of page 2 is the crop of page 1 they already
|
|
1471
|
+
// saw, which is the only way this can keep the plugin's one promise.
|
|
1472
|
+
let bandTop = contentTop
|
|
1473
|
+
for (const b of rep.bands) {
|
|
1474
|
+
const height = b.bottom - b.top
|
|
1475
|
+
const origin = originFor(bandTop, b.top)
|
|
1476
|
+
// Wholly an artifact, pixels AND words: this header was already read on the
|
|
1477
|
+
// page it belongs to, and a reader that meets it again on every page is
|
|
1478
|
+
// being read a table's header five times.
|
|
1479
|
+
const body = []
|
|
1480
|
+
if (image) {
|
|
1481
|
+
const index = repeatBands.indexOf(b)
|
|
1482
|
+
const regions = cropsFor(b.top, height)
|
|
1483
|
+
for (let j = 0; j < regions.length; j++) {
|
|
1484
|
+
const crop = regions[j]
|
|
1485
|
+
const ref = await imageObject(crop)
|
|
1486
|
+
const name = `Ir${index}${j ? `_${j}` : ''}`
|
|
1487
|
+
pageXObjects.push(`/${name} ${ref.id} 0 R`)
|
|
1488
|
+
const contentY = (crop.y - offY) * k
|
|
1489
|
+
const regionTop = bandTop - (contentY - b.top)
|
|
1490
|
+
drawImage(body, name, origin, regionTop, crop.height * k, crop)
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (text) {
|
|
1494
|
+
for (const i of bandRuns[repeatBands.indexOf(b)]) {
|
|
1495
|
+
if (suppressedRuns.has(i)) continue
|
|
1496
|
+
if (!drawRun(body, pageFonts, layer.runs[i], bodyMap(origin))) dropped++
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
artifact(ops, body)
|
|
1500
|
+
bandTop -= height
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (text) {
|
|
1504
|
+
for (let i = 0; i < layer.runs.length; i++) {
|
|
1505
|
+
if (runPages[i] !== p) continue
|
|
1506
|
+
// A value that became a fillable field's /V leaves the invisible layer:
|
|
1507
|
+
// extraction must return it once, from the field.
|
|
1508
|
+
if (suppressedRuns.has(i)) continue
|
|
1509
|
+
const body = []
|
|
1510
|
+
if (!drawRun(body, pageFonts, layer.runs[i], bodyMap(originFor(pageTop, slice.top)))) {
|
|
1511
|
+
dropped++
|
|
1512
|
+
continue
|
|
1513
|
+
}
|
|
1514
|
+
marked(struct && struct.ofRun[i], body)
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Running heads, page numbers and the watermark are pagination furniture by
|
|
1519
|
+
// definition — they belong to the paper, not to the document — so every one of
|
|
1520
|
+
// them is an artifact and none reaches the structure tree.
|
|
1521
|
+
const trim = []
|
|
1522
|
+
for (const f of furniture) {
|
|
1523
|
+
trim.push(...bandOps({ where: f.where, name: f.name, heightPt: f.heightPt,
|
|
1524
|
+
pageH, marginX, marginY, availW }))
|
|
1525
|
+
}
|
|
1526
|
+
for (const [where, spec] of [['header', bands.header], ['footer', bands.footer]]) {
|
|
1527
|
+
if (!spec || spec.kind !== 'text') continue
|
|
1528
|
+
const band = textBandOps({ spec, where, page: p + 1, pages: slices.length,
|
|
1529
|
+
pageH, marginX, marginY, availW, pdfText })
|
|
1530
|
+
if (band) { for (const f of band.fonts) pageFonts.add(f); trim.push(...band.ops) }
|
|
1531
|
+
}
|
|
1532
|
+
if (numbers) {
|
|
1533
|
+
const n = numberOps({ spec: numbers, page: p + 1, pages: slices.length,
|
|
1534
|
+
pageW, marginX, marginY, availW, pdfText })
|
|
1535
|
+
if (n) { pageFonts.add(n.font); trim.push(...n.ops) }
|
|
1536
|
+
}
|
|
1537
|
+
// Last, so it is over everything: a watermark under the page image would be
|
|
1538
|
+
// invisible on any page with a background.
|
|
1539
|
+
trim.push(...markOps(pageFonts))
|
|
1540
|
+
artifact(ops, trim)
|
|
1541
|
+
|
|
1542
|
+
const annots = []
|
|
1543
|
+
if (links) {
|
|
1544
|
+
for (let j = 0; j < layer.links.length; j++) {
|
|
1545
|
+
const link = layer.links[j]
|
|
1546
|
+
const cy = toContentY(link.y)
|
|
1547
|
+
const h = link.height * k
|
|
1548
|
+
const w = link.width * k
|
|
1549
|
+
// A link may legitimately appear on both pages it spans.
|
|
1550
|
+
if (cy + h <= slice.top || cy >= slice.top + slice.height) continue
|
|
1551
|
+
const top = pageTop - (cy - slice.top)
|
|
1552
|
+
const cx = toContentX(link.x)
|
|
1553
|
+
const action = actionFor(link.href)
|
|
1554
|
+
if (!action) continue
|
|
1555
|
+
// Intersect with both the slice's painted content window and MediaBox.
|
|
1556
|
+
// A link that straddles a break becomes two trimmed annotations, never a
|
|
1557
|
+
// rectangle reaching into the next page or into margins/furniture.
|
|
1558
|
+
const id = addLinkAnnot({
|
|
1559
|
+
left: Math.max(marginX, marginX + cx),
|
|
1560
|
+
right: Math.min(marginX + availW, marginX + cx + w),
|
|
1561
|
+
bottom: Math.max(pageTop - slice.height, top - h),
|
|
1562
|
+
top: Math.min(pageTop, top),
|
|
1563
|
+
}, action, { node: struct && struct.ofLink && struct.ofLink[j], pageId: pageIds[p] })
|
|
1564
|
+
if (id) annots.push(`${id} 0 R`)
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Fillable fields whose control lands on this page, on the same rectangle
|
|
1569
|
+
// the page image just yielded. A valued field's appearance is real text
|
|
1570
|
+
// built from the control's own measured runs; an empty field's is the
|
|
1571
|
+
// control's crop of the capture. The split is PDFKit's doing — see forms.js.
|
|
1572
|
+
for (const pl of fieldPlacements[p]) {
|
|
1573
|
+
const { field, member } = pl
|
|
1574
|
+
const c = member.control
|
|
1575
|
+
let cropId = null
|
|
1576
|
+
// Only an EMPTY field's appearance is the control's raster crop — a valued
|
|
1577
|
+
// one gets real text (see forms.js) — so the encode runs only when the
|
|
1578
|
+
// appearance will reference it.
|
|
1579
|
+
if (image && wantsCropAP(field, c)) {
|
|
1580
|
+
const cropX = Math.max(0, Math.min(vbW, offX + (c.rect.left - rect.left)))
|
|
1581
|
+
const cropY = Math.max(0, Math.min(vbH, offY + (c.rect.top - rect.top)))
|
|
1582
|
+
const cropW = Math.min(vbW - cropX, c.rect.width)
|
|
1583
|
+
const cropH = Math.min(vbH - cropY, c.rect.height)
|
|
1584
|
+
if (cropW > 0 && cropH > 0) {
|
|
1585
|
+
const ref = await imageObject({ x: cropX, y: cropY, width: cropW, height: cropH })
|
|
1586
|
+
cropId = ref.id
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
// The control's own value runs, re-based into the widget's box: the same
|
|
1590
|
+
// wrap, alignment and scroll the page painted, now drawn as the field's
|
|
1591
|
+
// appearance text. A password's runs are its bullets, and an EMPTY field
|
|
1592
|
+
// must look empty — so it contributes none.
|
|
1593
|
+
const lines = field.kind === 'password' ? []
|
|
1594
|
+
: (c.runs || []).map(i => layer.runs[i]).filter(Boolean).map(run => ({
|
|
1595
|
+
text: run.text,
|
|
1596
|
+
x: (run.originX - c.rect.left) * k,
|
|
1597
|
+
y: (c.rect.top + c.rect.height - run.originY) * k,
|
|
1598
|
+
size: run.size * k,
|
|
1599
|
+
}))
|
|
1600
|
+
const id = fieldWriter.widget(field, member, pl.rect, c.style.size * k, cropId, lines)
|
|
1601
|
+
annots.push(`${id} 0 R`)
|
|
1602
|
+
if (struct && struct.ofControl && struct.ofControl[member.i]) {
|
|
1603
|
+
annotStruct.push({
|
|
1604
|
+
id, dict: null, member,
|
|
1605
|
+
node: struct.ofControl[member.i], pageId: pageIds[p],
|
|
1606
|
+
})
|
|
1607
|
+
}
|
|
1608
|
+
annotCount++
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// debugText exists to be read, so it keeps its content stream in the clear.
|
|
1612
|
+
const body = ops.join('\n')
|
|
1613
|
+
const deflated = wantDeflate && canDeflate && !debugText
|
|
1614
|
+
const contentId = doc.addStream(
|
|
1615
|
+
deflated ? '/Filter /FlateDecode' : '',
|
|
1616
|
+
deflated ? await deflate(body) : body
|
|
1617
|
+
)
|
|
1618
|
+
doc.fill(pageIds[p],
|
|
1619
|
+
pageDict({
|
|
1620
|
+
contentId, annots, fonts: pageFonts, structParents: struct ? p : null,
|
|
1621
|
+
xobjects: pageXObjects,
|
|
1622
|
+
})
|
|
1623
|
+
)
|
|
1624
|
+
flow?.tick('page', p + 1, slices.length)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// ——— front and back matter ———
|
|
1628
|
+
//
|
|
1629
|
+
// Built AFTER the body, because none of it can be built before: an index needs
|
|
1630
|
+
// to know which page each heading landed on, and that is not knowable until the
|
|
1631
|
+
// body has been cut. Page objects are added in whatever order suits; only the
|
|
1632
|
+
// /Kids array below decides what comes first.
|
|
1633
|
+
if (page === 'fit' && (cover || back || toc)) {
|
|
1634
|
+
report("cover, back and toc need a paginated page size — they do nothing on page:'fit'.")
|
|
1635
|
+
}
|
|
1636
|
+
const paginated = page !== 'fit'
|
|
1637
|
+
|
|
1638
|
+
/** One finished page from a list of ops — the same stream handling the body gets. */
|
|
1639
|
+
const addPage = async (ops, { annots = [], fonts, xobjects = [] }) => {
|
|
1640
|
+
const body = ops.join('\n')
|
|
1641
|
+
const deflated = wantDeflate && canDeflate && !debugText
|
|
1642
|
+
const contentId = doc.addStream(
|
|
1643
|
+
deflated ? '/Filter /FlateDecode' : '',
|
|
1644
|
+
deflated ? await deflate(body) : body
|
|
1645
|
+
)
|
|
1646
|
+
return doc.add(pageDict({ contentId, annots, fonts, xobjects }))
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* A cover or a colophon: your own element, captured, one to a page — and given
|
|
1651
|
+
* the SAME invisible text layer the body gets.
|
|
1652
|
+
*
|
|
1653
|
+
* That layer is not a nicety here. A closing page is where the legal text goes,
|
|
1654
|
+
* and legal text nobody can search or copy is a picture of a contract. It is
|
|
1655
|
+
* measured from that element's own final SnapDOM artifact at export, so the
|
|
1656
|
+
* same exclusion, resolver and clone-plugin policy governs pixels and semantics.
|
|
1657
|
+
*/
|
|
1658
|
+
const matterPage = async (el, name, where) => {
|
|
1659
|
+
// Auxiliary pages get their own final SnapDOM artifact. Measuring `el`
|
|
1660
|
+
// directly would bypass exclude/filter/resolveNode/afterClone and could put a
|
|
1661
|
+
// redacted secret back into text, links, outline or structure.
|
|
1662
|
+
const shot = await captureElement(el, {
|
|
1663
|
+
scale: options.scale, dpr: options.dpr, backgroundColor: null, quality, codec,
|
|
1664
|
+
captureOptions: options.captureOptions,
|
|
1665
|
+
image,
|
|
1666
|
+
}, image ? encodeOpaque : null)
|
|
1667
|
+
const subMeasured = await measureArtifact(shot.url, shot.meta, {
|
|
1668
|
+
formValues: measured.formValues !== false,
|
|
1669
|
+
shadow: measured.shadow !== false,
|
|
1670
|
+
breakAvoid: false,
|
|
1671
|
+
outline: false,
|
|
1672
|
+
// A cover or colophon is furniture around the document; a fillable field
|
|
1673
|
+
// on one would be a form nobody expects to submit from a title page.
|
|
1674
|
+
forms: false,
|
|
1675
|
+
lang: lang || undefined,
|
|
1676
|
+
}, shot.source)
|
|
1677
|
+
// A matter page is measured in isolation, so a valid link to the body appears
|
|
1678
|
+
// unresolved here. `actionFor` below resolves it against the combined document
|
|
1679
|
+
// and reports only links that are still genuinely unresolved.
|
|
1680
|
+
for (const note of subMeasured.notes) {
|
|
1681
|
+
if (!/^\d+ internal link\(s\) point outside the final captured tree or clip/.test(note)) {
|
|
1682
|
+
report(`${where}: ${note}`)
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
let xid = null
|
|
1686
|
+
if (image) {
|
|
1687
|
+
const mask = shot.smask ? ` /SMask ${doc.addStream(shot.smask.dict, shot.smask.bytes)} 0 R` : ''
|
|
1688
|
+
xid = doc.addStream(shot.dict + mask, shot.bytes)
|
|
1689
|
+
}
|
|
1690
|
+
const fonts = new Set()
|
|
1691
|
+
const marks = matterMarks()
|
|
1692
|
+
const pageId = doc.reserve()
|
|
1693
|
+
const ops = []
|
|
1694
|
+
if (image) artifact(ops, fullPageOps({ name, pageW, pageH, aspect: shot.aspect }))
|
|
1695
|
+
|
|
1696
|
+
const annots = []
|
|
1697
|
+
if (text || links || struct) {
|
|
1698
|
+
// The same fit `fullPageOps` computed, recovered rather than passed: one of
|
|
1699
|
+
// them would drift the day the other changed.
|
|
1700
|
+
const ratio = shot.aspect > 0 ? shot.aspect : 1
|
|
1701
|
+
let w = pageW
|
|
1702
|
+
let h = w * ratio
|
|
1703
|
+
if (h > pageH) { h = pageH; w = h / ratio }
|
|
1704
|
+
const s = shot.vbW > 0 ? w / shot.vbW : 1
|
|
1705
|
+
const box = subMeasured.rect
|
|
1706
|
+
const map = {
|
|
1707
|
+
ax: (pageW - w) / 2 + (shot.offX - box.left) * s,
|
|
1708
|
+
ay: (pageH - h) / 2 + h + (box.top - shot.offY) * s,
|
|
1709
|
+
s,
|
|
1710
|
+
}
|
|
1711
|
+
let collisions = 0
|
|
1712
|
+
for (const [destName, at] of subMeasured.nav?.dests || []) {
|
|
1713
|
+
// Body destinations are established first and always win. This keeps a
|
|
1714
|
+
// cover accidentally reusing `#intro` from retargeting every body link.
|
|
1715
|
+
if (dests.has(destName)) { collisions++; continue }
|
|
1716
|
+
const x = at.top ? 0 : Math.min(pageW, Math.max(0, map.ax + at.x * map.s))
|
|
1717
|
+
const y = at.top ? pageH : Math.min(pageH, Math.max(0, map.ay - at.y * map.s))
|
|
1718
|
+
dests.set(destName,
|
|
1719
|
+
`[${pageId} 0 R /XYZ ${x.toFixed(3)} ${y.toFixed(3)} null]`)
|
|
1720
|
+
}
|
|
1721
|
+
if (collisions) {
|
|
1722
|
+
report(`${where}: ${collisions} destination name collision(s) kept the body destination.`)
|
|
1723
|
+
}
|
|
1724
|
+
if (debugText) ops.push('1 0 0 rg')
|
|
1725
|
+
const sub = subMeasured.layer
|
|
1726
|
+
unnamedImages += sub.unnamedImages
|
|
1727
|
+
// A matter page was measured from its own artifact, so its font keys index
|
|
1728
|
+
// its own table — resolved here against its own rules, sharing the byte
|
|
1729
|
+
// cache (a cover set in the body's face parses the binary once).
|
|
1730
|
+
const subFaces = await facesFor(sub.fontKeys, sub.runs, subMeasured.fontRules)
|
|
1731
|
+
// Its own structure, grafted under a Sect: a colophon is a section of the
|
|
1732
|
+
// document, and untagged content inside a tagged file is content a reader is
|
|
1733
|
+
// entitled to skip.
|
|
1734
|
+
const subStruct = struct ? subMeasured.structure : null
|
|
1735
|
+
const section = subStruct && { type: 'Sect', alt: null, lang: null, children: [] }
|
|
1736
|
+
if (text) {
|
|
1737
|
+
for (let i = 0; i < sub.runs.length; i++) {
|
|
1738
|
+
const body = []
|
|
1739
|
+
if (!drawRun(body, fonts, sub.runs[i], map, subFaces)) { dropped++; continue }
|
|
1740
|
+
const node = subStruct && subStruct.ofRun[i]
|
|
1741
|
+
if (node && marks) ops.push(...marks.mark(node, pageId, body))
|
|
1742
|
+
else ops.push(...body)
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
if (section) {
|
|
1746
|
+
section.children = subStruct.root.children
|
|
1747
|
+
;(where === 'cover' ? frontStruct : backStruct).push(section)
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
if (links) {
|
|
1751
|
+
for (let j = 0; j < sub.links.length; j++) {
|
|
1752
|
+
const link = sub.links[j]
|
|
1753
|
+
const action = actionFor(link.href, subMeasured.documentURL)
|
|
1754
|
+
if (!action) continue
|
|
1755
|
+
const id = addLinkAnnot({
|
|
1756
|
+
left: map.ax + link.x * map.s,
|
|
1757
|
+
bottom: map.ay - (link.y + link.height) * map.s,
|
|
1758
|
+
right: map.ax + (link.x + link.width) * map.s,
|
|
1759
|
+
top: map.ay - link.y * map.s,
|
|
1760
|
+
}, action, {
|
|
1761
|
+
node: subStruct && subStruct.ofLink && subStruct.ofLink[j],
|
|
1762
|
+
pageId,
|
|
1763
|
+
})
|
|
1764
|
+
if (id) annots.push(`${id} 0 R`)
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
artifact(ops, markOps(fonts))
|
|
1770
|
+
const body = ops.join('\n')
|
|
1771
|
+
const deflated = wantDeflate && canDeflate && !debugText
|
|
1772
|
+
const contentId = doc.addStream(
|
|
1773
|
+
deflated ? '/Filter /FlateDecode' : '',
|
|
1774
|
+
deflated ? await deflate(body) : body
|
|
1775
|
+
)
|
|
1776
|
+
doc.fill(pageId, pageDict({
|
|
1777
|
+
contentId, annots, fonts, xobjects: image ? [`/${name} ${xid} 0 R`] : [],
|
|
1778
|
+
structParents: marks ? marks.key : null,
|
|
1779
|
+
}))
|
|
1780
|
+
return pageId
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const frontIds = []
|
|
1784
|
+
const backIds = []
|
|
1785
|
+
if (paginated && cover && cover.nodeType === 1) {
|
|
1786
|
+
flow?.check()
|
|
1787
|
+
const id = await matterPage(cover, 'Ic', 'cover')
|
|
1788
|
+
if (id) frontIds.push(id)
|
|
1789
|
+
flow?.tick('matter')
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* One index line's title.
|
|
1794
|
+
*
|
|
1795
|
+
* Base-14 text when WinAnsi can address it — crisp at any zoom, a few dozen
|
|
1796
|
+
* bytes. When it cannot, the answer is NOT to paint the glyph-less Type0 font:
|
|
1797
|
+
* it is to CROP THE HEADING OUT OF THE PAGE RASTER, the same re-stamp a
|
|
1798
|
+
* repeated table header uses, scaled down to the line. The heading's pixels
|
|
1799
|
+
* already exist in the file; a Japanese index reads because snapdom drew it,
|
|
1800
|
+
* not because a font was embedded. The invisible text goes over the crop, so
|
|
1801
|
+
* the line is still searchable and copyable.
|
|
1802
|
+
*
|
|
1803
|
+
* @returns {{ops: string[], width: number, fonts: object[]}|null}
|
|
1804
|
+
*/
|
|
1805
|
+
let cropped = 0
|
|
1806
|
+
const tocImages = new Map()
|
|
1807
|
+
/**
|
|
1808
|
+
* How far the glyphs inside a heading's box actually reach, in viewport CSS px.
|
|
1809
|
+
* Falls back to the box when nothing in the layer sits there — a heading that
|
|
1810
|
+
* is an image, or one whose runs were dropped as clipped.
|
|
1811
|
+
*/
|
|
1812
|
+
const inkWidth = (box) => {
|
|
1813
|
+
let right = box.x
|
|
1814
|
+
for (const run of layer.runs) {
|
|
1815
|
+
if (run.originY < box.y - 1 || run.originY > box.y + box.h + 1) continue
|
|
1816
|
+
if (run.originX < box.x - 1) continue
|
|
1817
|
+
const edge = run.originX + run.width
|
|
1818
|
+
if (edge <= box.x + box.w + 1 && edge > right) right = edge
|
|
1819
|
+
}
|
|
1820
|
+
return right > box.x ? right - box.x : box.w
|
|
1821
|
+
}
|
|
1822
|
+
const drawTitle = ({ entry, key, size, x, baseline, room, colour }) => {
|
|
1823
|
+
const flat = pdfText(entry.title, key, size, { paint: false })
|
|
1824
|
+
if (flat && flat.paintable) {
|
|
1825
|
+
const title = fitText(entry.title, room, (t) => {
|
|
1826
|
+
const m = pdfText(t, key, size, { paint: false })
|
|
1827
|
+
return m ? m.width : Infinity
|
|
1828
|
+
})
|
|
1829
|
+
const drawn = title ? pdfText(title, key, size, { paint: false }) : null
|
|
1830
|
+
if (!drawn) return null
|
|
1831
|
+
return {
|
|
1832
|
+
width: drawn.width,
|
|
1833
|
+
fonts: [drawn.font],
|
|
1834
|
+
ops: [
|
|
1835
|
+
`${colour.map(c => c.toFixed(3)).join(' ')} rg`,
|
|
1836
|
+
`BT 0 Tr /${drawn.font.name} ${size} Tf 1 0 0 1 ` +
|
|
1837
|
+
`${x.toFixed(3)} ${baseline.toFixed(3)} Tm ${drawn.str} Tj ET`,
|
|
1838
|
+
],
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Everything WinAnsi cannot address. Without the raster there is nothing to
|
|
1843
|
+
// crop from, and nothing this plugin can honestly paint.
|
|
1844
|
+
const box = entry.box
|
|
1845
|
+
const cropImage = tocImages.get(entry)
|
|
1846
|
+
if (!image || !box || !cropImage || !(box.w > 0) || !(box.h > 0)) { unpaintable++; return null }
|
|
1847
|
+
// The heading's INK, not its box: a block-level heading's box is the whole
|
|
1848
|
+
// column, and cropping that would leave the dot leader starting a third of
|
|
1849
|
+
// the way across the page with nothing before it. The runs already know where
|
|
1850
|
+
// the glyphs stop.
|
|
1851
|
+
const bw = inkWidth(box) * k
|
|
1852
|
+
const bh = box.h * k
|
|
1853
|
+
// Scale the heading down to the index line's own type size.
|
|
1854
|
+
const scale = (size * 1.2) / bh
|
|
1855
|
+
const naturalWidth = bw * scale
|
|
1856
|
+
const width = Math.min(naturalWidth, room)
|
|
1857
|
+
const height = bh * scale
|
|
1858
|
+
const top = baseline + size
|
|
1859
|
+
|
|
1860
|
+
// This XObject is already the heading's viewBox crop, so it can be placed
|
|
1861
|
+
// directly. No full-document raster is allocated merely to paint one title.
|
|
1862
|
+
const ops = [
|
|
1863
|
+
'q',
|
|
1864
|
+
`${x.toFixed(3)} ${(top - height).toFixed(3)} ${width.toFixed(3)} ${height.toFixed(3)} re W n`,
|
|
1865
|
+
`${naturalWidth.toFixed(3)} 0 0 ${height.toFixed(3)} ${x.toFixed(3)} ${(top - height).toFixed(3)} cm`,
|
|
1866
|
+
`/${cropImage.name} Do`,
|
|
1867
|
+
'Q',
|
|
1868
|
+
]
|
|
1869
|
+
const fonts = []
|
|
1870
|
+
// Invisible, so the line is searchable and copyable — the sanctioned use of
|
|
1871
|
+
// the glyph-less font, and the only one.
|
|
1872
|
+
const hidden = pdfText(entry.title, key, size, { paint: false })
|
|
1873
|
+
if (hidden) {
|
|
1874
|
+
fonts.push(hidden.font)
|
|
1875
|
+
const tz = hidden.width > 0
|
|
1876
|
+
? Math.max(1, Math.min(1000, (width / hidden.width) * 100)).toFixed(2)
|
|
1877
|
+
: '100'
|
|
1878
|
+
ops.push(`BT 3 Tr /${hidden.font.name} ${size} Tf ${tz} Tz 1 0 0 1 ` +
|
|
1879
|
+
`${x.toFixed(3)} ${baseline.toFixed(3)} Tm ${hidden.str} Tj ET`)
|
|
1880
|
+
}
|
|
1881
|
+
cropped++
|
|
1882
|
+
return { ops, width, fonts, xobjects: [`/${cropImage.name} ${cropImage.id} 0 R`] }
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
const index = tocSpec(paginated ? toc : null)
|
|
1886
|
+
if (index) {
|
|
1887
|
+
// Depth from the outline TREE, not the heading level: a document that starts
|
|
1888
|
+
// at h2 should still show its two tiers, and a level gap is not an indent.
|
|
1889
|
+
const entries = []
|
|
1890
|
+
const gather = (nodes, depth) => {
|
|
1891
|
+
if (depth >= index.levels) return
|
|
1892
|
+
for (const node of nodes) {
|
|
1893
|
+
entries.push({
|
|
1894
|
+
depth,
|
|
1895
|
+
title: node.title,
|
|
1896
|
+
// Carried all the way from `collectNav`: without the heading's own box
|
|
1897
|
+
// there is nothing for a non-Latin line to crop.
|
|
1898
|
+
box: node.box || null,
|
|
1899
|
+
dest: dests.get(node.dest) || null,
|
|
1900
|
+
label: index.label(destPage.has(node.dest) ? destPage.get(node.dest) + 1 : 1, slices.length),
|
|
1901
|
+
})
|
|
1902
|
+
gather(node.children, depth + 1)
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
gather(outlineTree(nav?.outline || []), 0)
|
|
1906
|
+
|
|
1907
|
+
// Pre-decode one small crop for each non-WinAnsi title. tocLayout/drawTitle
|
|
1908
|
+
// stay synchronous, while the expensive raster work remains explicit here.
|
|
1909
|
+
if (image) {
|
|
1910
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1911
|
+
const entry = entries[i]
|
|
1912
|
+
const sample = pdfText(entry.title, index.font, index.size, { paint: false })
|
|
1913
|
+
if (sample?.paintable || !entry.box) continue
|
|
1914
|
+
const box = entry.box
|
|
1915
|
+
const left = Math.max(0, offX + (box.x - rect.left))
|
|
1916
|
+
const top = Math.max(0, offY + (box.y - rect.top))
|
|
1917
|
+
const right = Math.min(vbW, left + inkWidth(box))
|
|
1918
|
+
const bottom = Math.min(vbH, top + box.h)
|
|
1919
|
+
if (!(right > left) || !(bottom > top)) continue
|
|
1920
|
+
const ref = await imageObject({ x: left, y: top, width: right - left, height: bottom - top })
|
|
1921
|
+
tocImages.set(entry, { name: `It${i}`, id: ref.id })
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (!entries.length) {
|
|
1926
|
+
report('toc was asked for but the document has no headings to put in one — no index page written.')
|
|
1927
|
+
} else {
|
|
1928
|
+
// One /TOC element for the whole index, however many pages it runs to. Its
|
|
1929
|
+
// lines are generated navigation rather than prose, so they go out as ONE
|
|
1930
|
+
// marked sequence per page instead of one per line: a reader is told "this
|
|
1931
|
+
// is a table of contents" and then reads it in order, which is what a table
|
|
1932
|
+
// of contents is for.
|
|
1933
|
+
const tocNode = struct ? { type: 'TOC', alt: null, lang: null, children: [] } : null
|
|
1934
|
+
if (tocNode) frontStruct.push(tocNode)
|
|
1935
|
+
|
|
1936
|
+
for (const built of tocLayout({
|
|
1937
|
+
entries, spec: index, pdfText, drawTitle, marginX, availW, contentTop, availH,
|
|
1938
|
+
})) {
|
|
1939
|
+
const annots = links ? built.annots.map(a => {
|
|
1940
|
+
annotCount++
|
|
1941
|
+
return `${doc.add(
|
|
1942
|
+
`<< /Type /Annot /Subtype /Link /Border [0 0 0] ` +
|
|
1943
|
+
`/Rect [${a.rect.map(n => n.toFixed(3)).join(' ')}] /Dest ${a.dest} >>`
|
|
1944
|
+
)} 0 R`
|
|
1945
|
+
}) : []
|
|
1946
|
+
const marks = matterMarks()
|
|
1947
|
+
const pageId = doc.reserve()
|
|
1948
|
+
const ops = marks
|
|
1949
|
+
? marks.mark(tocNode, pageId, built.ops)
|
|
1950
|
+
: [...built.ops]
|
|
1951
|
+
artifact(ops, markOps(built.fonts))
|
|
1952
|
+
const body = ops.join('\n')
|
|
1953
|
+
const deflated = wantDeflate && canDeflate && !debugText
|
|
1954
|
+
const contentId = doc.addStream(
|
|
1955
|
+
deflated ? '/Filter /FlateDecode' : '',
|
|
1956
|
+
deflated ? await deflate(body) : body
|
|
1957
|
+
)
|
|
1958
|
+
doc.fill(pageId, pageDict({
|
|
1959
|
+
contentId, annots, fonts: built.fonts, xobjects: [...built.xobjects],
|
|
1960
|
+
structParents: marks ? marks.key : null,
|
|
1961
|
+
}))
|
|
1962
|
+
frontIds.push(pageId)
|
|
1963
|
+
flow?.tick('matter')
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
if (paginated && back && back.nodeType === 1) {
|
|
1969
|
+
flow?.check()
|
|
1970
|
+
const id = await matterPage(back, 'Ib', 'back')
|
|
1971
|
+
if (id) backIds.push(id)
|
|
1972
|
+
flow?.tick('matter')
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (unlinked) {
|
|
1976
|
+
report(`${unlinked} in-document link(s) got no annotation — their fragment has no destination here.`)
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// `quality` only ever reaches the JPEG candidate. When every region compressed
|
|
1980
|
+
// smaller losslessly, a lowered quality changed nothing at all — same bytes,
|
|
1981
|
+
// same size — and saying so is the difference between a knob that looks broken
|
|
1982
|
+
// and one that was simply not the knob for this document.
|
|
1983
|
+
if (codec === 'auto' && quality < 0.92 && losslessRegions && !jpegRegions) {
|
|
1984
|
+
report(`quality ${quality} changed nothing: all ${losslessRegions} page image(s) compressed ` +
|
|
1985
|
+
'smaller LOSSLESSLY than any JPEG, so the JPEG quality controls was discarded. Page artwork ' +
|
|
1986
|
+
'is flat runs and sharp edges, which deflate handles better than DCT. For a lighter file ' +
|
|
1987
|
+
"lower the capture's `scale`/`dpr`, or force `codec: 'jpeg'` and accept the artefacts.")
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Counted across every page in the file, front matter included, and therefore
|
|
1991
|
+
// reported only once everything has been drawn.
|
|
1992
|
+
if (offPage) {
|
|
1993
|
+
report(`${offPage} text run(s) land outside the page box and will not be selectable — ` +
|
|
1994
|
+
'content painted outside the capture element is not in the raster either.')
|
|
1995
|
+
}
|
|
1996
|
+
if (dropped) {
|
|
1997
|
+
report(`${dropped} text run(s) left out of the text layer (more than 65,534 distinct codepoints).`)
|
|
1998
|
+
}
|
|
1999
|
+
if (vectorText && vectorPainted) {
|
|
2000
|
+
report(`${vectorPainted} text run(s) painted as real vector glyphs from the embedded fonts — ` +
|
|
2001
|
+
'crisp at any zoom' + (image ? ', over the raster.' : ', and the raster is off.'))
|
|
2002
|
+
}
|
|
2003
|
+
if (vectorMissed) {
|
|
2004
|
+
report(`${vectorMissed} text run(s) could not be painted as vector (RTL, no embedded face, or an ` +
|
|
2005
|
+
'unresolved colour) and there is no raster to show them — they are searchable but not visible. ' +
|
|
2006
|
+
'Keep image:true for those, or embed a covering font.')
|
|
2007
|
+
}
|
|
2008
|
+
if (cropped) {
|
|
2009
|
+
report(`${cropped} index line(s) show the heading's own pixels rather than typeset text — ` +
|
|
2010
|
+
'their script is outside base-14, and this plugin embeds no fonts. They stay searchable.')
|
|
2011
|
+
}
|
|
2012
|
+
if (unpaintable) {
|
|
2013
|
+
report(`${unpaintable} piece(s) of page furniture could not be drawn: the plugin's own text is ` +
|
|
2014
|
+
'base-14 only and cannot address that script. Put it in an element instead — header, footer ' +
|
|
2015
|
+
'and watermark all take one, and elements are captured.')
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
flow?.check()
|
|
2019
|
+
flow?.tick('assemble')
|
|
2020
|
+
|
|
2021
|
+
// ——— structured annotations and fields ———
|
|
2022
|
+
//
|
|
2023
|
+
// Only now can /StructParent keys exist: every page — body, cover, contents,
|
|
2024
|
+
// colophon — has claimed its /StructParents key, and annotation keys must
|
|
2025
|
+
// continue past ALL of them in the one shared number tree. Links reserved
|
|
2026
|
+
// their dictionaries; widgets waited for their parent fields. Both get their
|
|
2027
|
+
// key, their bytes, and their /OBJR back into the owning node here.
|
|
2028
|
+
const widgetKeys = new Map()
|
|
2029
|
+
const annotRefs = []
|
|
2030
|
+
{
|
|
2031
|
+
let nextKey = markOwners.length
|
|
2032
|
+
for (const entry of annotStruct) {
|
|
2033
|
+
const key = nextKey++
|
|
2034
|
+
if (entry.dict !== null) doc.fill(entry.id, entry.dict + ` /StructParent ${key} >>`)
|
|
2035
|
+
else widgetKeys.set(entry.member, key)
|
|
2036
|
+
const list = marksOf.get(entry.node) || []
|
|
2037
|
+
list.push({ objr: entry.id, page: entry.pageId })
|
|
2038
|
+
marksOf.set(entry.node, list)
|
|
2039
|
+
annotRefs.push({ key, node: entry.node })
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
const acroForm = fieldWriter
|
|
2043
|
+
? fieldWriter.finish(member => widgetKeys.get(member) ?? null, fieldPlan.needAppearances)
|
|
2044
|
+
: null
|
|
2045
|
+
|
|
2046
|
+
// Writes the ToUnicode CMap, so it has to run after the last encode().
|
|
2047
|
+
unicodeFont.finalize()
|
|
2048
|
+
// The embedded programs deflate here — after the last encode named its glyphs,
|
|
2049
|
+
// before the build wants their bytes.
|
|
2050
|
+
for (const f of embeddedFonts.values()) await f.finalize()
|
|
2051
|
+
if (embeddedFonts.size) {
|
|
2052
|
+
const faces = [...embeddedFonts.values()].filter(f => f.active)
|
|
2053
|
+
if (faces.length) {
|
|
2054
|
+
const subset = faces.every(f => f.subset)
|
|
2055
|
+
report(`fonts embedded: ${faces.map(f =>
|
|
2056
|
+
`${f.label} (${f.kB} kB${f.subset ? ` subset of ${f.wholeKB} kB` : ' whole'})`).join(', ')} — ` +
|
|
2057
|
+
(subset
|
|
2058
|
+
? 'only the glyphs the page drew travel, renumbered into a dense subset.'
|
|
2059
|
+
: 'the whole file travels; glyphs are the ones the page was painted with.'))
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
if (coverageFallbacks) {
|
|
2063
|
+
report(`${coverageFallbacks} run(s) paint characters their embedded face lacks — those runs ` +
|
|
2064
|
+
'fall back to the unembedded layer and stay searchable.')
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// Bookmarks and named destinations. The name tree is written whether or not
|
|
2068
|
+
// there is an outline, because it is what makes `report.pdf#intro` work from a
|
|
2069
|
+
// href, an email or another PDF — a cross-reference into this file, from
|
|
2070
|
+
// outside it, without anything here knowing who is pointing.
|
|
2071
|
+
// `collectNav` speaks in destination NAMES, because a name is all it can know
|
|
2072
|
+
// before pagination exists. Only here is a name a point on a page, so this is
|
|
2073
|
+
// where the tree stops naming and starts pointing.
|
|
2074
|
+
const pointAt = (nodes) => nodes.map(node => ({
|
|
2075
|
+
title: node.title,
|
|
2076
|
+
dest: dests.get(node.dest) || null,
|
|
2077
|
+
children: pointAt(node.children),
|
|
2078
|
+
}))
|
|
2079
|
+
const outlineId = writeOutlines(doc, pointAt(outlineTree(nav?.outline || [])))
|
|
2080
|
+
const namesId = writeNameTree(doc, dests)
|
|
2081
|
+
|
|
2082
|
+
// Structure last: a node only earns its place once something on a page pointed
|
|
2083
|
+
// back at it, and that is only known after every page has been drawn.
|
|
2084
|
+
// Front matter reads first, back matter last — the order of the PAGES, which is
|
|
2085
|
+
// not the order they were built in.
|
|
2086
|
+
const structRoot = struct && {
|
|
2087
|
+
type: 'Document', alt: null, lang: null,
|
|
2088
|
+
children: [...frontStruct, ...struct.root.children, ...backStruct],
|
|
2089
|
+
}
|
|
2090
|
+
const structId = struct
|
|
2091
|
+
? writeStructTree(doc, structRoot, markOwners.map((_, i) => i), markOwners, marksOf, annotRefs)
|
|
2092
|
+
: null
|
|
2093
|
+
|
|
2094
|
+
// The only place page ORDER is decided. Everything above added its objects
|
|
2095
|
+
// whenever it happened to be built.
|
|
2096
|
+
const kids = [...frontIds, ...pageIds, ...backIds]
|
|
2097
|
+
doc.fill(pagesId, `<< /Type /Pages /Kids [${kids.map(id => `${id} 0 R`).join(' ')}] /Count ${kids.length} >>`)
|
|
2098
|
+
|
|
2099
|
+
// PDF/UA-1 eligibility, COMPUTED rather than configured. The claim is written
|
|
2100
|
+
// only when this export can show its work: every text-showing operation rode
|
|
2101
|
+
// an embedded face, the structure tree exists, the document says its language
|
|
2102
|
+
// and displays its title. Anything short of that is a named blocker — and the
|
|
2103
|
+
// absence of a claim, never a lie in the XMP.
|
|
2104
|
+
const fieldsWritten = fieldWriter ? fieldPlan.fields.length : 0
|
|
2105
|
+
const uaBlockers = []
|
|
2106
|
+
if (!embedFonts) uaBlockers.push('embedFonts: false')
|
|
2107
|
+
if (!struct) uaBlockers.push('tagged: false')
|
|
2108
|
+
if (!(lang || struct?.lang)) uaBlockers.push('no document language')
|
|
2109
|
+
if (!docMetaSpec?.title) uaBlockers.push('no meta.title to display')
|
|
2110
|
+
if (!embeddedFonts.size) uaBlockers.push('no embeddable @font-face behind the text')
|
|
2111
|
+
if (usedFonts.size) {
|
|
2112
|
+
const stacks = new Set()
|
|
2113
|
+
for (const run of layer.runs) {
|
|
2114
|
+
if (run.fk >= 0 && !bodyFaces[run.fk] && layer.fontKeys[run.fk]) {
|
|
2115
|
+
stacks.add(layer.fontKeys[run.fk].stack.split(',')[0].trim().replace(/^["']|["']$/g, ''))
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
uaBlockers.push(`base-14 text remains${stacks.size ? ` (system or unfetched: ${[...stacks].slice(0, 4).join(', ')})` : ''}`)
|
|
2119
|
+
}
|
|
2120
|
+
if (unicodeFont.id !== null) uaBlockers.push('glyph-less fallback text remains')
|
|
2121
|
+
if (fieldsWritten) uaBlockers.push('fillable fields type in unembedded Helvetica')
|
|
2122
|
+
if (unnamedImages) uaBlockers.push(`${unnamedImages} image(s) without alternative text`)
|
|
2123
|
+
const claimUA = uaBlockers.length === 0
|
|
2124
|
+
if (claimUA) {
|
|
2125
|
+
report('document claims PDF/UA-1: every text operation uses an embedded face, structure is ' +
|
|
2126
|
+
'tagged, language and displayed title are set.')
|
|
2127
|
+
} else if (embedFonts && struct && embeddedFonts.size && !usedFonts.size && unicodeFont.id === null) {
|
|
2128
|
+
// Fonts are fully clean — the remaining blockers are one option away, so
|
|
2129
|
+
// they are worth a sentence. A plain default export stays quiet.
|
|
2130
|
+
report(`PDF/UA-1 claim withheld: ${uaBlockers.join('; ')}.`)
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// Document identity. /Info is written always — /Producer is how a support
|
|
2134
|
+
// case tells this exporter's files apart — while the XMP packet exists only
|
|
2135
|
+
// when the caller said something worth mirroring into it.
|
|
2136
|
+
const infoId = doc.add(infoDict(docMetaSpec))
|
|
2137
|
+
// ENCODED, not passed as a string: the packet opens with the U+FEFF the XMP
|
|
2138
|
+
// spec demands and may carry any script the metadata does. The writer's
|
|
2139
|
+
// string path is Latin-1 and would truncate both — the BOM became a lone
|
|
2140
|
+
// 0xFF, malformed UTF-8 inside the very stream a validator reads first.
|
|
2141
|
+
const xmpId = docMetaSpec
|
|
2142
|
+
? doc.addStream('/Type /Metadata /Subtype /XML', new TextEncoder().encode(
|
|
2143
|
+
xmpPacket(docMetaSpec, lang || struct?.lang || null, { pdfua: claimUA })))
|
|
2144
|
+
: null
|
|
2145
|
+
const labels = pageLabels({
|
|
2146
|
+
front: frontIds.length, body: pageIds.length, back: backIds.length,
|
|
2147
|
+
})
|
|
2148
|
+
|
|
2149
|
+
doc.fill(catalogId,
|
|
2150
|
+
`<< /Type /Catalog /Pages ${pagesId} 0 R` +
|
|
2151
|
+
(outlineId ? ` /Outlines ${outlineId} 0 R` : '') +
|
|
2152
|
+
// Opening the sidebar is right for a document and wrong for a picture, and
|
|
2153
|
+
// the page count is what tells them apart: a one-page `page: 'fit'` capture
|
|
2154
|
+
// with an outline is still a picture, and a panel over it is just chrome.
|
|
2155
|
+
(outlineId && kids.length > 1 ? ' /PageMode /UseOutlines' : '') +
|
|
2156
|
+
(namesId ? ` /Names << /Dests ${namesId} 0 R >>` : '') +
|
|
2157
|
+
(structId ? ` /StructTreeRoot ${structId} 0 R /MarkInfo << /Marked true >>` : '') +
|
|
2158
|
+
// The language of the document, which is what decides how a screen reader
|
|
2159
|
+
// PRONOUNCES it. A tagged file without one is tagged for a reader that has to
|
|
2160
|
+
// guess.
|
|
2161
|
+
(struct && (lang || struct.lang) ? ` /Lang ${pdfTextString(lang || struct.lang)}` : '') +
|
|
2162
|
+
(acroForm ? ` /AcroForm ${doc.add(acroForm)} 0 R` : '') +
|
|
2163
|
+
(xmpId ? ` /Metadata ${xmpId} 0 R` : '') +
|
|
2164
|
+
(labels ? ` /PageLabels ${labels}` : '') +
|
|
2165
|
+
// Without this, a viewer shows the FILENAME over a document that took the
|
|
2166
|
+
// trouble to say its title.
|
|
2167
|
+
(docMetaSpec?.title ? ' /ViewerPreferences << /DisplayDocTitle true >>' : '') +
|
|
2168
|
+
' >>')
|
|
2169
|
+
|
|
2170
|
+
// ——— encryption ———
|
|
2171
|
+
//
|
|
2172
|
+
// Dead last, and after the catalog fill above: the pass rewrites every object
|
|
2173
|
+
// that exists, so anything written afterwards would travel in the clear inside
|
|
2174
|
+
// a file that says nothing is. Nothing may be added to `doc` below this line.
|
|
2175
|
+
if (encryptSpec) {
|
|
2176
|
+
const { p } = await doc.encrypt(catalogId, encryptSpec)
|
|
2177
|
+
report('the document is encrypted with AES-256 (standard security handler, revision 6). ' +
|
|
2178
|
+
'Without the user password the bytes cannot be read at all.' + (p === -1 ? '' :
|
|
2179
|
+
' Permission flags are written too, and a flag is a REQUEST a viewer may honour — ' +
|
|
2180
|
+
'any tool can ignore it. Only the password protects anything.'))
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
const blob = new Blob([doc.build(catalogId, { infoId, fileId: true })], { type: 'application/pdf' })
|
|
2184
|
+
return {
|
|
2185
|
+
blob,
|
|
2186
|
+
stats: {
|
|
2187
|
+
pages: kids.length,
|
|
2188
|
+
fields: fieldWriter ? fieldPlan.fields.length : 0,
|
|
2189
|
+
annotations: annotCount,
|
|
2190
|
+
fontsEmbedded: [...embeddedFonts.values()].filter(f => f.active)
|
|
2191
|
+
.map(f => ({ name: f.baseName, label: f.label, kB: f.kB, subset: f.subset })),
|
|
2192
|
+
pdfua: claimUA,
|
|
2193
|
+
vectorText: vectorPainted,
|
|
2194
|
+
},
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/**
|
|
2199
|
+
* The plugin. Register it once and every capture gains a `toPdf()`.
|
|
2200
|
+
*
|
|
2201
|
+
* ```js
|
|
2202
|
+
* * import pdf from './snapdom-pdf.mjs'
|
|
2203
|
+
*
|
|
2204
|
+
* snapdom.plugins(pdf()) // or snapdom(el, { plugins: [pdf()] })
|
|
2205
|
+
* const blob = await (await snapdom(report)).toPdf({ page: 'a4' })
|
|
2206
|
+
* ```
|
|
2207
|
+
*
|
|
2208
|
+
* @param {object} [defaults] Options for every export this plugin serves. The
|
|
2209
|
+
* four CAPTURE-TIME ones can ONLY be set here, because they decide what the
|
|
2210
|
+
* capture measures:
|
|
2211
|
+
* @param {boolean} [defaults.formValues=true] Put input/textarea/select values in the layer.
|
|
2212
|
+
* @param {boolean} [defaults.shadow=true] Walk open shadow roots.
|
|
2213
|
+
* @param {boolean} [defaults.breakAvoid=true] Keep rows, list items and cards off page breaks.
|
|
2214
|
+
* @param {boolean|string} [defaults.outline=true] Build bookmarks from `h1`–`h6`
|
|
2215
|
+
* (and `role="heading"`). A CSS selector uses that instead; `false` writes no
|
|
2216
|
+
* outline. Named destinations and in-document links do not depend on it.
|
|
2217
|
+
* @returns {object} a snapdom plugin
|
|
2218
|
+
*/
|
|
2219
|
+
export default function pdf(defaults = {}) {
|
|
2220
|
+
// Export is lazy and may happen long after plugin registration. Keep the
|
|
2221
|
+
// factory call deterministic even if its caller later reuses/mutates `defaults`.
|
|
2222
|
+
const pluginDefaults = snapshotOptions({ ...(defaults || {}) })
|
|
2223
|
+
return {
|
|
2224
|
+
name: 'snapdom-pdf',
|
|
2225
|
+
needs: 'render',
|
|
2226
|
+
pure: true,
|
|
2227
|
+
|
|
2228
|
+
// The searchable layer measures the final serialized SVG. A render-boundary
|
|
2229
|
+
// hook asks v3 to use that artifact even when html-in-canvas was requested.
|
|
2230
|
+
// This hook changes nothing, so repeated captures can still use burst/diff.
|
|
2231
|
+
beforeRender() {},
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* defineExports runs after every afterRender hook. Closing over its canonical
|
|
2235
|
+
* URL therefore captures the final composed artifact, including another
|
|
2236
|
+
* plugin's redactions/replacements, without measuring every PNG capture.
|
|
2237
|
+
*/
|
|
2238
|
+
defineExports(ctx) {
|
|
2239
|
+
const facade = ctx.exports
|
|
2240
|
+
const artifactURL = ctx.export?.url
|
|
2241
|
+
const rawMeta = ctx.meta || {}
|
|
2242
|
+
const meta = Object.freeze({
|
|
2243
|
+
w0: rawMeta.w0,
|
|
2244
|
+
h0: rawMeta.h0,
|
|
2245
|
+
vbW: rawMeta.vbW,
|
|
2246
|
+
vbH: rawMeta.vbH,
|
|
2247
|
+
targetW: rawMeta.targetW,
|
|
2248
|
+
targetH: rawMeta.targetH,
|
|
2249
|
+
contentX: rawMeta.contentX,
|
|
2250
|
+
contentY: rawMeta.contentY,
|
|
2251
|
+
clip: rawMeta.clip ? Object.freeze({ ...rawMeta.clip }) : null,
|
|
2252
|
+
})
|
|
2253
|
+
// v3 width/height specify absolute output pixels and suppress scale. Crops
|
|
2254
|
+
// inherit that density in their SVG header; clearing width/height at the
|
|
2255
|
+
// canvas facade must not make the user's ignored scale apply again.
|
|
2256
|
+
const absoluteSize = Number.isFinite(ctx.width) || Number.isFinite(ctx.height)
|
|
2257
|
+
const rasterScale = absoluteSize ? 1 : ctx.scale
|
|
2258
|
+
const captureDensity = absoluteSize
|
|
2259
|
+
? (Number.isFinite(ctx.width) ? meta.targetW / meta.vbW : meta.targetH / meta.vbH)
|
|
2260
|
+
: ctx.scale
|
|
2261
|
+
const source = Object.freeze({
|
|
2262
|
+
baseURL: String(ctx.element?.ownerDocument?.baseURI || document.baseURI),
|
|
2263
|
+
documentURL: String(ctx.element?.ownerDocument?.location?.href ||
|
|
2264
|
+
ctx.element?.ownerDocument?.baseURI || location.href),
|
|
2265
|
+
documentLang: String(ctx.element?.ownerDocument?.documentElement?.lang ||
|
|
2266
|
+
document.documentElement?.lang || ''),
|
|
2267
|
+
})
|
|
2268
|
+
let measuredPromise = null
|
|
2269
|
+
// The ENCODED bytes are cached for the life of the capture, and the canvas
|
|
2270
|
+
// they came from is not. That asymmetry is the whole point: a page's canvas
|
|
2271
|
+
// is megabytes of RGBA and its JPEG is a couple of hundred KB, so keeping
|
|
2272
|
+
// every region's canvas alive — one per page — held a 40-page export's worth
|
|
2273
|
+
// of bitmaps on an object the caller is still holding. Nothing pays for the
|
|
2274
|
+
// drop: within one export every repeat of a region (a repeated table header,
|
|
2275
|
+
// a heading cropped for the index) hits this cache before a canvas is ever
|
|
2276
|
+
// asked for, and only re-exporting the SAME capture at a different quality,
|
|
2277
|
+
// codec or background rasterizes twice.
|
|
2278
|
+
const imageCache = new Map()
|
|
2279
|
+
|
|
2280
|
+
const cropKey = (crop) => crop
|
|
2281
|
+
? ['crop', crop.x, crop.y, crop.width, crop.height].map(String).join(':')
|
|
2282
|
+
: 'full'
|
|
2283
|
+
const rasterFor = (options) => async (crop = null) => {
|
|
2284
|
+
const key = JSON.stringify([
|
|
2285
|
+
cropKey(crop), options.quality, options.backgroundColor, options.codec,
|
|
2286
|
+
])
|
|
2287
|
+
if (imageCache.has(key)) return imageCache.get(key)
|
|
2288
|
+
const promise = Promise.resolve().then(async () => {
|
|
2289
|
+
// Alpha is kept here; background flattening belongs to encodeImage.
|
|
2290
|
+
const canvas = await facade.canvas({
|
|
2291
|
+
backgroundColor: null,
|
|
2292
|
+
width: absoluteSize ? (crop?.width ?? meta.vbW) * captureDensity : null,
|
|
2293
|
+
height: absoluteSize ? (crop?.height ?? meta.vbH) * captureDensity : null,
|
|
2294
|
+
scale: rasterScale,
|
|
2295
|
+
...(crop ? { crop } : {}),
|
|
2296
|
+
})
|
|
2297
|
+
return encodeImage(canvas, {
|
|
2298
|
+
quality: options.quality,
|
|
2299
|
+
background: options.backgroundColor,
|
|
2300
|
+
codec: options.codec,
|
|
2301
|
+
})
|
|
2302
|
+
})
|
|
2303
|
+
imageCache.set(key, promise)
|
|
2304
|
+
promise.catch(() => { if (imageCache.get(key) === promise) imageCache.delete(key) })
|
|
2305
|
+
return promise
|
|
2306
|
+
}
|
|
2307
|
+
const measuredForCapture = () => {
|
|
2308
|
+
if (!measuredPromise) measuredPromise = measureArtifact(artifactURL, meta, pluginDefaults, source)
|
|
2309
|
+
return measuredPromise
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
return {
|
|
2313
|
+
pdf: async (exportCtx, exportOptions = exportCtx.export?.options || {}) => {
|
|
2314
|
+
const requested = exportCtx.export?.requestedOptions
|
|
2315
|
+
if (!requested) {
|
|
2316
|
+
throw new Error('[snapdom-pdf] this build requires @zumer/snapdom >=3.0.0-beta.0 <4 ' +
|
|
2317
|
+
'(missing export.requestedOptions)')
|
|
2318
|
+
}
|
|
2319
|
+
const opts = { ...requested }
|
|
2320
|
+
// The raw request records explicit keys so normalized core defaults do
|
|
2321
|
+
// not overwrite pdf() defaults. beforeExport can still steer this export
|
|
2322
|
+
// by mutating its effective option bag, including removing request keys.
|
|
2323
|
+
for (const key of Object.keys(opts)) {
|
|
2324
|
+
if (!Object.hasOwn(exportOptions, key)) delete opts[key]
|
|
2325
|
+
}
|
|
2326
|
+
for (const [key, value] of Object.entries(exportOptions)) {
|
|
2327
|
+
if (Object.hasOwn(requested, key) || !Object.is(value, exportCtx[key])) opts[key] = value
|
|
2328
|
+
}
|
|
2329
|
+
if (Array.isArray(opts.page)) opts.page = [...opts.page]
|
|
2330
|
+
const notes = []
|
|
2331
|
+
const report = (msg) => notes.push(msg)
|
|
2332
|
+
|
|
2333
|
+
for (const key of CAPTURE_TIME) {
|
|
2334
|
+
if (key in opts && opts[key] !== pluginDefaults[key]) {
|
|
2335
|
+
report(`\`${key}\` is decided when the capture measures the DOM — set it on ` +
|
|
2336
|
+
`pdf({ ${key}: … }), not on the export. Ignored here.`)
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
// Names that no longer mean anything here, and the two that would have
|
|
2340
|
+
// collided with the engine's own: `compress` downsamples inlined images
|
|
2341
|
+
// and `filename` defaults to 'snapDOM', which as a download trigger would
|
|
2342
|
+
// have made every PDF save itself. Hence `deflate` and `download`, which
|
|
2343
|
+
// the engine has no opinion about.
|
|
2344
|
+
for (const gone of ['mode', 'vector', 'scale', 'background', 'filename', 'compress']) {
|
|
2345
|
+
if (gone in opts) {
|
|
2346
|
+
report(`\`${gone}\` is not a PDF option — see the reference at ` +
|
|
2347
|
+
'https://snapdom.dev/pro/pdf/docs.html. Ignored.')
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// The capture owns its own resolution and its own flattening; the PDF side
|
|
2352
|
+
// reads them rather than re-deciding them.
|
|
2353
|
+
const options = {
|
|
2354
|
+
...pluginDefaults, ...opts, scale: captureDensity, dpr: exportCtx.dpr,
|
|
2355
|
+
rasterDensity: absoluteSize ? captureDensity : null,
|
|
2356
|
+
}
|
|
2357
|
+
if (!Object.hasOwn(options, 'quality')) options.quality = exportCtx.quality ?? 0.92
|
|
2358
|
+
if (!Object.hasOwn(options, 'backgroundColor')) {
|
|
2359
|
+
options.backgroundColor = exportCtx.backgroundColor ?? null
|
|
2360
|
+
}
|
|
2361
|
+
if (!Object.hasOwn(options, 'codec')) options.codec = 'auto'
|
|
2362
|
+
options.captureOptions = {
|
|
2363
|
+
exclude: exportCtx.excludePredicates?.length
|
|
2364
|
+
? [...(exportCtx.exclude || []), ...exportCtx.excludePredicates]
|
|
2365
|
+
: exportCtx.exclude,
|
|
2366
|
+
excludeMode: exportCtx.excludeMode,
|
|
2367
|
+
plugins: exportCtx.plugins,
|
|
2368
|
+
reconcile: exportCtx.reconcile,
|
|
2369
|
+
embedFonts: exportCtx.embedFonts,
|
|
2370
|
+
localFonts: exportCtx.localFonts,
|
|
2371
|
+
excludeFonts: exportCtx.excludeFonts,
|
|
2372
|
+
fontStylesheetDomains: exportCtx.fontStylesheetDomains,
|
|
2373
|
+
useProxy: exportCtx.useProxy,
|
|
2374
|
+
fallbackURL: exportCtx.fallbackURL,
|
|
2375
|
+
compress: exportCtx.compress,
|
|
2376
|
+
excludeStyleProps: exportCtx.excludeStyleProps,
|
|
2377
|
+
outerTransforms: exportCtx.outerTransforms,
|
|
2378
|
+
outerShadows: exportCtx.outerShadows,
|
|
2379
|
+
captureSelection: exportCtx.captureSelection,
|
|
2380
|
+
iconFonts: exportCtx.iconFonts,
|
|
2381
|
+
placeholders: exportCtx.placeholders,
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// All cheap validation precedes snapshot mount, canvas allocation and codec work.
|
|
2385
|
+
validateExport(options, meta)
|
|
2386
|
+
const flow = flowSpec({ signal: options.signal, onProgress: options.onProgress })
|
|
2387
|
+
const onReport = reportSpec(options.onReport)
|
|
2388
|
+
if (options.meta != null && (typeof options.meta !== 'object' || Array.isArray(options.meta))) {
|
|
2389
|
+
throw new TypeError('[snapdom-pdf] meta must be an object of document properties')
|
|
2390
|
+
}
|
|
2391
|
+
flow?.check()
|
|
2392
|
+
flow?.tick('measure')
|
|
2393
|
+
const measured = await measuredForCapture()
|
|
2394
|
+
if (!measured.rect.width || !measured.rect.height) {
|
|
2395
|
+
throw new Error('[snapdom-pdf] the final captured tree has no layout box')
|
|
2396
|
+
}
|
|
2397
|
+
notes.push(...measured.notes)
|
|
2398
|
+
|
|
2399
|
+
const raster = options.image === false ? null : rasterFor(options)
|
|
2400
|
+
const { blob, stats } = await draw({ measured, raster, meta, options, report, flow })
|
|
2401
|
+
for (const n of notes) warn(n)
|
|
2402
|
+
onReport?.(buildReport({
|
|
2403
|
+
messages: notes, pages: stats.pages, bytes: blob.size,
|
|
2404
|
+
fields: stats.fields, annotations: stats.annotations,
|
|
2405
|
+
fontsEmbedded: stats.fontsEmbedded, pdfua: stats.pdfua,
|
|
2406
|
+
vectorText: stats.vectorText,
|
|
2407
|
+
}))
|
|
2408
|
+
return deliver(blob, options.download)
|
|
2409
|
+
},
|
|
2410
|
+
}
|
|
2411
|
+
},
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
export { pdf }
|