@openpresentation/opf-render 0.11.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -8
- package/dist/annotations.js +45 -0
- package/dist/chart-options.js +177 -0
- package/dist/charts.js +194 -36
- package/dist/color-fonts.js +32 -0
- package/dist/deck-runtime.js +343 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +5 -0
- package/dist/element.d.ts +86 -0
- package/dist/element.js +521 -0
- package/dist/export-browser.d.ts +26 -0
- package/dist/export-browser.js +228 -0
- package/dist/font-manifest.js +158 -0
- package/dist/font-policy.js +232 -16
- package/dist/fonts.d.ts +30 -0
- package/dist/fonts.js +48 -22
- package/dist/index.d.ts +100 -2
- package/dist/pdf-fonts.js +333 -0
- package/dist/pdf-style.js +429 -0
- package/dist/pdf-subset.js +176 -0
- package/dist/pdf-text.js +338 -0
- package/dist/pdf-vector.js +1249 -0
- package/dist/pdf-writer.js +154 -0
- package/dist/pdf-xml.js +78 -0
- package/dist/player.d.ts +79 -0
- package/dist/player.js +706 -0
- package/dist/preview-fonts-cli.js +15 -0
- package/dist/preview-fonts-node.d.ts +14 -0
- package/dist/preview-fonts-node.js +86 -0
- package/dist/preview-fonts.d.ts +23 -0
- package/dist/preview-fonts.js +78 -0
- package/dist/raster-images.js +19 -6
- package/dist/raster-text.js +468 -0
- package/dist/raster.js +81 -12
- package/dist/script-font-pack.js +19 -1
- package/dist/script-fonts.js +214 -23
- package/dist/sha256.js +47 -0
- package/dist/svg.d.ts +9 -1
- package/dist/svg.js +360 -53
- package/dist/symbol-encodings.js +7 -0
- package/dist/symbol-fonts.js +96 -0
- package/package.json +84 -7
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
import { FontEmbedder, FontLibrary, nominalAdvance } from "./pdf-fonts.js";
|
|
2
|
+
import { actualTextFor, layoutText, textClusters } from "./pdf-text.js";
|
|
3
|
+
import { IDENTITY, viewBoxTransform, applyPoint, applyStyleSheets, attributesOf, inheritStyle, invert, multiply, parseColor, parseFontWeight, parseLength, parseNumberList, parsePath, parseTransform, pathBounds, ROOT_STYLE, shapeToPath, transformPath } from "./pdf-style.js";
|
|
4
|
+
import { PdfFile, asciiString, name, num, ref, sha256, hex, textString, utf16Hex, utf8Encoder } from "./pdf-writer.js";
|
|
5
|
+
import { isElement, parseXml, serialize, escapeAttribute } from "./pdf-xml.js";
|
|
6
|
+
|
|
7
|
+
// Vector PDF export (RR-12). This converts the renderer's own SVG (the same SVG the preview rasterizes) to PDF:
|
|
8
|
+
// shapes and paths as PDF paths, gradients and hatch patterns as PDF shadings and tiling patterns, pictures as
|
|
9
|
+
// image XObjects, and text as real text objects drawn with embedded TrueType subsets. It performs no layout of its
|
|
10
|
+
// own: every position, line break and width comes from the SVG. Effects the PDF cannot express (SVG filters,
|
|
11
|
+
// masks, nested SVG pictures) are rasterized for that element alone and reported as `pdf-raster-fallback`.
|
|
12
|
+
|
|
13
|
+
const VIEWPORT_FALLBACK = { width: 1280, height: 720 };
|
|
14
|
+
// Space separators other than U+0020: they are copied as plain spaces, so text that uses one carries /ActualText.
|
|
15
|
+
const NON_ASCII_SPACE = new RegExp("[" + ["\u00a0", "\u1680", "\u2000-\u200a", "\u202f", "\u205f", "\u3000"].join("") + "]");
|
|
16
|
+
// Per page: elements drawn (a <use> expansion counts every copy) and group nesting.
|
|
17
|
+
const MAX_ELEMENTS = 50000;
|
|
18
|
+
const MAX_NESTING = 256;
|
|
19
|
+
// Attributes with no PDF equivalent here, and the values that mean "default" (nothing to report).
|
|
20
|
+
const UNSUPPORTED_ATTRIBUTES = [
|
|
21
|
+
["rotate", /^(0|)$/], ["dominant-baseline", /^(auto|alphabetic|normal)$/], ["alignment-baseline", /^(auto|baseline|alphabetic)$/],
|
|
22
|
+
["baseline-shift", /^(baseline|0|0px|)$/], ["paint-order", /^(normal)$/], ["mix-blend-mode", /^(normal)$/], ["text-transform", /^(none)$/],
|
|
23
|
+
["font-variant", /^(normal|none)$/], ["font-variant-caps", /^(normal)$/], ["writing-mode", /^(lr|lr-tb|horizontal-tb)$/],
|
|
24
|
+
["marker-start", /^none$/], ["marker-mid", /^none$/], ["marker-end", /^none$/], ["vector-effect", /^none$/], ["mask-image", /^none$/],
|
|
25
|
+
].map(([key, ignored]) => [key, ignored]);
|
|
26
|
+
const SKIPPED = new Set(["defs", "title", "desc", "metadata", "style", "script", "clipPath", "mask", "marker", "pattern", "linearGradient", "radialGradient", "symbol", "filter", "font", "font-face"]);
|
|
27
|
+
const SHAPES = new Set(["rect", "circle", "ellipse", "line", "polyline", "polygon", "path"]);
|
|
28
|
+
|
|
29
|
+
class Content {
|
|
30
|
+
constructor(doc) {
|
|
31
|
+
this.doc = doc;
|
|
32
|
+
this.ops = [];
|
|
33
|
+
this.fonts = new Map();
|
|
34
|
+
this.xobjects = new Map();
|
|
35
|
+
this.states = new Map();
|
|
36
|
+
this.patterns = new Map();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
op(text) { this.ops.push(text); }
|
|
40
|
+
|
|
41
|
+
font(entry) { this.fonts.set(entry.resource, entry.object); return entry.resource; }
|
|
42
|
+
|
|
43
|
+
xobject(resource, object) { this.xobjects.set(resource, object); return resource; }
|
|
44
|
+
|
|
45
|
+
graphicsState(ca, CA) {
|
|
46
|
+
const state = this.doc.graphicsState(ca, CA);
|
|
47
|
+
this.states.set(state.name, state.object);
|
|
48
|
+
return state.name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pattern(resource, object) { this.patterns.set(resource, object); return resource; }
|
|
52
|
+
|
|
53
|
+
resources() {
|
|
54
|
+
const section = (label, map) => (map.size ? `/${label}<<${[...map].map(([key, object]) => `/${key} ${ref(object)}`).join("")}>>` : "");
|
|
55
|
+
return `${section("Font", this.fonts)}${section("XObject", this.xobjects)}${section("ExtGState", this.states)}${section("Pattern", this.patterns)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
text() { return this.ops.join("\n"); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class Converter {
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.options = options;
|
|
64
|
+
this.file = new PdfFile({ compress: options.compress !== false });
|
|
65
|
+
this.fonts = new FontLibrary({
|
|
66
|
+
onDiagnostic: (d) => this.diagnostic(d),
|
|
67
|
+
genericFamilies: (generic) => {
|
|
68
|
+
const base = generic === "monospace" || generic === "ui-monospace" ? options.monospaceFamily : generic === "sans-serif" || generic === "system-ui" || generic === "ui-sans-serif" ? options.sansSerifFamily : options.serifFamily ?? options.defaultFontFamily;
|
|
69
|
+
return [base ?? options.defaultFontFamily];
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
this.embedder = new FontEmbedder(this.file);
|
|
73
|
+
this.states = new Map();
|
|
74
|
+
this.images = new Map();
|
|
75
|
+
this.counters = { Fm: 0, Im: 0, P: 0, GS: 0 };
|
|
76
|
+
this.pages = [];
|
|
77
|
+
this.tagged = options.tagged !== false;
|
|
78
|
+
this.defaults = [options.defaultFontFamily, options.sansSerifFamily].filter(Boolean);
|
|
79
|
+
this.reported = new Set();
|
|
80
|
+
this.fallbackCount = 0;
|
|
81
|
+
this.env = {
|
|
82
|
+
fonts: this.fonts,
|
|
83
|
+
defaults: this.defaults,
|
|
84
|
+
diagnostic: (d) => this.diagnostic(d),
|
|
85
|
+
// A feature the PDF cannot give: reported once per character and family, fatal under `strict`.
|
|
86
|
+
missing: (d) => {
|
|
87
|
+
const key = d.code + d.fontFamily + d.codePoint;
|
|
88
|
+
if (this.reported.has(key)) return;
|
|
89
|
+
this.reported.add(key);
|
|
90
|
+
this.unsupported(d.code, d.message, { fontFamily: d.fontFamily, codePoint: d.codePoint });
|
|
91
|
+
},
|
|
92
|
+
unavailable: (families) => this.error("pdf-font-unavailable", `No embeddable font face is available for ${families.length ? `'${families.join("', '")}'` : "the requested text"}. Supply font files (fontFiles/fontDirs) or keep useBundledFonts enabled.`, {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
diagnostic(d) {
|
|
97
|
+
if (d.code === "pdf-font-substituted" || d.code === "pdf-font-fallback") {
|
|
98
|
+
const key = d.code + (d.requestedFamily ?? d.fontFamily) + (d.resolvedFamily ?? d.fallbackFamily) + (d.codePoint ?? "");
|
|
99
|
+
if (this.reported.has(key)) return;
|
|
100
|
+
this.reported.add(key);
|
|
101
|
+
}
|
|
102
|
+
this.options.onDiagnostic?.(d);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
error(code, message, details = {}) {
|
|
106
|
+
// Imported lazily to avoid a circular import with svg.js at module load.
|
|
107
|
+
return new this.options.ErrorClass(code, message, details);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A feature the PDF cannot express as vectors: reported, or fatal under `strict`. */
|
|
111
|
+
unsupported(code, message, details = {}) {
|
|
112
|
+
if (this.options.strict) throw this.error(code, `${message} (strict: the vector PDF export does not fall back).`, details);
|
|
113
|
+
this.diagnostic({ code, message, ...details });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
graphicsState(ca, CA) {
|
|
117
|
+
const key = `${num(ca, 4)}:${num(CA, 4)}`;
|
|
118
|
+
let state = this.states.get(key);
|
|
119
|
+
if (!state) {
|
|
120
|
+
state = { name: `GS${++this.counters.GS}`, object: this.file.add(`/Type/ExtGState/ca ${num(ca, 4)}/CA ${num(CA, 4)}/AIS false`) };
|
|
121
|
+
this.states.set(key, state);
|
|
122
|
+
}
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
127
|
+
// Fonts
|
|
128
|
+
|
|
129
|
+
async loadFontFiles(files, directories) {
|
|
130
|
+
// Font files are Node-only. A browser host passes no paths: its faces come from the SVG's @font-face data or `fontData`.
|
|
131
|
+
if (!files.length && !directories.length) return;
|
|
132
|
+
const { readdir, readFile, stat: fsStat, path } = await nodeFileSystem();
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const read = async (file) => {
|
|
135
|
+
const resolved = path.resolve(file);
|
|
136
|
+
if (seen.has(resolved)) return;
|
|
137
|
+
seen.add(resolved);
|
|
138
|
+
try {
|
|
139
|
+
const stat = await fsStat(resolved);
|
|
140
|
+
await this.fonts.addFile(resolved, stat, () => readFile(resolved));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
this.diagnostic({ code: "pdf-font-unreadable", message: `Font file ${resolved} could not be read: ${error.code ?? error.message}.`, origin: resolved });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
for (const directory of directories) {
|
|
146
|
+
for (const file of await listFonts(directory, { readdir, path })) await read(file);
|
|
147
|
+
}
|
|
148
|
+
for (const file of files) await read(file);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
addEmbeddedFontFaces(root) {
|
|
152
|
+
const stack = [root];
|
|
153
|
+
while (stack.length) {
|
|
154
|
+
const node = stack.pop();
|
|
155
|
+
if (node.name === "style") {
|
|
156
|
+
const css = node.children.map((child) => child.text ?? "").join("");
|
|
157
|
+
for (const rule of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
|
158
|
+
const source = /url\(\s*(["']?)(data:[^"')]*)\1\s*\)/.exec(rule[1]);
|
|
159
|
+
if (!source) continue;
|
|
160
|
+
const comma = source[2].indexOf(",");
|
|
161
|
+
const header = source[2].slice(0, comma), payload = source[2].slice(comma + 1);
|
|
162
|
+
try {
|
|
163
|
+
const bytes = dataUriBytes(header, payload);
|
|
164
|
+
const declared = /font-family\s*:\s*(?:"([^"]*)"|'([^']*)'|([^;"']+))/.exec(rule[1]);
|
|
165
|
+
this.fonts.addData(new Uint8Array(bytes), "svg @font-face", (declared?.[1] ?? declared?.[2] ?? declared?.[3])?.trim());
|
|
166
|
+
} catch { /* a malformed embedded font is skipped; text falls back to the loaded faces */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const child of node.children) if (isElement(child)) stack.push(child);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
174
|
+
// Pages
|
|
175
|
+
|
|
176
|
+
async addPage(source, index) {
|
|
177
|
+
let svg;
|
|
178
|
+
try { svg = parseXml(source); } catch (error) {
|
|
179
|
+
throw this.error("svg-too-deep", error instanceof Error ? error.message : String(error), {});
|
|
180
|
+
}
|
|
181
|
+
if (!svg) throw this.error("invalid-svg", "SVG input must contain a root <svg> element.", {});
|
|
182
|
+
this.addEmbeddedFontFaces(svg);
|
|
183
|
+
applyStyleSheets(svg, (message) => this.unsupported("pdf-unsupported-css", message, {}));
|
|
184
|
+
const attrs = attributesOf(svg);
|
|
185
|
+
const viewBox = parseNumberList(attrs.viewBox);
|
|
186
|
+
const hasBox = viewBox.length === 4 && viewBox[2] > 0 && viewBox[3] > 0;
|
|
187
|
+
const width = positive(parseLength(attrs.width), hasBox ? viewBox[2] : VIEWPORT_FALLBACK.width);
|
|
188
|
+
const height = positive(parseLength(attrs.height), hasBox ? viewBox[3] : VIEWPORT_FALLBACK.height);
|
|
189
|
+
// viewBox to page: scale to fit (xMidYMid meet unless the SVG says otherwise).
|
|
190
|
+
const root = hasBox ? viewBoxTransform(viewBox, attrs.preserveAspectRatio, 0, 0, width, height) : IDENTITY;
|
|
191
|
+
const defs = new Map();
|
|
192
|
+
const collect = (node) => { if (node.attrs?.id) defs.set(node.attrs.id, node); for (const child of node.children ?? []) if (isElement(child)) collect(child); };
|
|
193
|
+
collect(svg);
|
|
194
|
+
|
|
195
|
+
const content = new Content(this);
|
|
196
|
+
const page = {
|
|
197
|
+
index, width, height, source, defs, root, svg,
|
|
198
|
+
viewport: hasBox ? { w: viewBox[2], h: viewBox[3] } : { w: width, h: height },
|
|
199
|
+
elements: 0, limited: false, reportedAttributes: new Set(),
|
|
200
|
+
object: this.file.reserve(), content, annotations: [], mcids: [], blocks: new Map(),
|
|
201
|
+
section: { role: "Sect", kids: [], page: null },
|
|
202
|
+
label: attrs["aria-label"], language: attrs.lang ?? attrs["xml:lang"],
|
|
203
|
+
};
|
|
204
|
+
page.section.page = page;
|
|
205
|
+
this.pages.push(page);
|
|
206
|
+
// The page is painted white unless `background` says otherwise (raster mode composites on white too), so the page looks
|
|
207
|
+
// the same in viewers that show a dark or transparent paper.
|
|
208
|
+
const background = parseColor(this.options.background ?? "#FFFFFF");
|
|
209
|
+
if (background && background.a > 0) {
|
|
210
|
+
if (this.tagged) content.op("/Artifact BMC");
|
|
211
|
+
content.op(`q ${rgb(background)} rg 0 0 ${num(width)} ${num(height)} re f Q`);
|
|
212
|
+
if (this.tagged) content.op("EMC");
|
|
213
|
+
}
|
|
214
|
+
content.op(`q 1 0 0 -1 0 ${num(height)} cm ${matrixOps(root)}`);
|
|
215
|
+
const style = inheritStyle({ ...ROOT_STYLE, "font-family": this.defaults[0] ?? "Roboto" }, attrs);
|
|
216
|
+
const ctx = { page, content, matrix: root, rel: multiply([1, 0, 0, -1, 0, height], root), T: IDENTITY, style, alpha: 1, link: null, inForm: false };
|
|
217
|
+
for (const child of svg.children) await this.walk(child, ctx);
|
|
218
|
+
content.op("Q");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
222
|
+
// Traversal
|
|
223
|
+
|
|
224
|
+
async walk(node, ctx) {
|
|
225
|
+
if (!isElement(node) || SKIPPED.has(node.name)) return;
|
|
226
|
+
// Budgets against hostile input (a <use> chain that doubles at every level, enormous trees).
|
|
227
|
+
if (ctx.page.limited) return;
|
|
228
|
+
if (++ctx.page.elements > MAX_ELEMENTS) { this.expansionLimit(ctx, node, `more than ${MAX_ELEMENTS} elements`); return; }
|
|
229
|
+
if ((ctx.nesting ?? 0) > MAX_NESTING) { this.expansionLimit(ctx, node, `groups nested deeper than ${MAX_NESTING}`); return; }
|
|
230
|
+
const attrs = attributesOf(node);
|
|
231
|
+
if (attrs.display === "none") return;
|
|
232
|
+
this.checkAttributes(node, attrs, ctx);
|
|
233
|
+
if (node.name === "svg") return this.viewport(node, attrs, ctx, attrs, node);
|
|
234
|
+
if (node.name === "switch") {
|
|
235
|
+
// The first child that is an element (conditional processing attributes are not evaluated).
|
|
236
|
+
const first = node.children.find((child) => isElement(child) && !SKIPPED.has(child.name));
|
|
237
|
+
return first ? this.walk(first, ctx) : undefined;
|
|
238
|
+
}
|
|
239
|
+
if (node.name === "g" || node.name === "a") return this.element(node, attrs, ctx, (inner) => this.children(node, inner));
|
|
240
|
+
if (node.name === "use") return this.use(node, attrs, ctx);
|
|
241
|
+
if (SHAPES.has(node.name)) return this.element(node, attrs, ctx, (inner) => this.shape(node, attrs, inner), { leaf: "shape" });
|
|
242
|
+
if (node.name === "text") return this.element(node, attrs, ctx, (inner) => this.text(node, attrs, inner), { leaf: "text" });
|
|
243
|
+
if (node.name === "image") return this.element(node, attrs, ctx, (inner) => this.image(node, attrs, inner), { leaf: "image" });
|
|
244
|
+
this.unsupported("pdf-unsupported-element", `The <${node.name}> element is not drawn in the vector PDF.`, { path: node.attrs["data-opf-path"], element: node.name });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Presentation features this export does not draw: reported once per page and attribute, never silently dropped. */
|
|
248
|
+
checkAttributes(node, attrs, ctx) {
|
|
249
|
+
const page = ctx.page;
|
|
250
|
+
for (const [key, ignored] of UNSUPPORTED_ATTRIBUTES) {
|
|
251
|
+
const value = attrs[key];
|
|
252
|
+
if (value === undefined || ignored.test(String(value).trim())) continue;
|
|
253
|
+
const mark = `${key}=${value}`;
|
|
254
|
+
if (page.reportedAttributes.has(mark)) continue;
|
|
255
|
+
page.reportedAttributes.add(mark);
|
|
256
|
+
this.unsupported("pdf-unsupported-feature", `${key}="${String(value).slice(0, 40)}" on <${node.name}> is not drawn in the vector PDF.`, { path: node.attrs["data-opf-path"], element: node.name, attribute: key });
|
|
257
|
+
}
|
|
258
|
+
if (node.name === "textPath" && !page.reportedAttributes.has("textPath")) {
|
|
259
|
+
page.reportedAttributes.add("textPath");
|
|
260
|
+
this.unsupported("pdf-unsupported-feature", "<textPath> text is drawn along a straight line, not along its path.", { path: node.attrs["data-opf-path"], element: node.name });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async children(node, ctx) {
|
|
265
|
+
for (const child of node.children) await this.walk(child, ctx);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async use(node, attrs, ctx) {
|
|
269
|
+
const target = ctx.page.defs.get(String(attrs.href ?? attrs["xlink:href"] ?? "").replace(/^#/, ""));
|
|
270
|
+
if (!target || target === node) return;
|
|
271
|
+
if ((ctx.depth ?? 0) > 12) { this.expansionLimit(ctx, node, "nested <use> references"); return; }
|
|
272
|
+
const deeper = { ...ctx, depth: (ctx.depth ?? 0) + 1 };
|
|
273
|
+
// A <symbol> or <svg> target is a viewport: <use> width and height (default 100%) size it.
|
|
274
|
+
if (target.name === "symbol" || target.name === "svg") {
|
|
275
|
+
const own = attributesOf(target);
|
|
276
|
+
return this.viewport(target, { ...own, ...attrs, viewBox: own.viewBox, preserveAspectRatio: own.preserveAspectRatio }, deeper, own, node);
|
|
277
|
+
}
|
|
278
|
+
const wrapper = { name: "g", attrs: { ...node.attrs, transform: `${node.attrs.transform ?? ""} translate(${parseLength(attrs.x) ?? 0} ${parseLength(attrs.y) ?? 0})` }, children: [target], parent: node.parent };
|
|
279
|
+
delete wrapper.attrs.href; delete wrapper.attrs["xlink:href"];
|
|
280
|
+
return this.walk(wrapper, deeper);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Draw a nested <svg> (or a <symbol> a <use> instantiates): its x, y, width and height place a viewport, its viewBox is
|
|
285
|
+
* fitted into it, and content outside it is clipped unless overflow is visible. `sizing` gives x, y, width, height and the
|
|
286
|
+
* viewBox, `source` the element whose children are drawn, `origin` the element whose presentation attributes apply.
|
|
287
|
+
*/
|
|
288
|
+
async viewport(source, sizing, ctx, source2, origin) {
|
|
289
|
+
const page = ctx.page;
|
|
290
|
+
const fontSize = parseFloat(ctx.style["font-size"]) || 16;
|
|
291
|
+
const length = (value, reference, fallback) => parseLength(value, { reference, fontSize }) ?? fallback;
|
|
292
|
+
const x = length(sizing.x, page.viewport.w, 0), y = length(sizing.y, page.viewport.h, 0);
|
|
293
|
+
const width = length(sizing.width, page.viewport.w, page.viewport.w), height = length(sizing.height, page.viewport.h, page.viewport.h);
|
|
294
|
+
if (!(width > 0 && height > 0)) return;
|
|
295
|
+
const box = parseNumberList(source2.viewBox ?? sizing.viewBox);
|
|
296
|
+
const matrix = box.length === 4 && box[2] > 0 && box[3] > 0 ? viewBoxTransform(box, source2.preserveAspectRatio ?? sizing.preserveAspectRatio, x, y, width, height) : [1, 0, 0, 1, x, y];
|
|
297
|
+
const inherited = { ...origin.attrs };
|
|
298
|
+
for (const key of ["x", "y", "width", "height", "viewBox", "preserveAspectRatio", "transform", "overflow", "href", "xlink:href", "id"]) delete inherited[key];
|
|
299
|
+
const wrapper = { name: "g", attrs: { ...inherited, transform: `matrix(${matrix.join(" ")})` }, children: source.children, parent: source.parent };
|
|
300
|
+
const content = ctx.content;
|
|
301
|
+
content.op("q");
|
|
302
|
+
if (attributesOf(source).overflow !== "visible" && attributesOf(origin).overflow !== "visible") content.op(`${num(x)} ${num(y)} ${num(width)} ${num(height)} re W n`);
|
|
303
|
+
await this.element(wrapper, attributesOf(wrapper), ctx, (inner) => this.children(wrapper, inner));
|
|
304
|
+
content.op("Q");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The `<use>`-expansion and element budgets: report once per page, stop expanding. */
|
|
308
|
+
expansionLimit(ctx, node, what) {
|
|
309
|
+
const page = ctx.page;
|
|
310
|
+
if (page.limited) return;
|
|
311
|
+
page.limited = true;
|
|
312
|
+
this.unsupported("pdf-expansion-limit", `Too much content to draw (${what}); the rest of this page is not drawn.`, { path: node.attrs?.["data-opf-path"] });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Common element handling: transform, clip, opacity, link, and the fallback for filters and masks. */
|
|
316
|
+
async element(node, attrs, ctx, painter, { leaf } = {}) {
|
|
317
|
+
if (attrs.filter !== undefined && attrs.filter !== "none") return this.fallback(node, ctx, `filter ${attrs.filter}`);
|
|
318
|
+
if (attrs.mask !== undefined && attrs.mask !== "none") return this.fallback(node, ctx, `mask ${attrs.mask}`);
|
|
319
|
+
if (node.name === "image" && /^data:image\/svg\+xml/i.test(attrs.href ?? attrs["xlink:href"] ?? "")) return this.fallback(node, ctx, "nested SVG picture");
|
|
320
|
+
const transform = parseTransform(attrs.transform);
|
|
321
|
+
const isIdentity = transform.every((value, index) => value === IDENTITY[index]);
|
|
322
|
+
const style = inheritStyle(ctx.style, attrs);
|
|
323
|
+
if (style.visibility === "hidden" || style.visibility === "collapse") return;
|
|
324
|
+
const content = ctx.content;
|
|
325
|
+
const next = {
|
|
326
|
+
...ctx, style,
|
|
327
|
+
matrix: isIdentity ? ctx.matrix : multiply(ctx.matrix, transform),
|
|
328
|
+
rel: isIdentity ? ctx.rel : multiply(ctx.rel, transform),
|
|
329
|
+
T: isIdentity ? ctx.T : multiply(ctx.T, transform),
|
|
330
|
+
parentStyle: ctx.style,
|
|
331
|
+
nesting: (ctx.nesting ?? 0) + 1,
|
|
332
|
+
};
|
|
333
|
+
if (node.name === "a") {
|
|
334
|
+
const href = attrs.href ?? attrs["xlink:href"];
|
|
335
|
+
if (href !== undefined) next.link = { href, node };
|
|
336
|
+
}
|
|
337
|
+
const opacity = attrs.opacity === undefined ? 1 : Math.min(1, Math.max(0, parseFloat(attrs.opacity)));
|
|
338
|
+
if (opacity === 0 || Number.isNaN(opacity)) return;
|
|
339
|
+
content.op("q");
|
|
340
|
+
if (!isIdentity) content.op(`${transform.map((value) => num(value, 5)).join(" ")} cm`);
|
|
341
|
+
const clip = attrs["clip-path"];
|
|
342
|
+
if (clip && clip !== "none") this.clip(clip, ctx, next);
|
|
343
|
+
const needsGroup = opacity < 1 && !leaf && countLeaves(node) > 1;
|
|
344
|
+
if (needsGroup && !next.inForm) {
|
|
345
|
+
await this.group(node, next, opacity, painter);
|
|
346
|
+
} else {
|
|
347
|
+
next.alpha = ctx.alpha * opacity;
|
|
348
|
+
await painter(next);
|
|
349
|
+
}
|
|
350
|
+
content.op("Q");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Opacity applied to a group of several drawings: a transparency-group form XObject drawn with the constant alpha. */
|
|
354
|
+
async group(node, ctx, opacity, painter) {
|
|
355
|
+
const form = new Content(this);
|
|
356
|
+
const inverse = invert(ctx.matrix);
|
|
357
|
+
const box = inverse
|
|
358
|
+
? boundsOf([[0, 0], [ctx.page.width, 0], [ctx.page.width, ctx.page.height], [0, ctx.page.height]].map(([x, y]) => applyPoint(inverse, x, y)))
|
|
359
|
+
: { x: -1e5, y: -1e5, width: 2e5, height: 2e5 };
|
|
360
|
+
const inner = { ...ctx, content: form, rel: IDENTITY, alpha: 1, inForm: true };
|
|
361
|
+
await painter(inner);
|
|
362
|
+
const resource = `Fm${++this.counters.Fm}`;
|
|
363
|
+
const object = this.file.addStream(
|
|
364
|
+
`/Type/XObject/Subtype/Form/FormType 1/BBox[${num(box.x)} ${num(box.y)} ${num(box.x + box.width)} ${num(box.y + box.height)}]/Group<</S/Transparency/CS/DeviceRGB/I true/K false>>/Resources<<${form.resources()}>>`,
|
|
365
|
+
form.text(),
|
|
366
|
+
);
|
|
367
|
+
const c = ctx.content;
|
|
368
|
+
this.markArtifact(ctx, () => {
|
|
369
|
+
c.op(`/${c.graphicsState(opacity * ctx.alpha, opacity * ctx.alpha)} gs`);
|
|
370
|
+
c.op(`/${c.xobject(resource, object)} Do`);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
clip(value, parentCtx, ctx) {
|
|
375
|
+
const id = /url\(\s*#([^)\s]+)\s*\)/.exec(value)?.[1];
|
|
376
|
+
const def = id && parentCtx.page.defs.get(id);
|
|
377
|
+
if (!def || def.name !== "clipPath") {
|
|
378
|
+
this.unsupported("pdf-unsupported-clip", `clip-path ${value} does not reference a clipPath; the clip is ignored.`, {});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const attrs = attributesOf(def);
|
|
382
|
+
if (attrs.clipPathUnits === "objectBoundingBox") {
|
|
383
|
+
this.unsupported("pdf-unsupported-clip", "clipPathUnits=objectBoundingBox is not supported; the clip is ignored.", {});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const base = parseTransform(attrs.transform);
|
|
387
|
+
const ops = [];
|
|
388
|
+
let evenOdd = false;
|
|
389
|
+
for (const child of def.children) {
|
|
390
|
+
if (!isElement(child) || !(SHAPES.has(child.name))) continue;
|
|
391
|
+
const childAttrs = attributesOf(child);
|
|
392
|
+
if (childAttrs["clip-rule"] === "evenodd") evenOdd = true;
|
|
393
|
+
let commands = shapeToPath(child, childAttrs, (v, ref) => parseLength(v, { reference: ref }), parentCtx.page.viewport);
|
|
394
|
+
if (!commands) continue;
|
|
395
|
+
commands = transformPath(commands, multiply(base, parseTransform(childAttrs.transform)));
|
|
396
|
+
ops.push(pathOps(commands));
|
|
397
|
+
}
|
|
398
|
+
if (ops.length) ctx.content.op(`${ops.join(" ")} ${evenOdd ? "W*" : "W"} n`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
402
|
+
// Marked content (tagged PDF)
|
|
403
|
+
|
|
404
|
+
markArtifact(ctx, draw) {
|
|
405
|
+
if (!this.tagged || ctx.inForm) { draw(); return; }
|
|
406
|
+
ctx.content.op("/Artifact BMC");
|
|
407
|
+
draw();
|
|
408
|
+
ctx.content.op("EMC");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Run `draw` inside a marked-content sequence bound to a structure element; returns nothing. */
|
|
412
|
+
markContent(ctx, node, role, extra, draw) {
|
|
413
|
+
if (!this.tagged || ctx.inForm) { draw(); return null; }
|
|
414
|
+
const page = ctx.page;
|
|
415
|
+
const mcid = page.mcids.length;
|
|
416
|
+
const parent = this.structureParent(ctx, node, role, extra);
|
|
417
|
+
page.mcids.push(parent);
|
|
418
|
+
parent.kids.push({ mcid });
|
|
419
|
+
ctx.content.op(`/${role} <</MCID ${mcid}>> BDC`);
|
|
420
|
+
draw();
|
|
421
|
+
ctx.content.op("EMC");
|
|
422
|
+
return { parent, mcid };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
structureParent(ctx, node, role, extra) {
|
|
426
|
+
const page = ctx.page;
|
|
427
|
+
if (role === "Figure") {
|
|
428
|
+
const figure = { role: "Figure", alt: extra?.alt, kids: [], parent: page.section, page };
|
|
429
|
+
page.section.kids.push(figure);
|
|
430
|
+
return figure;
|
|
431
|
+
}
|
|
432
|
+
// Text lines of one block (the nearest traced group) share one structure element, in source order.
|
|
433
|
+
let owner = node.parent;
|
|
434
|
+
while (owner && !(owner.name === "g" && owner.attrs?.["data-opf-path"])) owner = owner.parent;
|
|
435
|
+
const key = owner ?? node;
|
|
436
|
+
let block = page.blocks.get(key);
|
|
437
|
+
if (!block) {
|
|
438
|
+
const tracePath = String(owner?.attrs?.["data-opf-path"] ?? "");
|
|
439
|
+
block = { role: /\.title$/.test(tracePath) ? "H1" : "P", kids: [], parent: page.section, page };
|
|
440
|
+
page.blocks.set(key, block);
|
|
441
|
+
page.section.kids.push(block);
|
|
442
|
+
}
|
|
443
|
+
if (ctx.link) {
|
|
444
|
+
let linkElement = block.links?.get(ctx.link.node);
|
|
445
|
+
if (!linkElement) {
|
|
446
|
+
linkElement = { role: "Link", kids: [], parent: block, page, href: ctx.link.href };
|
|
447
|
+
(block.links ??= new Map()).set(ctx.link.node, linkElement);
|
|
448
|
+
block.kids.push(linkElement);
|
|
449
|
+
}
|
|
450
|
+
return linkElement;
|
|
451
|
+
}
|
|
452
|
+
return block;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
456
|
+
// Shapes and paint
|
|
457
|
+
|
|
458
|
+
async shape(node, attrs, ctx) {
|
|
459
|
+
const style = ctx.style;
|
|
460
|
+
const fontSize = parseFloat(style["font-size"]) || 16;
|
|
461
|
+
const viewport = ctx.page.viewport;
|
|
462
|
+
const resolve = (value, reference) => parseLength(value, { reference: reference ?? viewport.w, fontSize });
|
|
463
|
+
const commands = shapeToPath(node, attrs, (value, reference) => resolve(value, reference), ctx.page.viewport);
|
|
464
|
+
if (!commands) return;
|
|
465
|
+
const isLine = node.name === "line";
|
|
466
|
+
const fill = isLine ? null : await this.paint(style.fill, style, ctx, commands, "fill");
|
|
467
|
+
const strokeWidth = resolve(style["stroke-width"]) ?? 1;
|
|
468
|
+
const stroke = strokeWidth > 0 ? await this.paint(style.stroke, style, ctx, commands, "stroke") : null;
|
|
469
|
+
if (!fill && !stroke) return;
|
|
470
|
+
const content = ctx.content;
|
|
471
|
+
this.markArtifact(ctx, () => {
|
|
472
|
+
content.op("q");
|
|
473
|
+
const fillOpacity = fill?.color ? fill.color.a * num2(style["fill-opacity"]) : num2(style["fill-opacity"]);
|
|
474
|
+
const strokeOpacity = stroke?.color ? stroke.color.a * num2(style["stroke-opacity"]) : num2(style["stroke-opacity"]);
|
|
475
|
+
const ca = ctx.alpha * fillOpacity, CA = ctx.alpha * strokeOpacity;
|
|
476
|
+
if (ca < 1 || CA < 1) content.op(`/${content.graphicsState(fill ? ca : 1, stroke ? CA : 1)} gs`);
|
|
477
|
+
if (fill) this.setPaint(content, fill, "fill");
|
|
478
|
+
if (stroke) {
|
|
479
|
+
this.setPaint(content, stroke, "stroke");
|
|
480
|
+
content.op(`${num(strokeWidth, 4)} w`);
|
|
481
|
+
const cap = { butt: 0, round: 1, square: 2 }[style["stroke-linecap"]] ?? 0;
|
|
482
|
+
const join = { miter: 0, round: 1, bevel: 2, "miter-clip": 0, arcs: 0 }[style["stroke-linejoin"]] ?? 0;
|
|
483
|
+
content.op(`${cap} J ${join} j ${num(parseFloat(style["stroke-miterlimit"]) || 4, 3)} M`);
|
|
484
|
+
const dashes = parseNumberList(style["stroke-dasharray"]);
|
|
485
|
+
if (dashes.length && dashes.every((value) => value >= 0) && dashes.some((value) => value > 0)) {
|
|
486
|
+
const list = dashes.length % 2 ? [...dashes, ...dashes] : dashes;
|
|
487
|
+
content.op(`[${list.map((value) => num(value, 3)).join(" ")}] ${num(parseLength(style["stroke-dashoffset"], { fontSize }) ?? 0, 3)} d`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
content.op(pathOps(commands));
|
|
491
|
+
const evenOdd = style["fill-rule"] === "evenodd" ? "*" : "";
|
|
492
|
+
content.op(fill && stroke ? `B${evenOdd}` : fill ? `f${evenOdd}` : "S");
|
|
493
|
+
content.op("Q");
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
setPaint(content, paint, kind) {
|
|
498
|
+
if (paint.color) content.op(`${rgb(paint.color)} ${kind === "fill" ? "rg" : "RG"}`);
|
|
499
|
+
else content.op(`/Pattern ${kind === "fill" ? "cs" : "CS"} /${content.pattern(paint.resource, paint.object)} ${kind === "fill" ? "scn" : "SCN"}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Resolve a fill/stroke value to {color} or {resource, object} (a pattern), or null for none. */
|
|
503
|
+
async paint(value, style, ctx, commands, kind) {
|
|
504
|
+
if (value === undefined || value === null) return null;
|
|
505
|
+
const text = String(value).trim();
|
|
506
|
+
if (text === "none") return null;
|
|
507
|
+
const url = /^url\(\s*#([^)\s]+)\s*\)\s*(.*)$/.exec(text);
|
|
508
|
+
if (url) {
|
|
509
|
+
const def = ctx.page.defs.get(url[1]);
|
|
510
|
+
if (def?.name === "linearGradient" || def?.name === "radialGradient") {
|
|
511
|
+
const made = this.gradient(def, ctx, commands);
|
|
512
|
+
if (made) return made;
|
|
513
|
+
} else if (def?.name === "pattern") {
|
|
514
|
+
const made = await this.tilingPattern(def, ctx, commands);
|
|
515
|
+
if (made) return made;
|
|
516
|
+
}
|
|
517
|
+
const fallback = url[2] ? parseColor(url[2], parseColor(style.color)) : undefined;
|
|
518
|
+
if (fallback) return { color: fallback };
|
|
519
|
+
this.unsupported("pdf-unsupported-paint", `Paint ${text} could not be resolved; nothing is drawn for it.`, { kind });
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const color = parseColor(text, parseColor(style.color));
|
|
523
|
+
if (!color) {
|
|
524
|
+
if (!/^(none|inherit|context-(fill|stroke))$/i.test(text)) this.unsupported("pdf-unsupported-paint", `The colour "${text.slice(0, 40)}" is not understood; nothing is drawn for it.`, { kind });
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
return color.a === 0 ? null : { color };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
stops(def, ctx) {
|
|
531
|
+
let source = def;
|
|
532
|
+
const seen = new Set();
|
|
533
|
+
while (source && !source.children.some((child) => child.name === "stop") && !seen.has(source)) {
|
|
534
|
+
seen.add(source);
|
|
535
|
+
const href = attributesOf(source).href ?? attributesOf(source)["xlink:href"];
|
|
536
|
+
source = href && ctx.page.defs.get(href.replace(/^#/, ""));
|
|
537
|
+
}
|
|
538
|
+
if (!source) return [];
|
|
539
|
+
const stops = [];
|
|
540
|
+
let last = 0;
|
|
541
|
+
for (const child of source.children) {
|
|
542
|
+
if (child.name !== "stop") continue;
|
|
543
|
+
const attrs = attributesOf(child);
|
|
544
|
+
let offset = String(attrs.offset ?? "0").trim();
|
|
545
|
+
offset = offset.endsWith("%") ? parseFloat(offset) / 100 : parseFloat(offset);
|
|
546
|
+
offset = Math.min(1, Math.max(last, Number.isFinite(offset) ? offset : 0));
|
|
547
|
+
last = offset;
|
|
548
|
+
const color = parseColor(attrs["stop-color"] ?? "#000000") ?? { r: 0, g: 0, b: 0, a: 1 };
|
|
549
|
+
stops.push({ offset, color: { ...color, a: color.a * (attrs["stop-opacity"] === undefined ? 1 : parseFloat(attrs["stop-opacity"])) } });
|
|
550
|
+
}
|
|
551
|
+
return stops;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
inheritedAttribute(def, key, ctx) {
|
|
555
|
+
let current = def;
|
|
556
|
+
const seen = new Set();
|
|
557
|
+
while (current && !seen.has(current)) {
|
|
558
|
+
seen.add(current);
|
|
559
|
+
const value = attributesOf(current)[key];
|
|
560
|
+
if (value !== undefined) return value;
|
|
561
|
+
const href = attributesOf(current).href ?? attributesOf(current)["xlink:href"];
|
|
562
|
+
current = href && ctx.page.defs.get(href.replace(/^#/, ""));
|
|
563
|
+
}
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
gradient(def, ctx, commands) {
|
|
568
|
+
const stops = this.stops(def, ctx);
|
|
569
|
+
if (!stops.length) return null;
|
|
570
|
+
if (stops.length === 1) return { color: stops[0].color };
|
|
571
|
+
if (stops.some((stop) => stop.color.a < 1)) {
|
|
572
|
+
this.unsupported("pdf-unsupported-paint", "A gradient with transparent stops is drawn opaque in the vector PDF.", { element: def.attrs.id });
|
|
573
|
+
}
|
|
574
|
+
const spread = this.inheritedAttribute(def, "spreadMethod", ctx);
|
|
575
|
+
if (spread === "reflect" || spread === "repeat") {
|
|
576
|
+
this.unsupported("pdf-unsupported-paint", `spreadMethod="${spread}" is drawn as pad in the vector PDF.`, { element: def.attrs.id });
|
|
577
|
+
}
|
|
578
|
+
const units = this.inheritedAttribute(def, "gradientUnits", ctx) ?? "objectBoundingBox";
|
|
579
|
+
const bbox = pathBounds(commands);
|
|
580
|
+
if (units === "objectBoundingBox" && (!bbox || !(bbox.width > 0) || !(bbox.height > 0))) return null;
|
|
581
|
+
const unitMatrix = units === "objectBoundingBox" ? [bbox.width, 0, 0, bbox.height, bbox.x, bbox.y] : IDENTITY;
|
|
582
|
+
const read = (key, fallback) => {
|
|
583
|
+
const raw = this.inheritedAttribute(def, key, ctx);
|
|
584
|
+
if (raw === undefined) return fallback;
|
|
585
|
+
const text = String(raw).trim();
|
|
586
|
+
if (units === "objectBoundingBox") return text.endsWith("%") ? parseFloat(text) / 100 : parseFloat(text);
|
|
587
|
+
return parseLength(text, { reference: ctx.page.width }) ?? fallback;
|
|
588
|
+
};
|
|
589
|
+
const transform = parseTransform(this.inheritedAttribute(def, "gradientTransform", ctx));
|
|
590
|
+
const matrix = multiply(multiply(ctx.rel, unitMatrix), transform);
|
|
591
|
+
const functions = [], bounds = [], encode = [];
|
|
592
|
+
const padded = [...stops];
|
|
593
|
+
if (padded[0].offset > 0) padded.unshift({ offset: 0, color: padded[0].color });
|
|
594
|
+
if (padded.at(-1).offset < 1) padded.push({ offset: 1, color: padded.at(-1).color });
|
|
595
|
+
for (let index = 0; index + 1 < padded.length; index++) {
|
|
596
|
+
functions.push(`<</FunctionType 2/Domain[0 1]/C0[${rgb(padded[index].color)}]/C1[${rgb(padded[index + 1].color)}]/N 1>>`);
|
|
597
|
+
if (index + 2 < padded.length) bounds.push(num(padded[index + 1].offset, 5));
|
|
598
|
+
encode.push("0 1");
|
|
599
|
+
}
|
|
600
|
+
const fn = functions.length === 1 ? functions[0] : `<</FunctionType 3/Domain[0 1]/Functions[${functions.join("")}]/Bounds[${bounds.join(" ")}]/Encode[${encode.join(" ")}]>>`;
|
|
601
|
+
let shading;
|
|
602
|
+
if (def.name === "linearGradient") {
|
|
603
|
+
const coords = [read("x1", 0), read("y1", 0), read("x2", units === "objectBoundingBox" ? 1 : ctx.page.width), read("y2", 0)];
|
|
604
|
+
shading = `<</ShadingType 2/ColorSpace/DeviceRGB/Coords[${coords.map((value) => num(value, 5)).join(" ")}]/Function ${fn}/Extend[true true]>>`;
|
|
605
|
+
} else {
|
|
606
|
+
const half = units === "objectBoundingBox" ? 0.5 : ctx.page.width / 2;
|
|
607
|
+
const cx = read("cx", half), cy = read("cy", half), r = read("r", half);
|
|
608
|
+
const fx = read("fx", cx), fy = read("fy", cy);
|
|
609
|
+
shading = `<</ShadingType 3/ColorSpace/DeviceRGB/Coords[${[fx, fy, 0, cx, cy, r].map((value) => num(value, 5)).join(" ")}]/Function ${fn}/Extend[true true]>>`;
|
|
610
|
+
}
|
|
611
|
+
const resource = `P${++this.counters.P}`;
|
|
612
|
+
const object = this.file.add(`/Type/Pattern/PatternType 2/Matrix[${matrix.map((value) => num(value, 5)).join(" ")}]/Shading ${shading}`);
|
|
613
|
+
return { resource, object };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async tilingPattern(def, ctx, commands) {
|
|
617
|
+
const attrs = attributesOf(def);
|
|
618
|
+
const units = attrs.patternUnits ?? "objectBoundingBox";
|
|
619
|
+
const bbox = pathBounds(commands);
|
|
620
|
+
const read = (key, reference) => {
|
|
621
|
+
const raw = attrs[key];
|
|
622
|
+
if (raw === undefined) return 0;
|
|
623
|
+
if (units === "objectBoundingBox") return (String(raw).endsWith("%") ? parseFloat(raw) / 100 : parseFloat(raw)) * reference;
|
|
624
|
+
return parseLength(raw, { reference: ctx.page.width }) ?? 0;
|
|
625
|
+
};
|
|
626
|
+
if (units === "objectBoundingBox" && !bbox) return null;
|
|
627
|
+
const x = read("x", bbox?.width ?? 1) + (units === "objectBoundingBox" ? bbox.x : 0);
|
|
628
|
+
const y = read("y", bbox?.height ?? 1) + (units === "objectBoundingBox" ? bbox.y : 0);
|
|
629
|
+
const width = read("width", bbox?.width ?? 1), height = read("height", bbox?.height ?? 1);
|
|
630
|
+
if (!(width > 0 && height > 0)) return null;
|
|
631
|
+
const matrix = multiply(multiply(ctx.rel, parseTransform(attrs.patternTransform)), [1, 0, 0, 1, x, y]);
|
|
632
|
+
const cell = new Content(this);
|
|
633
|
+
const style = inheritStyle({ ...ROOT_STYLE }, attrs);
|
|
634
|
+
const viewBox = parseNumberList(attrs.viewBox);
|
|
635
|
+
let inner = IDENTITY;
|
|
636
|
+
if (viewBox.length === 4 && viewBox[2] > 0 && viewBox[3] > 0) inner = [width / viewBox[2], 0, 0, height / viewBox[3], -viewBox[0] * width / viewBox[2], -viewBox[1] * height / viewBox[3]];
|
|
637
|
+
else if (attrs.patternContentUnits === "objectBoundingBox" && bbox) inner = [bbox.width, 0, 0, bbox.height, 0, 0];
|
|
638
|
+
const cellCtx = { page: ctx.page, content: cell, matrix: multiply(ctx.matrix, inner), rel: inner, T: inner, style, alpha: 1, link: null, inForm: true, depth: (ctx.depth ?? 0) + 1 };
|
|
639
|
+
if (cellCtx.depth > 4) return null;
|
|
640
|
+
cell.op(`q ${matrixOps(inner)}`);
|
|
641
|
+
for (const child of def.children) await this.walk(child, cellCtx);
|
|
642
|
+
cell.op("Q");
|
|
643
|
+
const resource = `P${++this.counters.P}`;
|
|
644
|
+
const object = this.file.addStream(`/Type/Pattern/PatternType 1/PaintType 1/TilingType 1/BBox[0 0 ${num(width, 4)} ${num(height, 4)}]/XStep ${num(width, 4)}/YStep ${num(height, 4)}/Matrix[${matrix.map((value) => num(value, 5)).join(" ")}]/Resources<<${cell.resources()}>>`, cell.text());
|
|
645
|
+
return { resource, object };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
649
|
+
// Images
|
|
650
|
+
|
|
651
|
+
async image(node, attrs, ctx) {
|
|
652
|
+
const href = attrs.href ?? attrs["xlink:href"];
|
|
653
|
+
const tracePath = node.attrs["data-opf-path"];
|
|
654
|
+
if (typeof href !== "string" || !/^data:/i.test(href)) {
|
|
655
|
+
this.unsupported("pdf-image-skipped", "An image that is not an embedded data URI is not drawn (the export performs no network or file access).", { path: tracePath });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const decoded = await this.decodeImage(href, tracePath);
|
|
659
|
+
if (!decoded) return;
|
|
660
|
+
const x = parseLength(attrs.x) ?? 0, y = parseLength(attrs.y) ?? 0;
|
|
661
|
+
const width = parseLength(attrs.width, { reference: ctx.page.width }) ?? decoded.width, height = parseLength(attrs.height, { reference: ctx.page.height }) ?? decoded.height;
|
|
662
|
+
if (!(width > 0 && height > 0)) return;
|
|
663
|
+
const [align, mode] = String(attrs.preserveAspectRatio ?? "xMidYMid meet").trim().split(/\s+/);
|
|
664
|
+
let draw = { x, y, w: width, h: height }, clipBox = null;
|
|
665
|
+
if (align !== "none") {
|
|
666
|
+
const slice = mode === "slice";
|
|
667
|
+
const scale = slice ? Math.max(width / decoded.width, height / decoded.height) : Math.min(width / decoded.width, height / decoded.height);
|
|
668
|
+
const w = decoded.width * scale, h = decoded.height * scale;
|
|
669
|
+
const ax = /xMin/.test(align) ? 0 : /xMax/.test(align) ? 1 : 0.5, ay = /YMin/.test(align) ? 0 : /YMax/.test(align) ? 1 : 0.5;
|
|
670
|
+
draw = { x: x + (width - w) * ax, y: y + (height - h) * ay, w, h };
|
|
671
|
+
if (slice) clipBox = { x, y, w: width, h: height };
|
|
672
|
+
}
|
|
673
|
+
const content = ctx.content;
|
|
674
|
+
const alt = attrs["aria-label"] ?? attrs.alt ?? node.attrs["aria-label"];
|
|
675
|
+
const emit = () => {
|
|
676
|
+
content.op("q");
|
|
677
|
+
if (ctx.alpha < 1) content.op(`/${content.graphicsState(ctx.alpha, ctx.alpha)} gs`);
|
|
678
|
+
if (clipBox) content.op(`${num(clipBox.x)} ${num(clipBox.y)} ${num(clipBox.w)} ${num(clipBox.h)} re W n`);
|
|
679
|
+
content.op(`${num(draw.w, 4)} 0 0 ${num(-draw.h, 4)} ${num(draw.x, 4)} ${num(draw.y + draw.h, 4)} cm /${content.xobject(decoded.resource, decoded.object)} Do`);
|
|
680
|
+
content.op("Q");
|
|
681
|
+
};
|
|
682
|
+
if (alt) this.markContent(ctx, node, "Figure", { alt }, emit);
|
|
683
|
+
else this.markArtifact(ctx, emit);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async decodeImage(href, tracePath) {
|
|
687
|
+
const comma = href.indexOf(",");
|
|
688
|
+
if (comma < 0) return null;
|
|
689
|
+
const header = href.slice(0, comma), payload = href.slice(comma + 1);
|
|
690
|
+
let bytes;
|
|
691
|
+
try {
|
|
692
|
+
bytes = dataUriBytes(header, payload);
|
|
693
|
+
} catch {
|
|
694
|
+
this.unsupported("pdf-image-skipped", "An embedded image could not be decoded and is not drawn.", { path: tracePath });
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
const digest = hex(sha256(bytes));
|
|
698
|
+
if (this.images.has(digest)) return this.images.get(digest);
|
|
699
|
+
let result = null;
|
|
700
|
+
try {
|
|
701
|
+
const codec = requireCodec(this.options);
|
|
702
|
+
const meta = await codec.metadata(bytes);
|
|
703
|
+
const resource = `Im${++this.counters.Im}`;
|
|
704
|
+
const isJpeg = meta.format === "jpeg" && (meta.orientation === undefined || meta.orientation === 1) && (meta.channels === 3 || meta.channels === 1) && meta.space !== "cmyk";
|
|
705
|
+
if (isJpeg) {
|
|
706
|
+
const object = this.file.add(`/Type/XObject/Subtype/Image/Width ${meta.width}/Height ${meta.height}/ColorSpace/${meta.channels === 1 ? "DeviceGray" : "DeviceRGB"}/BitsPerComponent 8/Interpolate true/Filter/DCTDecode`, new Uint8Array(bytes));
|
|
707
|
+
result = { resource, object, width: meta.width, height: meta.height };
|
|
708
|
+
} else {
|
|
709
|
+
const { data, info } = await codec.rgba(bytes, { orient: true });
|
|
710
|
+
const pixels = info.width * info.height;
|
|
711
|
+
const color = new Uint8Array(pixels * 3), alpha = new Uint8Array(pixels);
|
|
712
|
+
let opaque = true;
|
|
713
|
+
for (let index = 0; index < pixels; index++) {
|
|
714
|
+
color[index * 3] = data[index * 4]; color[index * 3 + 1] = data[index * 4 + 1]; color[index * 3 + 2] = data[index * 4 + 2];
|
|
715
|
+
alpha[index] = data[index * 4 + 3];
|
|
716
|
+
if (alpha[index] !== 255) opaque = false;
|
|
717
|
+
}
|
|
718
|
+
let mask = "";
|
|
719
|
+
if (!opaque) {
|
|
720
|
+
const maskObject = this.file.addStream(`/Type/XObject/Subtype/Image/Width ${info.width}/Height ${info.height}/ColorSpace/DeviceGray/BitsPerComponent 8/Interpolate true`, alpha);
|
|
721
|
+
mask = `/SMask ${ref(maskObject)}`;
|
|
722
|
+
}
|
|
723
|
+
const object = this.file.addStream(`/Type/XObject/Subtype/Image/Width ${info.width}/Height ${info.height}/ColorSpace/DeviceRGB/BitsPerComponent 8/Interpolate true${mask}`, color);
|
|
724
|
+
result = { resource, object, width: info.width, height: info.height };
|
|
725
|
+
}
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.unsupported("pdf-image-skipped", `An embedded image could not be decoded and is not drawn: ${error instanceof Error ? error.message : String(error)}`, { path: tracePath });
|
|
728
|
+
}
|
|
729
|
+
this.images.set(digest, result);
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
734
|
+
// Text
|
|
735
|
+
|
|
736
|
+
async text(node, attrs, ctx) {
|
|
737
|
+
const { runs } = layoutText(node, this.env, ctx.parentStyle, ctx.link);
|
|
738
|
+
if (!runs.length) return;
|
|
739
|
+
for (const run of runs) {
|
|
740
|
+
// Gradients and patterns on text are fitted to the run's box.
|
|
741
|
+
const box = runBox(run);
|
|
742
|
+
run.fill = await this.paint(run.style.fill, run.style, ctx, box, "fill");
|
|
743
|
+
run.stroke = await this.paint(run.style.stroke, run.style, ctx, box, "stroke");
|
|
744
|
+
}
|
|
745
|
+
// Runs are drawn in visual (left to right) order. Right-to-left runs are marked /ReversedChars so extractors read
|
|
746
|
+
// their characters back to front, and a cluster whose glyph-to-Unicode map cannot give its text (a ligature, a
|
|
747
|
+
// letter split into base and dots, a mirrored bracket) carries /ActualText; a left-to-right run the map cannot
|
|
748
|
+
// recover (reordered Indic or Khmer clusters, a no-break space) carries one /ActualText for its text object.
|
|
749
|
+
for (const run of runs) {
|
|
750
|
+
run.entry = this.embedder.use(run.face);
|
|
751
|
+
for (const glyph of run.glyphs) glyph.cid = this.embedder.record(run.entry, glyph);
|
|
752
|
+
}
|
|
753
|
+
const draw = () => { for (const run of runs) this.drawRun(run, ctx); };
|
|
754
|
+
if (attrs["aria-hidden"] === "true") this.markArtifact(ctx, draw);
|
|
755
|
+
else this.markContent(ctx, node, "P", {}, draw);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
drawRun(run, ctx) {
|
|
759
|
+
const content = ctx.content;
|
|
760
|
+
const resource = content.font(run.entry);
|
|
761
|
+
const nominalCache = run.face.nominalCache;
|
|
762
|
+
const size = run.size, upem = run.face.upem;
|
|
763
|
+
const fill = run.fill;
|
|
764
|
+
const strokeWidth = parseLength(run.style["stroke-width"], { fontSize: size }) ?? 1;
|
|
765
|
+
const stroke = run.stroke;
|
|
766
|
+
const solidFill = fill;
|
|
767
|
+
if (!solidFill && !stroke) return;
|
|
768
|
+
const rtl = run.level % 2 === 1;
|
|
769
|
+
const runActual = !rtl && (!sameText(run) || NON_ASCII_SPACE.test(run.logical)) ? run.logical : null;
|
|
770
|
+
const operations = [];
|
|
771
|
+
let current = null, pending = 0, hex = "";
|
|
772
|
+
const flushHex = () => { if (hex) { current.items.push(`<${hex}>`); hex = ""; } };
|
|
773
|
+
// Adjustments are whole thousandths of an em; the remainder is carried to the next one, so rounding never accumulates.
|
|
774
|
+
const addNumber = () => {
|
|
775
|
+
const whole = Math.round(pending);
|
|
776
|
+
if (whole !== 0) { flushHex(); current.items.push(String(whole)); pending -= whole; }
|
|
777
|
+
};
|
|
778
|
+
const extra = run.extraSpacing ?? 0;
|
|
779
|
+
const scale = size / upem;
|
|
780
|
+
let pen = 0;
|
|
781
|
+
const decorations = [];
|
|
782
|
+
// A glyph the font displaced (a mark placed by GPOS) is shown alone with its own text matrix, and so is the glyph after
|
|
783
|
+
// it, so no text rise or large kerning number splits it into text objects that PDFium reads twice inside one span.
|
|
784
|
+
let placedBefore = false;
|
|
785
|
+
const showGlyph = (glyph, penBefore) => {
|
|
786
|
+
const nominal = nominalAdvance(run.face, glyph.gid, nominalCache);
|
|
787
|
+
const shaped = glyph.advance * 1000 / upem + (glyph.spacing + extra) * 1000 / size;
|
|
788
|
+
const placed = Math.abs(glyph.xOffset * scale) > 0.025 * size || Math.abs(glyph.yOffset * scale) > 0.03 * size;
|
|
789
|
+
const code = glyph.cid.toString(16).toUpperCase().padStart(4, "0");
|
|
790
|
+
if (placed || placedBefore) {
|
|
791
|
+
if (current) { addNumber(); flushHex(); }
|
|
792
|
+
// The text object starts at the glyph's natural position (PDFium merges an /ActualText span across adjacent objects only),
|
|
793
|
+
// and the font's horizontal placement is a leading adjustment inside it.
|
|
794
|
+
const shift = glyph.xOffset ? [num(-glyph.xOffset * 1000 / upem, 3)] : [];
|
|
795
|
+
operations.push({ at: [run.x + run.scaleX * penBefore, run.y - glyph.yOffset * scale], items: [...shift, `<${code}>`] });
|
|
796
|
+
current = null;
|
|
797
|
+
pending = 0;
|
|
798
|
+
placedBefore = placed;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (current === null) { current = { items: [] }; operations.push(current); }
|
|
802
|
+
addNumber();
|
|
803
|
+
hex += code;
|
|
804
|
+
pending += nominal - shaped;
|
|
805
|
+
};
|
|
806
|
+
// Each unit is a glyph, or for a left-to-right run the glyph map cannot give back (reordered Indic or Khmer clusters, a
|
|
807
|
+
// no-break space) a cluster of glyphs. A unit whose text the map cannot give carries it as /ActualText, one span per
|
|
808
|
+
// unit as Chromium writes them; a run that cannot be split into clusters gets one span for the whole run.
|
|
809
|
+
let units, wholeRun = false;
|
|
810
|
+
if (runActual === null) units = run.glyphs.map((glyph) => ({ glyphs: [glyph], actual: actualTextFor(run, glyph) }));
|
|
811
|
+
else {
|
|
812
|
+
units = textClusters(run);
|
|
813
|
+
if (units === null) { wholeRun = true; units = [{ glyphs: run.glyphs, actual: null }]; }
|
|
814
|
+
}
|
|
815
|
+
const openSpan = (text) => {
|
|
816
|
+
if (current) { addNumber(); flushHex(); }
|
|
817
|
+
current = null;
|
|
818
|
+
operations.push({ actual: text });
|
|
819
|
+
};
|
|
820
|
+
const closeSpan = () => {
|
|
821
|
+
if (current) { addNumber(); flushHex(); }
|
|
822
|
+
current = null;
|
|
823
|
+
operations.push({ end: true });
|
|
824
|
+
};
|
|
825
|
+
if (wholeRun) openSpan(runActual);
|
|
826
|
+
for (const unit of units) {
|
|
827
|
+
if (unit.actual !== null) openSpan(unit.actual);
|
|
828
|
+
for (const glyph of unit.glyphs) {
|
|
829
|
+
const advance = glyph.advance * scale + glyph.spacing + extra;
|
|
830
|
+
if (glyph.codePoints.length === 0) {
|
|
831
|
+
// A glyph that stands for no character (a dot or mark the font split off a letter) is drawn as a filled outline,
|
|
832
|
+
// so extractors see only the letters.
|
|
833
|
+
decorations.push({ glyph, pen });
|
|
834
|
+
pending -= advance * 1000 / size;
|
|
835
|
+
} else showGlyph(glyph, pen);
|
|
836
|
+
pen += advance;
|
|
837
|
+
}
|
|
838
|
+
if (unit.actual !== null) closeSpan();
|
|
839
|
+
}
|
|
840
|
+
if (wholeRun) closeSpan();
|
|
841
|
+
if (current) { addNumber(); flushHex(); }
|
|
842
|
+
const alpha = ctx.alpha;
|
|
843
|
+
const ca = alpha * (solidFill ? (solidFill.color?.a ?? 1) * num2(run.style["fill-opacity"]) : 1), CA = alpha * (stroke?.color ? stroke.color.a * num2(run.style["stroke-opacity"]) : 1);
|
|
844
|
+
content.op("q");
|
|
845
|
+
if (ca < 1 || CA < 1) content.op(`/${content.graphicsState(ca, CA)} gs`);
|
|
846
|
+
if (solidFill) this.setPaint(content, solidFill, "fill");
|
|
847
|
+
if (stroke) { this.setPaint(content, stroke, "stroke"); content.op(`${num(strokeWidth, 3)} w`); }
|
|
848
|
+
content.op("BT");
|
|
849
|
+
if (rtl) content.op("/ReversedChars BMC");
|
|
850
|
+
content.op(`/${resource} ${num(size, 3)} Tf`);
|
|
851
|
+
const mode = solidFill && stroke ? 2 : stroke ? 1 : 0;
|
|
852
|
+
if (mode) content.op(`${mode} Tr`);
|
|
853
|
+
if (run.scaleX !== 1) content.op(`${num(run.scaleX * 100, 4)} Tz`);
|
|
854
|
+
content.op(`1 0 0 -1 ${num(run.x, 4)} ${num(run.y, 4)} Tm`);
|
|
855
|
+
let line = [run.x, run.y];
|
|
856
|
+
for (const operation of operations) {
|
|
857
|
+
if (operation.actual !== undefined) content.op(`/Span <</ActualText <FEFF${utf16Hex(operation.actual)}> >> BDC`);
|
|
858
|
+
else if (operation.end) content.op("EMC");
|
|
859
|
+
else {
|
|
860
|
+
if (!operation.items.length) continue;
|
|
861
|
+
if (operation.at) {
|
|
862
|
+
// Td moves from the start of the current line, which is where the last Td or Tm put it.
|
|
863
|
+
content.op(`${num(operation.at[0] - line[0], 4)} ${num(line[1] - operation.at[1], 4)} Td`);
|
|
864
|
+
line = operation.at;
|
|
865
|
+
}
|
|
866
|
+
content.op(operation.items.length === 1 && operation.items[0][0] === "<" ? `${operation.items[0]} Tj` : `[${operation.items.join("")}] TJ`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (rtl) content.op("EMC");
|
|
870
|
+
content.op("ET");
|
|
871
|
+
for (const { glyph, pen: origin } of decorations) {
|
|
872
|
+
const commands = glyphOutline(run.face.font, glyph.gid, (x, y) => [run.x + (origin + (glyph.xOffset + x) * scale) * run.scaleX, run.y - (glyph.yOffset + y) * scale]);
|
|
873
|
+
if (commands.length) content.op(`${pathOps(commands)} f`);
|
|
874
|
+
}
|
|
875
|
+
// Underline / strike-through / overline in the text colour, from the font's own metrics.
|
|
876
|
+
if (run.decoration.size && solidFill) {
|
|
877
|
+
const font = run.face.font, scale = size / upem;
|
|
878
|
+
for (const kind of run.decoration) {
|
|
879
|
+
let position, thickness;
|
|
880
|
+
if (kind === "underline") { position = -(font.underlinePosition || -upem * 0.1) * scale; thickness = (font.underlineThickness || upem * 0.05) * scale; }
|
|
881
|
+
else if (kind === "line-through") { position = -(font["OS/2"]?.yStrikeoutPosition || upem * 0.3) * scale; thickness = (font["OS/2"]?.yStrikeoutSize || upem * 0.05) * scale; }
|
|
882
|
+
else { position = font.ascent * scale; thickness = (font.underlineThickness || upem * 0.05) * scale; position = -position; }
|
|
883
|
+
const lineWidth = run.width;
|
|
884
|
+
content.op(`${num(run.x, 3)} ${num(run.y + position - thickness / 2, 3)} ${num(lineWidth, 3)} ${num(thickness, 3)} re f`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
content.op("Q");
|
|
888
|
+
if (run.link) this.linkAnnotation(run, ctx);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
linkAnnotation(run, ctx) {
|
|
892
|
+
const uri = safeUri(run.link.href);
|
|
893
|
+
if (uri === null) {
|
|
894
|
+
this.unsupported("pdf-link-skipped", `The link target ${JSON.stringify(run.link.href).slice(0, 80)} is not an http, https, mailto or tel address; no PDF link is created.`, {});
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const scale = run.size / run.face.upem;
|
|
898
|
+
const top = run.y - run.face.font.ascent * scale, bottom = run.y - run.face.font.descent * scale;
|
|
899
|
+
const corners = [[run.x, top], [run.x + run.width, top], [run.x + run.width, bottom], [run.x, bottom]].map(([x, y]) => applyPoint(ctx.matrix, x, y));
|
|
900
|
+
const box = boundsOf(corners);
|
|
901
|
+
const page = ctx.page;
|
|
902
|
+
const annotation = { rect: [box.x, page.height - box.y - box.height, box.x + box.width, page.height - box.y], uri, object: this.file.reserve(), parent: null };
|
|
903
|
+
page.annotations.push(annotation);
|
|
904
|
+
const last = page.mcids.at(-1);
|
|
905
|
+
annotation.parent = last?.role === "Link" ? last : null;
|
|
906
|
+
if (annotation.parent) (annotation.parent.annots ??= []).push(annotation);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
910
|
+
// Raster fallback for effects with no PDF equivalent
|
|
911
|
+
|
|
912
|
+
async fallback(node, ctx, reason) {
|
|
913
|
+
const tracePath = node.attrs["data-opf-path"];
|
|
914
|
+
this.unsupported("pdf-raster-fallback", `<${node.name}> uses ${reason}, which has no PDF vector equivalent; this element alone is rasterized.`, { path: tracePath, element: node.name, reason });
|
|
915
|
+
if (!this.options.rasterize) return;
|
|
916
|
+
const page = ctx.page;
|
|
917
|
+
// A standalone SVG of the page's defs, the element's ancestors' inherited style and transform, and the element.
|
|
918
|
+
const chain = [];
|
|
919
|
+
for (let parent = node.parent; parent && parent !== page.svg; parent = parent.parent) {
|
|
920
|
+
if (parent.name === "g" || parent.name === "a") chain.unshift(parent);
|
|
921
|
+
}
|
|
922
|
+
const inheritedKeys = ["fill", "fill-opacity", "fill-rule", "stroke", "stroke-opacity", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray", "font-family", "font-size", "font-style", "font-weight", "color", "text-anchor", "transform", "direction", "style"];
|
|
923
|
+
const open = chain.map((parent) => `<g${inheritedKeys.filter((key) => parent.attrs[key] !== undefined).map((key) => ` ${key}="${escapeAttribute(parent.attrs[key])}"`).join("")}>`).join("");
|
|
924
|
+
const defs = [];
|
|
925
|
+
const collect = (element) => { for (const child of element.children ?? []) if (isElement(child)) { if (child.name === "defs") defs.push(serialize(child, onlyLocalReferences)); collect(child); } };
|
|
926
|
+
collect(page.svg);
|
|
927
|
+
const rootAttrs = page.svg.attrs;
|
|
928
|
+
const standalone = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${page.width}" height="${page.height}" viewBox="${escapeAttribute(rootAttrs.viewBox ?? `0 0 ${page.width} ${page.height}`)}">${defs.join("")}${open}${serialize(node, onlyLocalReferences)}${"</g>".repeat(chain.length)}</svg>`;
|
|
929
|
+
const scale = this.options.rasterFallbackScale;
|
|
930
|
+
const { png } = await this.options.rasterize(standalone, scale);
|
|
931
|
+
const { data, info } = await requireCodec(this.options).rgba(png, { orient: false });
|
|
932
|
+
let minX = info.width, minY = info.height, maxX = -1, maxY = -1;
|
|
933
|
+
for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) {
|
|
934
|
+
if (data[(y * info.width + x) * 4 + 3]) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; }
|
|
935
|
+
}
|
|
936
|
+
if (maxX < 0) return;
|
|
937
|
+
const w = maxX - minX + 1, h = maxY - minY + 1;
|
|
938
|
+
const color = new Uint8Array(w * h * 3), alpha = new Uint8Array(w * h);
|
|
939
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
940
|
+
const from = ((y + minY) * info.width + x + minX) * 4, to = y * w + x;
|
|
941
|
+
color[to * 3] = data[from]; color[to * 3 + 1] = data[from + 1]; color[to * 3 + 2] = data[from + 2]; alpha[to] = data[from + 3];
|
|
942
|
+
}
|
|
943
|
+
const mask = this.file.addStream(`/Type/XObject/Subtype/Image/Width ${w}/Height ${h}/ColorSpace/DeviceGray/BitsPerComponent 8/Interpolate true`, alpha);
|
|
944
|
+
const object = this.file.addStream(`/Type/XObject/Subtype/Image/Width ${w}/Height ${h}/ColorSpace/DeviceRGB/BitsPerComponent 8/Interpolate true/SMask ${ref(mask)}`, color);
|
|
945
|
+
const resource = `Im${++this.counters.Im}`;
|
|
946
|
+
const content = ctx.content;
|
|
947
|
+
const inverse = invert(ctx.matrix) ?? IDENTITY;
|
|
948
|
+
const sx = page.width / info.width, sy = page.height / info.height;
|
|
949
|
+
this.markArtifact(ctx, () => {
|
|
950
|
+
content.op("q");
|
|
951
|
+
content.op(`${matrixOps(inverse)} ${num(w * sx, 4)} 0 0 ${num(-h * sy, 4)} ${num(minX * sx, 4)} ${num((minY + h) * sy, 4)} cm /${content.xobject(resource, object)} Do`);
|
|
952
|
+
content.op("Q");
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// -----------------------------------------------------------------------------------------------------------
|
|
957
|
+
// Document assembly
|
|
958
|
+
|
|
959
|
+
async finish(metadata, language) {
|
|
960
|
+
let reports;
|
|
961
|
+
try { reports = this.embedder.finish(); } catch (error) {
|
|
962
|
+
throw this.error("pdf-font-unreadable", `A font could not be embedded: ${error instanceof Error ? error.message : String(error)}`, {});
|
|
963
|
+
}
|
|
964
|
+
for (const report of reports) this.diagnostic(report);
|
|
965
|
+
const file = this.file;
|
|
966
|
+
const pagesObject = file.reserve();
|
|
967
|
+
const pageObjects = [];
|
|
968
|
+
const tagged = this.tagged;
|
|
969
|
+
const structure = tagged ? this.buildStructure(pagesObject, language) : null;
|
|
970
|
+
|
|
971
|
+
for (const page of this.pages) {
|
|
972
|
+
const contents = file.addStream("", page.content.text());
|
|
973
|
+
const annotations = page.annotations.map((annotation) => {
|
|
974
|
+
const struct = tagged && annotation.parent ? ` /StructParent ${structure.annotationKey(annotation)}` : "";
|
|
975
|
+
file.set(annotation.object, `/Type/Annot/Subtype/Link/Rect[${annotation.rect.map((value) => num(value, 3)).join(" ")}]/Border[0 0 0]/F 4/A<</S/URI/URI${asciiString(annotation.uri)}>>/P ${ref(page.object)}${struct}`);
|
|
976
|
+
return ref(annotation.object);
|
|
977
|
+
});
|
|
978
|
+
const structParents = tagged && page.mcids.length ? `/StructParents ${structure.pageKey(page)}` : "";
|
|
979
|
+
file.set(page.object,
|
|
980
|
+
`/Type/Page/Parent ${ref(pagesObject)}/MediaBox[0 0 ${num(page.width)} ${num(page.height)}]/Resources<<${page.content.resources()}>>/Contents ${ref(contents)}` +
|
|
981
|
+
`/Group<</S/Transparency/CS/DeviceRGB>>${annotations.length ? `/Annots[${annotations.join(" ")}]` : ""}${structParents}${tagged ? "/Tabs/S" : ""}`);
|
|
982
|
+
pageObjects.push(page.object);
|
|
983
|
+
}
|
|
984
|
+
file.set(pagesObject, `/Type/Pages/Count ${pageObjects.length}/Kids[${pageObjects.map(ref).join(" ")}]`);
|
|
985
|
+
|
|
986
|
+
const info = this.infoDictionary(metadata);
|
|
987
|
+
const xmp = file.addStream("/Type/Metadata/Subtype/XML", utf8Encoder.encode(xmpPacket(metadata, language, this.options.producer)), { raw: true });
|
|
988
|
+
const catalogParts = [`/Type/Catalog/Pages ${ref(pagesObject)}/Metadata ${ref(xmp)}`];
|
|
989
|
+
if (language) catalogParts.push(`/Lang${asciiString(language)}`);
|
|
990
|
+
if (metadata.title) catalogParts.push("/ViewerPreferences<</DisplayDocTitle true>>");
|
|
991
|
+
if (tagged) catalogParts.push(`/MarkInfo<</Marked true>>/StructTreeRoot ${ref(structure.root)}`);
|
|
992
|
+
const catalog = file.add(catalogParts.join(""));
|
|
993
|
+
return file.serialize({ catalog, info });
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
infoDictionary(metadata) {
|
|
997
|
+
const parts = [];
|
|
998
|
+
if (metadata.title) parts.push(`/Title${textString(metadata.title)}`);
|
|
999
|
+
if (metadata.author) parts.push(`/Author${textString(metadata.author)}`);
|
|
1000
|
+
if (metadata.subject) parts.push(`/Subject${textString(metadata.subject)}`);
|
|
1001
|
+
if (metadata.keywords) parts.push(`/Keywords${textString(Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords)}`);
|
|
1002
|
+
parts.push(`/Creator${textString(metadata.creator ?? this.options.producer)}/Producer${textString(this.options.producer)}`);
|
|
1003
|
+
const created = pdfDate(metadata.creationDate);
|
|
1004
|
+
if (created) parts.push(`/CreationDate${asciiString(created)}`);
|
|
1005
|
+
const modified = pdfDate(metadata.modificationDate ?? metadata.creationDate);
|
|
1006
|
+
if (modified) parts.push(`/ModDate${asciiString(modified)}`);
|
|
1007
|
+
return this.file.add(parts.join(""));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** The logical structure tree: Document > one Sect per slide > H1/P/Figure (> Link) in source order. */
|
|
1011
|
+
buildStructure(pagesObject, documentLanguage) {
|
|
1012
|
+
const file = this.file;
|
|
1013
|
+
const root = file.reserve();
|
|
1014
|
+
const documentElement = file.reserve();
|
|
1015
|
+
const objects = new Map();
|
|
1016
|
+
const numberOf = (element) => {
|
|
1017
|
+
let object = objects.get(element);
|
|
1018
|
+
if (!object) { object = file.reserve(); objects.set(element, object); }
|
|
1019
|
+
return object;
|
|
1020
|
+
};
|
|
1021
|
+
const parentTree = [];
|
|
1022
|
+
let nextKey = 0;
|
|
1023
|
+
const pageKeys = new Map(), annotationKeys = new Map();
|
|
1024
|
+
const sections = [];
|
|
1025
|
+
for (const page of this.pages) {
|
|
1026
|
+
const section = page.section;
|
|
1027
|
+
if (!section.kids.length) continue;
|
|
1028
|
+
sections.push(section);
|
|
1029
|
+
}
|
|
1030
|
+
const emit = (element, parentObject) => {
|
|
1031
|
+
const object = numberOf(element);
|
|
1032
|
+
const kids = element.kids.map((kid) => {
|
|
1033
|
+
if (kid.mcid !== undefined) return String(kid.mcid);
|
|
1034
|
+
emit(kid, object);
|
|
1035
|
+
return ref(numberOf(kid));
|
|
1036
|
+
});
|
|
1037
|
+
for (const annotation of element.annots ?? []) kids.push(`<</Type/OBJR/Obj ${ref(annotation.object)}/Pg ${ref(element.page.object)}>>`);
|
|
1038
|
+
const alt = element.alt ? `/Alt${textString(element.alt)}` : "";
|
|
1039
|
+
// A slide whose SVG declares another language than the document says so on its section.
|
|
1040
|
+
const lang = element.role === "Sect" && element.page.language && element.page.language !== documentLanguage ? `/Lang${asciiString(element.page.language)}` : "";
|
|
1041
|
+
file.set(object, `/Type/StructElem/S${name(element.role)}/P ${ref(parentObject)}/Pg ${ref(element.page.object)}${alt}${lang}/K[${kids.join(" ")}]`);
|
|
1042
|
+
};
|
|
1043
|
+
for (const section of sections) emit(section, documentElement);
|
|
1044
|
+
file.set(documentElement, `/Type/StructElem/S/Document/P ${ref(root)}/K[${sections.map((section) => ref(numberOf(section))).join(" ")}]`);
|
|
1045
|
+
for (const page of this.pages) {
|
|
1046
|
+
if (!page.mcids.length) continue;
|
|
1047
|
+
const key = nextKey++;
|
|
1048
|
+
pageKeys.set(page, key);
|
|
1049
|
+
parentTree.push(`${key}[${page.mcids.map((element) => ref(numberOf(element))).join(" ")}]`);
|
|
1050
|
+
}
|
|
1051
|
+
for (const page of this.pages) {
|
|
1052
|
+
for (const annotation of page.annotations) {
|
|
1053
|
+
if (!annotation.parent) continue;
|
|
1054
|
+
const key = nextKey++;
|
|
1055
|
+
annotationKeys.set(annotation, key);
|
|
1056
|
+
parentTree.push(`${key} ${ref(numberOf(annotation.parent))}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const tree = file.add(`/Nums[${parentTree.join(" ")}]`);
|
|
1060
|
+
file.set(root, `/Type/StructTreeRoot/K ${ref(documentElement)}/ParentTree ${ref(tree)}/ParentTreeNextKey ${nextKey}`);
|
|
1061
|
+
return { root, pageKey: (page) => pageKeys.get(page), annotationKey: (annotation) => annotationKeys.get(annotation) };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1066
|
+
// Helpers
|
|
1067
|
+
|
|
1068
|
+
function positive(value, fallback) {
|
|
1069
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function num2(value) {
|
|
1073
|
+
const parsed = parseFloat(value);
|
|
1074
|
+
return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : 1;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function rgb(color) {
|
|
1078
|
+
return `${num(color.r, 4)} ${num(color.g, 4)} ${num(color.b, 4)}`;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function matrixOps(matrix) {
|
|
1082
|
+
return `${matrix.map((value) => num(value, 5)).join(" ")} cm`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function pathOps(commands) {
|
|
1086
|
+
return commands.map(([type, ...values]) => {
|
|
1087
|
+
const coordinates = values.map((value) => num(value, 3)).join(" ");
|
|
1088
|
+
return { M: `${coordinates} m`, L: `${coordinates} l`, C: `${coordinates} c`, Z: "h" }[type];
|
|
1089
|
+
}).join(" ");
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** A glyph's outline as absolute path commands, each point mapped by `place(fontX, fontY)`. */
|
|
1093
|
+
function glyphOutline(font, gid, place) {
|
|
1094
|
+
const commands = [];
|
|
1095
|
+
let x = 0, y = 0;
|
|
1096
|
+
for (const { command, args } of font.getGlyph(gid).path.commands) {
|
|
1097
|
+
if (command === "moveTo") { [x, y] = args; commands.push(["M", ...place(x, y)]); }
|
|
1098
|
+
else if (command === "lineTo") { [x, y] = args; commands.push(["L", ...place(x, y)]); }
|
|
1099
|
+
else if (command === "quadraticCurveTo") {
|
|
1100
|
+
const [cx, cy, ex, ey] = args;
|
|
1101
|
+
commands.push(["C", ...place(x + 2 / 3 * (cx - x), y + 2 / 3 * (cy - y)), ...place(ex + 2 / 3 * (cx - ex), ey + 2 / 3 * (cy - ey)), ...place(ex, ey)]);
|
|
1102
|
+
x = ex; y = ey;
|
|
1103
|
+
} else if (command === "bezierCurveTo") {
|
|
1104
|
+
commands.push(["C", ...place(args[0], args[1]), ...place(args[2], args[3]), ...place(args[4], args[5])]);
|
|
1105
|
+
x = args[4]; y = args[5];
|
|
1106
|
+
} else if (command === "closePath") commands.push(["Z"]);
|
|
1107
|
+
}
|
|
1108
|
+
return commands;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function boundsOf(points) {
|
|
1112
|
+
const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]);
|
|
1113
|
+
const x = Math.min(...xs), y = Math.min(...ys);
|
|
1114
|
+
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function countLeaves(node) {
|
|
1118
|
+
if (!isElement(node)) return 0;
|
|
1119
|
+
if (SKIPPED.has(node.name)) return 0;
|
|
1120
|
+
if (SHAPES.has(node.name) || node.name === "image") return 1;
|
|
1121
|
+
if (node.name === "text") return 1;
|
|
1122
|
+
let count = 0;
|
|
1123
|
+
for (const child of node.children) { count += countLeaves(child); if (count > 1) break; }
|
|
1124
|
+
return count;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// The rasterizer may read files: a rasterized fragment keeps only embedded (data:) and in-document (#id) references.
|
|
1128
|
+
function onlyLocalReferences(key, value) {
|
|
1129
|
+
return !(key === "href" || key === "xlink:href") || /^(data:|#)/i.test(String(value).trim());
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function runBox(run) {
|
|
1133
|
+
const scale = run.size / run.face.upem;
|
|
1134
|
+
const top = run.y - run.face.font.ascent * scale, bottom = run.y - run.face.font.descent * scale;
|
|
1135
|
+
return [["M", run.x, top], ["L", run.x + run.width, top], ["L", run.x + run.width, bottom], ["L", run.x, bottom], ["Z"]];
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
function sameText(run) {
|
|
1140
|
+
if (run.level % 2 === 1) return false;
|
|
1141
|
+
const got = run.glyphs.flatMap((glyph) => glyph.codePoints);
|
|
1142
|
+
const expected = [...run.text].map((character) => character.codePointAt(0));
|
|
1143
|
+
return got.length === expected.length && got.every((value, index) => value === expected[index]);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function safeUri(value) {
|
|
1147
|
+
const text = String(value).trim();
|
|
1148
|
+
if (!/^(https?:|mailto:|tel:)/i.test(text)) return null;
|
|
1149
|
+
// PDF URI strings are 7-bit ASCII: percent-encode anything else (UTF-8).
|
|
1150
|
+
let out = "";
|
|
1151
|
+
for (const byte of utf8Encoder.encode(text)) out += byte > 0x20 && byte < 0x7f ? String.fromCharCode(byte) : "%" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
1152
|
+
return out;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function pdfDate(value) {
|
|
1156
|
+
if (value === undefined || value === null) return null;
|
|
1157
|
+
// A date-time without a zone designator would be read in the machine's time zone; the export reads it as UTC.
|
|
1158
|
+
const text = typeof value === "string" && /^\d{4}-\d{2}-\d{2}[T ][\d:.]+$/.test(value.trim()) ? value.trim().replace(" ", "T") + "Z" : value;
|
|
1159
|
+
const date = text instanceof Date ? text : new Date(text);
|
|
1160
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
1161
|
+
const pad = (number, length = 2) => String(number).padStart(length, "0");
|
|
1162
|
+
return `D:${pad(date.getUTCFullYear(), 4)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function xmpEscape(value) {
|
|
1166
|
+
const control = new RegExp("[" + [0,8,11,12].map((c) => "\\x" + c.toString(16).padStart(2, "0")).join("") + "\\x0e-\\x1f\\ufffe\\uffff]", "g");
|
|
1167
|
+
return String(value).replace(control, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function xmpPacket(metadata, language, producer) {
|
|
1171
|
+
const alt = (tag, value) => `<dc:${tag}><rdf:Alt><rdf:li xml:lang="x-default">${xmpEscape(value)}</rdf:li></rdf:Alt></dc:${tag}>`;
|
|
1172
|
+
const created = pdfDate(metadata.creationDate);
|
|
1173
|
+
const iso = created ? `${created.slice(2, 6)}-${created.slice(6, 8)}-${created.slice(8, 10)}T${created.slice(10, 12)}:${created.slice(12, 14)}:${created.slice(14, 16)}Z` : null;
|
|
1174
|
+
return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>\n<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/">` +
|
|
1175
|
+
`<dc:format>application/pdf</dc:format>${metadata.title ? alt("title", metadata.title) : ""}${metadata.author ? `<dc:creator><rdf:Seq><rdf:li>${xmpEscape(metadata.author)}</rdf:li></rdf:Seq></dc:creator>` : ""}${metadata.subject ? alt("description", metadata.subject) : ""}${language ? `<dc:language><rdf:Bag><rdf:li>${xmpEscape(language)}</rdf:li></rdf:Bag></dc:language>` : ""}` +
|
|
1176
|
+
`<pdf:Producer>${xmpEscape(producer)}</pdf:Producer>${iso ? `<xmp:CreateDate>${iso}</xmp:CreateDate>` : ""}</rdf:Description></rdf:RDF></x:xmpmeta>\n<?xpacket end="w"?>`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// The Node modules the font-file paths need, loaded only when a path is given. The specifiers are not literals, so a browser bundler
|
|
1180
|
+
// leaves them alone: a browser host never passes a font path.
|
|
1181
|
+
async function nodeFileSystem() {
|
|
1182
|
+
const fsName = "node:fs/promises", pathName = "node:path";
|
|
1183
|
+
const [fs, path] = await Promise.all([import(/* webpackIgnore: true */ /* @vite-ignore */ fsName), import(/* webpackIgnore: true */ /* @vite-ignore */ pathName)]);
|
|
1184
|
+
return { readdir: fs.readdir, readFile: fs.readFile, stat: fs.stat, path: path.default ?? path };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Pictures are decoded by the host's `imageCodec` (sharp in Node, see raster.js; a canvas in a browser, see export-browser.js):
|
|
1188
|
+
// `metadata(bytes)` gives {format, width, height, orientation, channels, space} and `rgba(bytes, {orient})` gives
|
|
1189
|
+
// {data, info: {width, height}} with 8-bit RGBA pixels (EXIF orientation applied when `orient`). This module imports no decoder, so a
|
|
1190
|
+
// browser bundle never reaches the native ones.
|
|
1191
|
+
function requireCodec(options) {
|
|
1192
|
+
if (!options.imageCodec) throw new Error("This conversion has no image codec.");
|
|
1193
|
+
return options.imageCodec;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** The bytes of a data: URI payload (base64, or percent-encoded text read as Latin-1 bytes), without Buffer. */
|
|
1197
|
+
export function dataUriBytes(header, payload) {
|
|
1198
|
+
const text = decodeURIComponent(payload);
|
|
1199
|
+
if (/;base64/i.test(header)) {
|
|
1200
|
+
// Lenient like Buffer.from(..., "base64"): characters outside the alphabet are skipped and missing padding is added.
|
|
1201
|
+
const clean = text.replace(/[^A-Za-z0-9+/]/g, "");
|
|
1202
|
+
const binary = atob(clean + "=".repeat((4 - (clean.length % 4)) % 4));
|
|
1203
|
+
const bytes = new Uint8Array(binary.length);
|
|
1204
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
1205
|
+
return bytes;
|
|
1206
|
+
}
|
|
1207
|
+
const bytes = new Uint8Array(text.length);
|
|
1208
|
+
for (let index = 0; index < text.length; index++) bytes[index] = text.charCodeAt(index) & 0xff;
|
|
1209
|
+
return bytes;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function listFonts(directory, { readdir, path }) {
|
|
1213
|
+
const found = [];
|
|
1214
|
+
let entries;
|
|
1215
|
+
try { entries = await readdir(directory, { withFileTypes: true }); } catch { return found; }
|
|
1216
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1217
|
+
for (const entry of entries) {
|
|
1218
|
+
const full = path.join(directory, entry.name);
|
|
1219
|
+
if (entry.isDirectory()) found.push(...await listFonts(full, { readdir, path }));
|
|
1220
|
+
else if (/\.(ttf|otf)$/i.test(entry.name)) found.push(full);
|
|
1221
|
+
}
|
|
1222
|
+
return found;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Convert SVG slides to a vector PDF. `options` carries the resolved conversion settings (see svgToPdf in raster.js).
|
|
1227
|
+
*/
|
|
1228
|
+
export async function svgsToVectorPdf(svgs, options) {
|
|
1229
|
+
const converter = new Converter(options);
|
|
1230
|
+
await converter.loadFontFiles(options.fontFiles ?? [], options.fontDirs ?? []);
|
|
1231
|
+
// Faces handed over as bytes (a browser host has no file paths): { data, family? }.
|
|
1232
|
+
for (const face of options.fontData ?? []) converter.fonts.addData(face.data, face.origin ?? "fontData", face.family);
|
|
1233
|
+
// `signal` cancels between pages, `onProgress({ page, pages })` reports after each one, and `yieldToHost` lets a browser repaint between them.
|
|
1234
|
+
for (const [index, source] of svgs.entries()) {
|
|
1235
|
+
options.signal?.throwIfAborted?.();
|
|
1236
|
+
await converter.addPage(source, index);
|
|
1237
|
+
options.onProgress?.({ page: index + 1, pages: svgs.length });
|
|
1238
|
+
if (options.yieldToHost) await options.yieldToHost();
|
|
1239
|
+
}
|
|
1240
|
+
options.signal?.throwIfAborted?.();
|
|
1241
|
+
const first = converter.pages[0];
|
|
1242
|
+
// Control characters have no place in document properties.
|
|
1243
|
+
const control = new RegExp("[" + String.fromCharCode(0) + "-" + String.fromCharCode(31) + String.fromCharCode(127) + "]", "g");
|
|
1244
|
+
const metadata = Object.fromEntries(Object.entries(options.metadata ?? {}).map(([key, value]) => [key, typeof value === "string" ? value.replace(control, "") : Array.isArray(value) ? value.map((item) => String(item).replace(control, "")) : value]));
|
|
1245
|
+
const language = metadata.language ?? first?.language;
|
|
1246
|
+
return converter.finish(metadata, language);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
|