@openpresentation/opf-render 0.16.0 → 0.17.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/README.md +12 -1
- package/dist/deck-runtime.js +5 -1
- package/dist/drawn-faces.js +38 -0
- package/dist/element.js +21 -8
- package/dist/font-fstype.js +70 -0
- package/dist/font-registry.js +6 -3
- package/dist/font-subset.js +173 -0
- package/dist/fonts-browser.d.ts +11 -0
- package/dist/fonts-browser.js +33 -2
- package/dist/fonts-node.js +25 -0
- package/dist/fonts.d.ts +10 -0
- package/dist/hb-shape.js +121 -0
- package/dist/index.d.ts +22 -0
- package/dist/pdf-text.js +13 -7
- package/dist/pdf-vector.js +3 -0
- package/dist/script-font-pack.js +23 -16
- package/dist/svg.d.ts +22 -0
- package/dist/svg.js +93 -44
- package/dist/text-paths.js +330 -0
- package/package.json +3 -3
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// RR-64: `textAsPaths: true` draws every <text> of a slide as glyph outlines, so the SVG needs no font. The renderer has
|
|
2
|
+
// already decided every line, position and width (x, y, text-anchor, textLength); the vector PDF export's SVG text layout
|
|
3
|
+
// (layoutText: text chunks, bidirectional runs, per-character fallback, fontkit shaping, textLength) turns that into positioned
|
|
4
|
+
// glyph runs, and this module writes each glyph as a <use> of its outline. Outlines are kept once per slide in <defs>, in font
|
|
5
|
+
// units, under an id made of the face's content hash and the glyph id, so SVGs inlined on one page never resolve to another
|
|
6
|
+
// face's glyph. Everything outside the <text> elements is left byte for byte as the renderer wrote it.
|
|
7
|
+
//
|
|
8
|
+
// A <text> becomes a <g> that keeps the element's own attributes except the text-only ones (so its transform, opacity and
|
|
9
|
+
// data-opf-* trace stay), labelled with its text for assistive technology unless it is aria-hidden; a tspan with a trace keeps
|
|
10
|
+
// it on a nested <g>; a link becomes an <a> around its runs; underline and line-through are rectangles in the text colour from
|
|
11
|
+
// the font's own metrics. Text drawn with a colour or bitmap font (COLR, CBDT, sbix, SVG tables) stays <text>, reported as
|
|
12
|
+
// `text-as-paths-kept-text`, and the SVG then embeds that face as usual.
|
|
13
|
+
|
|
14
|
+
import { FontLibrary, shape as fontkitShape } from "./pdf-fonts.js";
|
|
15
|
+
import { featuresOf, layoutText } from "./pdf-text.js";
|
|
16
|
+
import { ROOT_STYLE, attributesOf, inheritStyle, parseFontFamilies, parseLength } from "./pdf-style.js";
|
|
17
|
+
import { openTypeLanguage } from "./script-fonts.js";
|
|
18
|
+
import { decodeEntities, escapeAttribute, escapeText, parseXml } from "./pdf-xml.js";
|
|
19
|
+
|
|
20
|
+
const TOKENS = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
|
|
21
|
+
const ATTRIBUTE = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
22
|
+
// Attributes that only mean something on text: the outline group does not carry them.
|
|
23
|
+
const TEXT_ONLY = new Set(["x", "y", "dx", "dy", "rotate", "textLength", "lengthAdjust", "text-anchor", "text-rendering", "xml:space", "font-family", "font-size",
|
|
24
|
+
"font-weight", "font-style", "font-variant", "font-stretch", "font-feature-settings", "font-variant-ligatures", "letter-spacing", "word-spacing", "text-decoration",
|
|
25
|
+
"dominant-baseline", "alignment-baseline", "baseline-shift", "direction", "unicode-bidi", "writing-mode"]);
|
|
26
|
+
const COLOUR_TABLES = ["COLR", "CBDT", "sbix", "SVG "];
|
|
27
|
+
const libraries = new WeakMap();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The outline font library of a fonts handle: every face of its registry (`registry.exportFaces()`), or the faces of its
|
|
31
|
+
* `embeddedFonts` list. Built once per registry and extended when the registry gains faces (`ensure`, `addFaces`). Null when
|
|
32
|
+
* the handle holds no font bytes.
|
|
33
|
+
*/
|
|
34
|
+
export function outlineLibrary(fonts) {
|
|
35
|
+
const registry = fonts?.registry;
|
|
36
|
+
if (typeof registry?.exportFaces === "function") {
|
|
37
|
+
const exported = registry.exportFaces();
|
|
38
|
+
const described = typeof registry.describeFaces === "function" ? registry.describeFaces() : [];
|
|
39
|
+
let entry = libraries.get(registry);
|
|
40
|
+
if (!entry) { entry = { library: newLibrary(fonts), added: 0 }; libraries.set(registry, entry); }
|
|
41
|
+
// Registries only ever append faces.
|
|
42
|
+
for (let index = entry.added; index < exported.length; index++) addFace(entry.library, exported[index].data, exported[index].family, described[index]);
|
|
43
|
+
entry.added = exported.length;
|
|
44
|
+
return entry.library;
|
|
45
|
+
}
|
|
46
|
+
const list = fonts?.embeddedFonts;
|
|
47
|
+
if (!Array.isArray(list) || !list.length) return null;
|
|
48
|
+
let library = libraries.get(list);
|
|
49
|
+
if (!library) {
|
|
50
|
+
library = newLibrary(fonts);
|
|
51
|
+
for (const font of list) {
|
|
52
|
+
const match = /^data:font\/[a-z0-9]+;base64,([A-Za-z0-9+/=]+)$/.exec(String(font?.dataUrl ?? ""));
|
|
53
|
+
if (match) addFace(library, decodeBase64(match[1]), font.family, font);
|
|
54
|
+
}
|
|
55
|
+
libraries.set(list, library);
|
|
56
|
+
}
|
|
57
|
+
return library;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function newLibrary(fonts) {
|
|
61
|
+
const fallback = fonts?.registry?.fallbackFamily ?? "Roboto";
|
|
62
|
+
const library = new FontLibrary({ genericFamilies: () => [fallback] });
|
|
63
|
+
library.restricted = new Set();
|
|
64
|
+
return library;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The face is matched by the family, weight and style the registry gives it, the ones the SVG names. A CFF face can be outlined
|
|
68
|
+
// (only the PDF cannot subset it); a face whose fsType restricts embedding is never outlined: its text stays text.
|
|
69
|
+
function addFace(library, data, family, described) {
|
|
70
|
+
const face = library.addData(data, "fonts", family);
|
|
71
|
+
if (!face || face.outlineStyled) return;
|
|
72
|
+
if (Number.isInteger(described?.weight)) face.weight = described.weight;
|
|
73
|
+
if (typeof described?.italic === "boolean") face.italic = described.italic;
|
|
74
|
+
if (face.unusable === "pdf-font-unsupported-format" && face.font) face.embeddable = true;
|
|
75
|
+
if (face.unusable === "pdf-font-embedding-restricted") for (const name of face.names) library.restricted.add(name);
|
|
76
|
+
face.outlineStyled = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decodeBase64(text) {
|
|
80
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(text, "base64"));
|
|
81
|
+
const binary = atob(text), bytes = new Uint8Array(binary.length);
|
|
82
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
83
|
+
return bytes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Replace the <text> elements of the slide's content strings by glyph outlines. `library` comes from `outlineLibrary`,
|
|
88
|
+
* `rootStyle` holds what the <svg> element gives its children (lang), `report(diagnostic)` receives what cannot be outlined
|
|
89
|
+
* and `fail(code, message)` builds the error thrown when no face draws a text. Returns the new content and the <defs> markup.
|
|
90
|
+
*/
|
|
91
|
+
export function outlineSlideText(content, { library, shaper, rootStyle = {}, report = () => {}, fail }) {
|
|
92
|
+
const defs = new Map();
|
|
93
|
+
const reported = new Set();
|
|
94
|
+
const env = {
|
|
95
|
+
fonts: library,
|
|
96
|
+
defaults: [],
|
|
97
|
+
splitByOwner: true,
|
|
98
|
+
// HarfBuzz, the browser's shaper, when the handle carries one (RR-64 phase 2); fontkit otherwise.
|
|
99
|
+
...(shaper ? { shape: (face, text, options) => shaper.shape(face.hash, face.data, text, { ...options, language: options.lang }) } : {}),
|
|
100
|
+
// The renderer reports its own font substitutions and missing glyphs while it lays the slide out.
|
|
101
|
+
diagnostic: () => {},
|
|
102
|
+
missing: () => {},
|
|
103
|
+
unavailable: (families) => fail("text-as-paths-font-unavailable", `No font face can outline text in ${families.length ? `'${families.join("', '")}'` : "the requested family"}; pass the fonts handle the slide was laid out with.`),
|
|
104
|
+
};
|
|
105
|
+
const base = inheritStyle(ROOT_STYLE, rootStyle);
|
|
106
|
+
const REASONS = {
|
|
107
|
+
"colour-font": "is a colour or bitmap font, which has no plain outlines",
|
|
108
|
+
restricted: "forbids embedding (OS/2 fsType), so its outlines are not written",
|
|
109
|
+
shaping: "shapes differently in HarfBuzz and in the layout's measurement on text with no pinned width",
|
|
110
|
+
};
|
|
111
|
+
const keep = (family, reason) => {
|
|
112
|
+
if (reported.has(`${family}|${reason}`)) return;
|
|
113
|
+
reported.add(`${family}|${reason}`);
|
|
114
|
+
report({ code: "text-as-paths-fallback", reason, fontFamily: family, message: `'${family}' ${REASONS[reason]}; that text stays text and the SVG embeds its face.` });
|
|
115
|
+
};
|
|
116
|
+
const outlined = content.map((markup) => typeof markup === "string" && markup.includes("<text") ? outlineMarkup(markup, base, env, defs, keep) : markup);
|
|
117
|
+
const definitions = [...defs].map(([id, d]) => `<path id="${id}" d="${d}"/>`).join("");
|
|
118
|
+
return { content: outlined, defs: definitions ? `<defs>${definitions}</defs>` : "" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Splices each <text> element of `markup`; the style it inherits is folded from its ancestors' attributes.
|
|
122
|
+
function outlineMarkup(markup, base, env, defs, keep) {
|
|
123
|
+
const ancestors = [];
|
|
124
|
+
let out = "", from = 0, start = -1, depth = 0, parentStyle = null;
|
|
125
|
+
for (const match of markup.matchAll(TOKENS)) {
|
|
126
|
+
const [token, close, name, attributes = "", selfClose] = match;
|
|
127
|
+
if (name === undefined) continue;
|
|
128
|
+
if (start >= 0) {
|
|
129
|
+
if (name === "text") depth += close ? -1 : selfClose ? 0 : 1;
|
|
130
|
+
if (depth > 0) continue;
|
|
131
|
+
const end = match.index + token.length;
|
|
132
|
+
out += markup.slice(from, start) + outlineText(markup.slice(start, end), parentStyle, env, defs, keep);
|
|
133
|
+
from = end;
|
|
134
|
+
start = -1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (close) { ancestors.pop(); continue; }
|
|
138
|
+
if (name === "text" && selfClose) {
|
|
139
|
+
// An empty <text/> (an empty table cell) draws nothing: it becomes an empty group with the element's other attributes.
|
|
140
|
+
const end = match.index + token.length;
|
|
141
|
+
out += markup.slice(from, match.index) + outlineText(token, base, env, defs, keep);
|
|
142
|
+
from = end;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (name === "text") {
|
|
146
|
+
start = match.index;
|
|
147
|
+
depth = 1;
|
|
148
|
+
parentStyle = ancestors.reduce((style, attrs) => inheritStyle(style, attrs), base);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!selfClose) ancestors.push(attributesOf({ attrs: parseAttributes(attributes) }));
|
|
152
|
+
}
|
|
153
|
+
return from === 0 ? markup : out + markup.slice(from);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseAttributes(text) {
|
|
157
|
+
const attrs = {};
|
|
158
|
+
for (const attribute of text.matchAll(ATTRIBUTE)) attrs[attribute[1]] = decodeEntities(attribute[2] ?? attribute[3] ?? "");
|
|
159
|
+
return attrs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function outlineText(source, parentStyle, env, defs, keep) {
|
|
163
|
+
const node = parseXml(`<svg>${source}</svg>`)?.children.find((child) => child.name === "text");
|
|
164
|
+
if (!node) return source;
|
|
165
|
+
const { runs } = layoutText(node, env, parentStyle, null);
|
|
166
|
+
const attrs = Object.entries(node.attrs).filter(([key]) => !TEXT_ONLY.has(key));
|
|
167
|
+
const hidden = node.attrs["aria-hidden"] === "true";
|
|
168
|
+
const groups = [], outlined = [];
|
|
169
|
+
for (const run of runs) {
|
|
170
|
+
const reason = fallbackReason(run, env);
|
|
171
|
+
if (reason) keep([...run.face.names][0], reason);
|
|
172
|
+
else outlined.push(run);
|
|
173
|
+
const drawn = reason ? keptRun(run) : drawRun(run, defs);
|
|
174
|
+
if (!drawn) continue;
|
|
175
|
+
const owner = run.owner !== node && run.owner ? run.owner : null;
|
|
176
|
+
const last = groups.at(-1);
|
|
177
|
+
if (last && last.owner === owner) last.parts.push(drawn);
|
|
178
|
+
else groups.push({ owner, parts: [drawn] });
|
|
179
|
+
}
|
|
180
|
+
const body = groups.map(({ owner, parts }) => {
|
|
181
|
+
const trace = owner ? Object.entries(owner.attrs).filter(([key]) => key.startsWith("data-")) : [];
|
|
182
|
+
return trace.length ? element("g", trace, parts.join("")) : parts.join("");
|
|
183
|
+
}).join("");
|
|
184
|
+
// RR-64 phase 3: the glyphs are hidden from assistive technology and the words are real text again, invisible, one <text> per
|
|
185
|
+
// line in reading order, pinned to the drawn width: a screen reader reads text (FA-30), and selection, copy and find work.
|
|
186
|
+
return element("g", attrs, body + (hidden ? "" : readableText(outlined)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The invisible text layer of the outlined runs (runs that stayed text are text already): per line, the runs in logical order, at
|
|
190
|
+
// the line's left edge (its right edge for a right-to-left line), in the generic family (no face is embedded for it), sized like
|
|
191
|
+
// the first run and stretched to the runs' drawn width.
|
|
192
|
+
function readableText(runs) {
|
|
193
|
+
const lines = [];
|
|
194
|
+
for (const run of runs) { let line = lines.find((item) => item.chunk === run.chunk); if (!line) lines.push(line = { chunk: run.chunk, runs: [] }); line.runs.push(run); }
|
|
195
|
+
return lines.map(({ chunk, runs: parts }) => {
|
|
196
|
+
const words = [...parts].sort((a, b) => a.start - b.start).map((run) => run.logical).join("");
|
|
197
|
+
if (!/\S/.test(words)) return "";
|
|
198
|
+
const left = Math.min(...parts.map((run) => run.x)), width = parts.reduce((sum, run) => sum + run.width, 0), first = parts[0];
|
|
199
|
+
return element("text", [["x", fixed(chunk.rtl ? left + width : left)], ["y", fixed(first.y)], ["font-family", "sans-serif"], ["font-size", fixed(first.size)], ["fill", "none"],
|
|
200
|
+
["direction", chunk.rtl ? "rtl" : undefined], ["xml:space", "preserve"], ["textLength", fixed(width)], ["lengthAdjust", "spacingAndGlyphs"], ["lang", first.style.lang]], escapeText(words));
|
|
201
|
+
}).join("");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Why a run stays text, or null: a colour or bitmap font; a family whose fsType restricts embedding (the run was laid out with
|
|
205
|
+
// another face); or, with HarfBuzz shaping, a run with no pinned width whose HarfBuzz advance differs from the fontkit advance
|
|
206
|
+
// the layout measured by more than 0.1 px (inside a textLength the run is scaled to the measured width, as the browser does).
|
|
207
|
+
function fallbackReason(run, env) {
|
|
208
|
+
if (COLOUR_TABLES.some((table) => run.face.font.directory?.tables?.[table])) return "colour-font";
|
|
209
|
+
for (const family of parseFontFamilies(run.style["font-family"])) {
|
|
210
|
+
if (env.fonts.restricted?.has(family.toLowerCase())) return "restricted";
|
|
211
|
+
if (run.face.names.has(family.toLowerCase())) break;
|
|
212
|
+
}
|
|
213
|
+
if (env.shape && !run.scope) {
|
|
214
|
+
const direction = run.level % 2 === 1 ? "rtl" : "ltr", language = openTypeLanguage(run.style.lang ?? run.style["xml:lang"]);
|
|
215
|
+
const measured = fontkitShape(run.face, run.text, { features: featuresOf(run.style), language, direction }).reduce((sum, glyph) => sum + glyph.advance, 0);
|
|
216
|
+
const shaped = run.glyphs.reduce((sum, glyph) => sum + glyph.advance, 0);
|
|
217
|
+
if (Math.abs(measured - shaped) * run.scale > 0.1) return "shaping";
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A run that stays text, where layoutText placed it: its own family, size and paint, its width pinned with textLength.
|
|
223
|
+
function keptRun(run) {
|
|
224
|
+
const style = run.style, rtl = run.level % 2 === 1;
|
|
225
|
+
const attrs = [["x", fixed(rtl ? run.x + run.width : run.x)], ["y", fixed(run.y)], ["font-family", style["font-family"]], ["font-size", fixed(run.size)],
|
|
226
|
+
["font-weight", style["font-weight"]], ["font-style", style["font-style"] === "normal" ? undefined : style["font-style"]], ["fill", style.fill],
|
|
227
|
+
["fill-opacity", style["fill-opacity"] === "1" ? undefined : style["fill-opacity"]], ["direction", rtl ? "rtl" : undefined], ["xml:space", "preserve"],
|
|
228
|
+
["textLength", fixed(run.width)], ["lengthAdjust", "spacingAndGlyphs"], ["lang", style.lang]];
|
|
229
|
+
const text = `<text${attrs.filter(([, value]) => value !== undefined && value !== null && value !== "").map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join("")}>${escapeAttribute(run.logical)}</text>`;
|
|
230
|
+
return run.link ? element("a", Object.entries(run.link.node?.attrs ?? { href: run.link.href }), text) : text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function drawRun(run, defs) {
|
|
234
|
+
const style = run.style, size = run.size, face = run.face, upem = face.upem;
|
|
235
|
+
const scale = size / upem, extra = run.extraSpacing ?? 0, scaleX = run.scaleX ?? 1;
|
|
236
|
+
// The run is drawn in font units (y up) under one transform; each glyph is a <use> at its pen position in font units.
|
|
237
|
+
const uses = [];
|
|
238
|
+
let pen = 0;
|
|
239
|
+
for (const glyph of run.glyphs) {
|
|
240
|
+
const id = glyphId(face, glyph.gid, defs);
|
|
241
|
+
if (id) {
|
|
242
|
+
const x = units(pen / scale + glyph.xOffset), y = units(glyph.yOffset);
|
|
243
|
+
uses.push(`<use href="#${id}"${x === "0" ? "" : ` x="${x}"`}${y === "0" ? "" : ` y="${y}"`}/>`);
|
|
244
|
+
}
|
|
245
|
+
pen += glyph.advance * scale + glyph.spacing + extra;
|
|
246
|
+
}
|
|
247
|
+
const decorations = [...run.decoration].map((kind) => decorationRect(kind, run, scale));
|
|
248
|
+
if (!uses.length && !decorations.length) return "";
|
|
249
|
+
const paint = [["fill", style.fill]];
|
|
250
|
+
if (style["fill-opacity"] !== undefined && style["fill-opacity"] !== "1") paint.push(["fill-opacity", style["fill-opacity"]]);
|
|
251
|
+
if (style.stroke && style.stroke !== "none") {
|
|
252
|
+
// Glyphs are drawn at the em scale, so the stroke width is given in font units.
|
|
253
|
+
const width = parseLength(style["stroke-width"], { fontSize: size }) ?? 1;
|
|
254
|
+
paint.push(["stroke", style.stroke], ["stroke-width", precise(width / scale)]);
|
|
255
|
+
if (style["stroke-opacity"] !== undefined && style["stroke-opacity"] !== "1") paint.push(["stroke-opacity", style["stroke-opacity"]]);
|
|
256
|
+
}
|
|
257
|
+
if (style.visibility && style.visibility !== "visible") paint.push(["visibility", style.visibility]);
|
|
258
|
+
const glyphs = uses.length ? `<g transform="matrix(${precise(scale * scaleX)} 0 0 ${precise(-scale)} ${fixed(run.x)} ${fixed(run.y)})">${uses.join("")}</g>` : "";
|
|
259
|
+
const drawn = element("g", [...paint, ["aria-hidden", "true"]], glyphs + decorations.join(""));
|
|
260
|
+
// A link inside the text keeps its element's attributes (href, rel, target) and is named by its words.
|
|
261
|
+
return run.link ? element("a", [...Object.entries(run.link.node?.attrs ?? { href: run.link.href }), ["aria-label", run.logical]], drawn) : drawn;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Underline, line-through and overline in the text colour, from the font's own metrics (the vector PDF draws the same).
|
|
265
|
+
function decorationRect(kind, run, scale) {
|
|
266
|
+
const font = run.face.font, upem = run.face.upem;
|
|
267
|
+
let position, thickness;
|
|
268
|
+
if (kind === "underline") { position = -(font.underlinePosition || -upem * 0.1) * scale; thickness = (font.underlineThickness || upem * 0.05) * scale; }
|
|
269
|
+
else if (kind === "line-through") { position = -(font["OS/2"]?.yStrikeoutPosition || upem * 0.3) * scale; thickness = (font["OS/2"]?.yStrikeoutSize || upem * 0.05) * scale; }
|
|
270
|
+
else { position = -font.ascent * scale; thickness = (font.underlineThickness || upem * 0.05) * scale; }
|
|
271
|
+
return `<rect x="${fixed(run.x)}" y="${fixed(run.y + position - thickness / 2)}" width="${fixed(run.width)}" height="${fixed(thickness)}" stroke="none"/>`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The outline of a glyph, once per slide, in font units (y up); null for a glyph that draws nothing (a space).
|
|
275
|
+
function glyphId(face, gid, defs) {
|
|
276
|
+
const id = `opf-g-${face.hash.slice(0, 12)}-${gid}`;
|
|
277
|
+
if (defs.has(id)) return id;
|
|
278
|
+
const d = outlinePath(face.font.getGlyph(gid).path.commands);
|
|
279
|
+
if (!d) return null;
|
|
280
|
+
defs.set(id, d);
|
|
281
|
+
return id;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Relative commands, each delta taken from the previous rounded point, so rounding to a tenth of a unit never drifts.
|
|
285
|
+
function outlinePath(commands) {
|
|
286
|
+
const parts = [];
|
|
287
|
+
let x = 0, y = 0, startX = 0, startY = 0, draws = false;
|
|
288
|
+
for (const { command, args } of commands) {
|
|
289
|
+
const letter = { moveTo: "m", lineTo: "l", quadraticCurveTo: "q", bezierCurveTo: "c", closePath: "z" }[command];
|
|
290
|
+
if (!letter) continue;
|
|
291
|
+
if (letter === "z") { parts.push("z"); x = startX; y = startY; continue; }
|
|
292
|
+
const points = [];
|
|
293
|
+
for (let index = 0; index < args.length; index += 2) points.push([round(args[index]), round(args[index + 1])]);
|
|
294
|
+
parts.push(letter + numbers(points.flatMap(([px, py]) => [px - x, py - y])));
|
|
295
|
+
[x, y] = points.at(-1);
|
|
296
|
+
if (letter === "m") { startX = x; startY = y; } else draws = true;
|
|
297
|
+
}
|
|
298
|
+
return draws ? parts.join("") : "";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function round(value) { return Math.round(value * 10) / 10; }
|
|
302
|
+
// Numbers joined the short SVG way: no separator before a minus sign.
|
|
303
|
+
function numbers(values) {
|
|
304
|
+
return values.map((value, index) => { const text = units(Math.round(value * 10) / 10); return index && !text.startsWith("-") ? " " + text : text; }).join("");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function element(name, attrs, body) {
|
|
308
|
+
const text = attrs.filter(([, value]) => value !== undefined && value !== null).map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join("");
|
|
309
|
+
return body ? `<${name}${text}>${body}</${name}>` : `<${name}${text}/>`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Font units to a tenth; positions to a thousandth of a pixel; the em scale to six significant digits.
|
|
313
|
+
function units(value) { const text = String(Math.round(value * 10) / 10); return text === "-0" ? "0" : text; }
|
|
314
|
+
function fixed(value) { const text = Number(value).toFixed(3).replace(/\.?0+$/, ""); return text === "-0" ? "0" : text; }
|
|
315
|
+
function precise(value) { return String(Number(Number(value).toPrecision(6))); }
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The outline engine a fonts handle carries as `outlines` (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws each
|
|
320
|
+
* slide's text with it. The faces are read from the handle when a slide is outlined, so faces it loads later are used too.
|
|
321
|
+
*/
|
|
322
|
+
export function textOutlines(fonts) {
|
|
323
|
+
return Object.freeze({
|
|
324
|
+
outlineSlideText: (content, options) => {
|
|
325
|
+
const library = outlineLibrary(fonts);
|
|
326
|
+
if (!library) throw options.fail("text-as-paths-needs-fonts", "textAsPaths needs font faces to outline: pass the handle loadFonts() returns as `fonts`.");
|
|
327
|
+
return outlineSlideText(content, { ...options, library, shaper: fonts.shaper });
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -139,9 +139,10 @@
|
|
|
139
139
|
"test:preview-a11y-browser": "node test/preview-a11y-browser.mjs"
|
|
140
140
|
},
|
|
141
141
|
"dependencies": {
|
|
142
|
-
"@openpresentation/opf": "^0.
|
|
142
|
+
"@openpresentation/opf": "^0.17.0",
|
|
143
143
|
"bidi-js": "1.1.0",
|
|
144
144
|
"fontkit": "^2.0.4",
|
|
145
|
+
"harfbuzzjs": "1.6.2",
|
|
145
146
|
"pako": "1.0.11"
|
|
146
147
|
},
|
|
147
148
|
"peerDependencies": {
|
|
@@ -387,7 +388,6 @@
|
|
|
387
388
|
"@resvg/resvg-js": "2.6.2",
|
|
388
389
|
"axe-core": "4.13.0",
|
|
389
390
|
"esbuild": "0.28.2",
|
|
390
|
-
"harfbuzzjs": "1.6.2",
|
|
391
391
|
"pdf-lib": "1.17.1",
|
|
392
392
|
"pdfjs-dist": "6.3.289",
|
|
393
393
|
"playwright": "1.63.0",
|