@openpresentation/opf-render 0.16.0 → 0.17.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 +4 -0
- package/dist/deck-runtime.js +5 -1
- package/dist/font-registry.js +6 -3
- package/dist/fonts-browser.js +3 -0
- package/dist/fonts-node.js +3 -0
- package/dist/fonts.d.ts +6 -0
- package/dist/index.d.ts +10 -0
- package/dist/pdf-text.js +8 -5
- package/dist/script-font-pack.js +23 -16
- package/dist/svg.d.ts +10 -0
- package/dist/svg.js +63 -15
- package/dist/text-paths.js +278 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -156,6 +156,8 @@ Installed size, measured on Windows with `npm install --ignore-scripts`, unpacke
|
|
|
156
156
|
|
|
157
157
|
Accessibility of the SVG (FA-30): the slide root is a labelled container, `role="group"` with `aria-roledescription="slide"` and `aria-label` set to the slide title, never `role="img"`, which would make every text node inside presentational. Titles, body text, list entries, quotes, metrics, table cells, captions, footnotes and header or footer text stay live text a screen reader reaches, in the order the renderer paints them. Purely decorative drawing (the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, and an image whose `alt` is `""`) carries `aria-hidden="true"`. A picture with `alt` is `role="img"` with that label (a header or footer picture without `alt` is hidden); a chart with `alt` is a `role="img"` group, and one with `alt: ""` is hidden. `<opf-deck>`, the player and `renderDeckHtml` remove the root's `role`, `aria-roledescription` and `aria-label` (the slide section or figure around the drawing carries the one name), so a slide is announced once.
|
|
158
158
|
|
|
159
|
+
Text as outlines (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws every `<text>` as glyph outlines, so the SVG needs no font and looks the same in every browser, image viewer and design tool. Each glyph is a `<use>` of an outline kept once per slide in `<defs>` (in font units, under an id made of the face's content hash and the glyph id, so SVGs inlined on one page never mix up glyphs); positions, widths and shaping are the vector PDF's (the same layout of the renderer's pinned lines), so over the core example decks resvg draws an outlined slide like its text (mean channel difference at most 0.006 of 255) and an outlined slide is about 38 KB at the median (58 KB at p90, 98 KB at most) against 0.5 to 4 MB with embedded faces. It needs the fonts handle `loadFonts()` returns, which carries the outline engine as `fonts.outlines` (`text-as-paths-needs-fonts` otherwise); the handle's faces are parsed for outlining once, on the first outlined slide. Each outlined element is a group that keeps the element's attributes and `data-opf-*` trace (a tspan's trace stays on a nested group), labelled `role="img"` with its text for assistive technology unless it is `aria-hidden`; links stay `<a>`; underline and line-through are rectangles from the font's own metrics. Text in a colour or bitmap font (emoji) stays text, reported as `text-as-paths-kept-text`, and the SVG embeds just that text's faces. The trade-offs, which make it the choice for thumbnails, previews and portable SVG files and not for an editing surface: the text is no longer text (it cannot be selected, copied, searched or translated, and assistive technology reads one label per text element instead of the text itself); a caret, selection and input method need live text; a vector PDF made from an outlined SVG has paths instead of selectable text, so `svgToPdf` should get the `<text>` SVG; browsers draw outlines without the hinting they give text, so small text looks a little lighter than browser text (resvg already draws text as outlines); and in `/fonts-browser` the outline engine adds about 85 KB (minified) to the bundle. Plan and later phases: [opf-render#164](https://github.com/OpenPresentation/opf-render/issues/164).
|
|
160
|
+
|
|
159
161
|
The preview draws three accepted spec fields from tables shared with opf-pptx through core (`@openpresentation/opf`): `code.language` colours code (comments, strings, numbers, keywords, names and types, in theme-derived colours kept at 4.5:1 or more on the code panel; an unknown language stays plain and the text is never changed), `metric.trend` draws an arrow beside the trend word (green up, red down, neutral flat, with "Trend: up" as its text alternative) and colours the delta and trend text, and every one of the 54 DrawingML preset pattern backgrounds draws from its 8 x 8 bitmap (one pixel per 1/96 inch from the slide's top-left). With a core that lacks these exports the preview draws plain, without a new diagnostic. `scripts/derive-pattern-bitmaps.mjs` compares a PowerPoint export of the pattern slides with core's tiles (ECMA-376 names the presets without defining their pixels; core's tiles were measured with it from desktop PowerPoint, Office 365, Windows, 2026-10-01).
|
|
160
162
|
|
|
161
163
|
```js
|
|
@@ -489,6 +491,8 @@ renderSvg(template, { variables: { client: 'Globex', revenue: 1250000 } });
|
|
|
489
491
|
|
|
490
492
|
A template previews with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic` (a built-in variable such as `{{speaker.name}}` whose document field is missing reports `variable-builtin-missing`; built-ins read the document's own `speaker`, `organization` and `name`, and a template preview keeps an absent one visible); a variable with no example keeps its `{{id}}` text. A normal deck with an unfilled required variable throws `OPFRenderError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. `resolvePresentation(...).presentation` is the concrete deck. `variables: false` draws the document as authored, with tokens and `var:` references visible (the editor canvas's view of a template, so inline edits never overwrite a token). Decks without content variables are untouched. See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
|
|
491
493
|
|
|
494
|
+
Slide-scoped built-ins (FA-31): `{{slide.number}}`, `{{slide.section}}` and `{{deck.slideCount}}` work in any string of a slide (titles, body text, table cells, notes) and in header and footer `text`. The deck-wide pass leaves them as written; the renderer draws each slide from core's substituted slide (`resolveSlideContext(...).slide`), numbered by its position in the deck you render (run core `paginate` first and the numbers and the count are the paginated ones), so the body and the footer show the same number. A zone's `text` is one furniture part (`data-opf-furniture-field="text"`); the former `organization`, `speaker`, `section` and `slideNumber` flags are gone, so write `{{organization.name}}` or `{{slide.number}}` in the `text`.
|
|
495
|
+
|
|
492
496
|
### Symbol-encoded families: Symbol, Wingdings, Webdings (FF-45)
|
|
493
497
|
|
|
494
498
|
Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings keep their glyphs at the 224 codes 0x20..0xFF of a Microsoft Symbol cmap, not at Unicode code points, and none of them may be bundled. A run or design font in one of them no longer fails with `font-encoding-required`: each character is normalised to its code (the private-use character U+F0xx that Office writes for Insert > Symbol and `a:sym` runs, or the Windows-1252 character of the code, so "l" and U+F06C are the same Wingdings bullet) and mapped through core's reversible, version-specific tables (`@openpresentation/opf` `spec/reference/symbol-font-encodings.json`, read through `@openpresentation/opf/symbol-font-encodings`, not copied here; `mapSymbolText('Wingdings', 'l')` gives U+26AB) to the Unicode equivalent, which the first loaded face of the family's chain draws: `SYMBOL_PREVIEW_FACES` is Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math, Noto Sans for the dingbat fonts and Noto Sans (Greek letters, digits, punctuation) first for Symbol. The three symbol packages are optional peers of the script pack under the key `Zsym` (`scripts: ['Zsym']`; `scripts: 'auto'` selects it when a run, `design.fonts` or a font scheme names one of the families; `fonts.ensure(presentation)`): `@expo-google-fonts/noto-sans-symbols-2`, `noto-sans-symbols` and `noto-sans-math`, OFL-1.1, together about 1.2 MiB. Every code the tables map (1059 of 1120: Symbol 189, Wingdings 222, Wingdings 2 217, Wingdings 3 208, Webdings 223) draws a real glyph when the pack is loaded; the 61 codes without a Unicode equivalent (the Wingdings 0xFF Windows logo, unassigned codes) draw the placeholder U+25A1. Each glyph is one positioned tspan at the advance of the verified Windows font (Wingdings 5.01, Wingdings 2 and 3 1.55, Webdings 5.01, Symbol 5.01), in measurement and drawing alike, so line breaks and bullet gaps follow PowerPoint; the open glyph keeps its shape and is compressed to its code's advance only when wider, never stretched. Characters that are not codes (a CJK letter in a Wingdings run) draw as themselves. Without the pack, Symbol's Greek letters, digits and punctuation draw with the office pack's Noto Sans, the other codes draw U+FFFD, and the `font-glyph-fallback` diagnostic (`codes`, `placeholder`) names the pack; a registry with no chain face and no `fallbackFamily` throws `font-encoding-required`, naming it too. `resolveFont` and `resolveStyle` report `symbolEncoding` with the substitute face, so exporters keep writing the chosen family and the original characters, and the PPTX names Wingdings with its codes. The symbol faces also end the glyph fallback chain. Appearance is the open face's, not Microsoft's; native PowerPoint verification of the exported runs is separate (FF-46).
|
package/dist/deck-runtime.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSlideSvg:
|
|
3
3
|
// there is no second layout engine) behind a face-level font gate, and synchronizes windows over a BroadcastChannel.
|
|
4
4
|
// Nothing here fetches anything but the font files the host's font root serves.
|
|
5
|
+
import { resolveSlideVariables } from "@openpresentation/opf";
|
|
5
6
|
import { renderSlideSvg } from "./svg.js";
|
|
6
7
|
|
|
7
8
|
/** Error raised for a deck that cannot be read, rendered or loaded. */
|
|
@@ -49,7 +50,10 @@ export function presentableIndexes(document, { includeHidden = false } = {}) {
|
|
|
49
50
|
|
|
50
51
|
/** Facts about one slide for a counter, a label, a thumbnail or the speaker view. `notes` is plain text only. */
|
|
51
52
|
export function slideInfo(document, index) {
|
|
52
|
-
const
|
|
53
|
+
const authored = isObject(document?.slides?.[index]) ? document.slides[index] : {};
|
|
54
|
+
// FA-31: the strings a counter, label or speaker view shows hold this slide's number, section and the deck's slide count, as the drawn slide does.
|
|
55
|
+
const { title: shownTitle, subtitle, tag, notes, section } = resolveSlideVariables({ title: authored.title, subtitle: authored.subtitle, tag: authored.tag, notes: authored.notes, section: authored.section }, { slideNumber: Math.max(1, index + 1), slideCount: Math.max(1, document?.slides?.length ?? 1) });
|
|
56
|
+
const slide = { ...authored, title: shownTitle, subtitle, tag, notes, section };
|
|
53
57
|
const title = plainText(slide.title).trim() || plainText(slide.subtitle).trim() || plainText(slide.tag).trim();
|
|
54
58
|
return {
|
|
55
59
|
index,
|
package/dist/font-registry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { create } from "fontkit";
|
|
2
2
|
import { FONT_COMPATIBILITY, disabledFeaturesFor } from "./font-compatibility.js";
|
|
3
|
-
import { adjustedFontSize, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
|
|
3
|
+
import { adjustedFontSize, createScriptFonts, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
|
|
4
4
|
import { fontPolicyFor } from "@openpresentation/opf/font-policy";
|
|
5
5
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
6
6
|
import { SYMBOL_SCRIPT, isSymbolEncodedFamily, symbolEncodingFamily, symbolPreviewFaces } from "./symbol-fonts.js";
|
|
@@ -289,9 +289,12 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
289
289
|
const {value,scale}=metrics(text,size,style,true),bounds=value.outline,drawn=adjustedFontSize(size,scale===1?undefined:scale);
|
|
290
290
|
return bounds===null?null:{x:bounds.x*drawn,y:bounds.y*drawn,width:bounds.width*drawn,height:bounds.height*drawn};
|
|
291
291
|
};
|
|
292
|
+
// resolveFont lets exporters tell a substitute from the chosen family (FF-31). forScripts (opf#485) is the script planner core
|
|
293
|
+
// validate asks for each slide's measurement, so its layout checks measure script runs in the faces this renderer draws.
|
|
294
|
+
const textMeasurement={measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution};
|
|
295
|
+
textMeasurement.forScripts=profile=>createScriptFonts(profile,textMeasurement).textMeasurement;
|
|
292
296
|
return {
|
|
293
|
-
|
|
294
|
-
textMeasurement: {measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution},
|
|
297
|
+
textMeasurement,
|
|
295
298
|
resolveFont(style) { return resolve(style).resolution; },
|
|
296
299
|
clearSubstitutions() { substitutions.clear(); },
|
|
297
300
|
get substitutions() { return [...substitutions.values()]; },
|
package/dist/fonts-browser.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createFontRegistry, OPFFontError } from "./font-registry.js";
|
|
|
2
2
|
import { lazyFontEntries, lazyFontList, normalizeExtraLazyFonts } from "./lazy-font-list.js";
|
|
3
3
|
import { lazyFacesNeeded } from "./lazy-fonts.js";
|
|
4
4
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
5
|
+
import { textOutlines } from "./text-paths.js";
|
|
5
6
|
import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
|
|
6
7
|
export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
|
|
7
8
|
export { lazyFontEntries, lazyFontList, splitStartupFaces } from "./lazy-font-list.js";
|
|
@@ -293,6 +294,8 @@ export async function loadFonts({ faces = [], ...options } = {}) {
|
|
|
293
294
|
textMeasurement: registry.textMeasurement,
|
|
294
295
|
get embeddedFonts() { if (stale) { embedded = registry.embeddedFonts; stale = false; } return embedded; },
|
|
295
296
|
registry,
|
|
297
|
+
// RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
|
|
298
|
+
outlines: textOutlines({ registry }),
|
|
296
299
|
manifest: BUNDLED_FONT_MANIFEST,
|
|
297
300
|
get substitutions() { return registry.substitutions; },
|
|
298
301
|
/**
|
package/dist/fonts-node.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { createFontRegistry, OPFFontError } from "./font-registry.js";
|
|
7
7
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
8
8
|
import { lazyFontList } from "./lazy-font-list.js";
|
|
9
|
+
import { textOutlines } from "./text-paths.js";
|
|
9
10
|
export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
10
11
|
import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
|
|
11
12
|
export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
|
|
@@ -272,6 +273,8 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
|
|
|
272
273
|
useBundledFonts: false,
|
|
273
274
|
loadSystemFonts: false,
|
|
274
275
|
registry,
|
|
276
|
+
// RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
|
|
277
|
+
outlines: textOutlines({ registry }),
|
|
275
278
|
manifest: BUNDLED_FONT_MANIFEST,
|
|
276
279
|
get substitutions() { return registry.substitutions; },
|
|
277
280
|
/** Load the script faces the presentation's text needs (see `ensureFonts`); `embeddedFonts` and `fontFiles` then include them. */
|
package/dist/fonts.d.ts
CHANGED
|
@@ -49,7 +49,11 @@ export interface RenderFonts {
|
|
|
49
49
|
useBundledFonts?: boolean;
|
|
50
50
|
/** Whether the raster conversion loads system fonts. Default false (the output never depends on the machine). */
|
|
51
51
|
loadSystemFonts?: boolean;
|
|
52
|
+
/** The outline engine `textAsPaths: true` draws text with (RR-64); a `loadFonts` handle carries one over its registry's faces. */
|
|
53
|
+
outlines?: TextOutlines;
|
|
52
54
|
}
|
|
55
|
+
/** Draws a slide's text as glyph outlines (RR-64). Read by `renderSvg`; hosts pass the handle and `textAsPaths: true`. */
|
|
56
|
+
export interface TextOutlines { outlineSlideText(content: string[], options: object): { content: string[]; defs: string } }
|
|
53
57
|
/** What `fonts.ensure(presentation)` loaded: the script packages and vendored faces that were missing, and drawn CJK characters no face covers. */
|
|
54
58
|
export interface EnsureResult { scripts: string[]; lazy: LazyFont[]; uncovered: string[] }
|
|
55
59
|
/** The fonts handle `loadFonts()` returns from `/fonts-node` and `/fonts-browser`: pass it as `{ fonts }` to every deck-level function. */
|
|
@@ -58,6 +62,8 @@ export interface FontsHandle extends RenderFonts {
|
|
|
58
62
|
readonly embeddedFonts: EmbeddedFont[];
|
|
59
63
|
/** The face registry behind the handle (shaping, resolution, lazy and script loading). */
|
|
60
64
|
readonly registry: FontRegistry;
|
|
65
|
+
/** The outline engine for `textAsPaths: true` (RR-64), over this registry's faces, including faces `ensure` loads later. */
|
|
66
|
+
readonly outlines: TextOutlines;
|
|
61
67
|
/** The pinned manifest of the bundled font packages. */
|
|
62
68
|
readonly manifest: { readonly version: number; readonly packages: readonly object[] };
|
|
63
69
|
/** The substitutions made so far (a requested family drawn with another face), as `registry.substitutions`. */
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,16 @@ export interface RenderSvgOptions {
|
|
|
60
60
|
fonts?: RenderFonts;
|
|
61
61
|
/** `false` writes no `@font-face` data into the SVG (RR-61): the fonts handle still measures and the SVG still names the families, for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document). Default true: each SVG embeds the faces of `fonts.embeddedFonts` its own text draws. */
|
|
62
62
|
embedFonts?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
|
|
65
|
+
* no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
|
|
66
|
+
* faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
|
|
67
|
+
* cannot be selected, searched or edited in place, and each outlined element is a `role="img"` group labelled with its text for
|
|
68
|
+
* assistive technology. Each element keeps its `data-opf-*` trace. Text in a colour or bitmap font stays text
|
|
69
|
+
* (`text-as-paths-kept-text`). Use it for thumbnails, previews and portable SVG files, not for an editing surface or the
|
|
70
|
+
* vector PDF (which keeps real text from `<text>` SVG). Default false.
|
|
71
|
+
*/
|
|
72
|
+
textAsPaths?: boolean;
|
|
63
73
|
/** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
|
|
64
74
|
glyphFallback?: "chain" | "none";
|
|
65
75
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
package/dist/pdf-text.js
CHANGED
|
@@ -59,7 +59,7 @@ export function layoutText(textNode, env, parentStyle, link) {
|
|
|
59
59
|
const preserve = own["xml:space"] === "preserve" || /^pre/.test(own["white-space"] ?? "");
|
|
60
60
|
for (const child of node.children) {
|
|
61
61
|
if (child.text !== undefined) {
|
|
62
|
-
for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve });
|
|
62
|
+
for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve, owner: node });
|
|
63
63
|
} else if (isElement(child)) {
|
|
64
64
|
if (child.name === "tspan" || child.name === "textPath" || child.name === "tref") walk(child, own, nextScope, nextDecoration, activeLink);
|
|
65
65
|
else if (child.name === "a") {
|
|
@@ -89,7 +89,8 @@ export function layoutText(textNode, env, parentStyle, link) {
|
|
|
89
89
|
const styleIds = new Map();
|
|
90
90
|
const faceCache = new Map();
|
|
91
91
|
const linkIds = new Map();
|
|
92
|
-
const
|
|
92
|
+
const ownerIds = new Map();
|
|
93
|
+
const runsByChunk = chunks.map((chunk) => buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds));
|
|
93
94
|
|
|
94
95
|
// textLength: the renderer gives lines and tabs an exact width; reproduce it per scope over all its runs.
|
|
95
96
|
const scopeWidth = new Map();
|
|
@@ -155,7 +156,7 @@ function collapseWhitespace(chars) {
|
|
|
155
156
|
for (const char of kept) chars.push(char);
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
|
|
159
|
+
function buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds) {
|
|
159
160
|
const text = chunk.chars.map((char) => char.ch).join("");
|
|
160
161
|
const baseDirection = chunk.chars[0].style.direction === "rtl" ? "rtl" : "ltr";
|
|
161
162
|
// Levels per UTF-16 unit; characters take the level of their first unit.
|
|
@@ -174,9 +175,11 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
|
|
|
174
175
|
if (IGNORED.test(char.ch)) return;
|
|
175
176
|
const level = levels[index];
|
|
176
177
|
const resolved = pickFace(char, env, styleIds, faceCache);
|
|
177
|
-
|
|
178
|
+
// RR-64: outlined text keeps each element's trace, so with `env.splitByOwner` a run never spans two elements.
|
|
179
|
+
const owner = env.splitByOwner ? linkId(ownerIds, { node: char.owner }) : 0;
|
|
180
|
+
const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}|${owner}`;
|
|
178
181
|
if (!current || current.key !== key) {
|
|
179
|
-
current = { chunk: chunkInfo, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
|
|
182
|
+
current = { chunk: chunkInfo, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, owner: char.owner, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
|
|
180
183
|
runs.push(current);
|
|
181
184
|
}
|
|
182
185
|
current.logical += char.ch;
|
package/dist/script-font-pack.js
CHANGED
|
@@ -54,28 +54,23 @@ export function scriptPackageEntries(packages, {baseUrl}) {
|
|
|
54
54
|
// Everywhere: the schema URL, embedded assets, catalog records, speaker notes, ids, image alt text and sources,
|
|
55
55
|
// links, metadata and typed `extensions` passthrough. On the presentation: name (aria-label fallback), description,
|
|
56
56
|
// filename, author, audience, purpose, tone, takeaway, duration, tags, narrative, language and version.
|
|
57
|
-
// On a slide: beat. The organization (name), the speaker (name and title) and a slide's section are drawn only
|
|
58
|
-
//
|
|
59
|
-
// only when a
|
|
60
|
-
// categories and series names, code, quotes, metrics, timelines,
|
|
57
|
+
// On a slide: beat. The organization (name), the speaker (name and title) and a slide's section are drawn only through the
|
|
58
|
+
// built-in variables that read them ({{organization.name}}, {{speaker.title}}, {{slide.section}}, FA-31), so they count
|
|
59
|
+
// only when a string the preview draws holds such a token. Everything else is drawn text: titles, text blocks, list items,
|
|
60
|
+
// runs, table cells, chart labels, categories and series names, code, quotes, metrics, timelines, header and footer text.
|
|
61
61
|
const UNDRAWN_KEYS = Object.freeze(["$schema", "assets", "catalogs", "notes", "id", "alt", "altText", "src", "url", "href", "link", "metadata", "extensions"]);
|
|
62
62
|
const UNDRAWN_ROOT_KEYS = Object.freeze(["name", "description", "filename", "author", "authors", "creator", "audience", "purpose", "tone", "takeaway", "duration", "tags", "narrative", "language", "license", "version", "keywords"]);
|
|
63
63
|
const UNDRAWN_SLIDE_KEYS = Object.freeze(["beat"]);
|
|
64
64
|
const UNDRAWN_VALUE = /^(?:https?|data|blob|pkg|file|mailto):\S*$/i;
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
66
|
+
// The built-in variables that copy a document value into drawn text: the inline token and the whole-field reference
|
|
67
|
+
// (a logo or photo reference draws a picture, not text).
|
|
68
|
+
const ORGANIZATION_TOKEN = /\{\{\s*organization\.|var:organization\.(?!logo\b)/;
|
|
69
|
+
const SPEAKER_TOKEN = /\{\{\s*speaker\.|var:speaker\.(?!photo\b|image\b|avatar\b)/;
|
|
70
|
+
const SECTION_TOKEN = /\{\{\s*slide\.section\s*[|}]/;
|
|
72
71
|
|
|
73
|
-
/** The strings
|
|
74
|
-
|
|
75
|
-
const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set(UNDRAWN_ROOT_KEYS), undrawnSlide = new Set(UNDRAWN_SLIDE_KEYS);
|
|
76
|
-
if (!furnitureUses(presentation, "organization")) undrawnRoot.add("organization");
|
|
77
|
-
if (!furnitureUses(presentation, "speaker")) undrawnRoot.add("speaker");
|
|
78
|
-
if (!furnitureUses(presentation, "section")) undrawnSlide.add("section");
|
|
72
|
+
/** The strings under `presentation`, skipping the keys in the three sets (root keys, slide keys, and everywhere). */
|
|
73
|
+
function* walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide) {
|
|
79
74
|
function* visit(item, level) {
|
|
80
75
|
if (typeof item === "string") { if (!UNDRAWN_VALUE.test(item)) yield item; }
|
|
81
76
|
else if (Array.isArray(item)) for (const child of item) yield* visit(child, level === "slides" ? "slide" : level === "root" ? "deep" : level);
|
|
@@ -89,6 +84,18 @@ export function* drawnStrings(presentation) {
|
|
|
89
84
|
yield* visit(presentation, "root");
|
|
90
85
|
}
|
|
91
86
|
|
|
87
|
+
/** The strings a preview of this presentation draws. */
|
|
88
|
+
export function* drawnStrings(presentation) {
|
|
89
|
+
const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set([...UNDRAWN_ROOT_KEYS, "organization", "speaker"]), undrawnSlide = new Set([...UNDRAWN_SLIDE_KEYS, "section"]);
|
|
90
|
+
// The organization, the speaker and a slide's section draw only where a drawn string reads them with a built-in variable.
|
|
91
|
+
for (const text of walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide)) {
|
|
92
|
+
if (ORGANIZATION_TOKEN.test(text)) undrawnRoot.delete("organization");
|
|
93
|
+
if (SPEAKER_TOKEN.test(text)) undrawnRoot.delete("speaker");
|
|
94
|
+
if (SECTION_TOKEN.test(text)) undrawnSlide.delete("section");
|
|
95
|
+
}
|
|
96
|
+
yield* walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide);
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
/**
|
|
93
100
|
* The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
|
|
94
101
|
* with. When it throws, the renderer ignores the document language, so the profile is empty and Han text is
|
package/dist/svg.d.ts
CHANGED
|
@@ -60,6 +60,16 @@ export interface RenderSvgOptions {
|
|
|
60
60
|
fonts?: RenderFonts;
|
|
61
61
|
/** `false` writes no `@font-face` data into the SVG (RR-61): the fonts handle still measures and the SVG still names the families, for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document). Default true: each SVG embeds the faces of `fonts.embeddedFonts` its own text draws. */
|
|
62
62
|
embedFonts?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
|
|
65
|
+
* no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
|
|
66
|
+
* faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
|
|
67
|
+
* cannot be selected, searched or edited in place, and each outlined element is a `role="img"` group labelled with its text for
|
|
68
|
+
* assistive technology. Each element keeps its `data-opf-*` trace. Text in a colour or bitmap font stays text
|
|
69
|
+
* (`text-as-paths-kept-text`). Use it for thumbnails, previews and portable SVG files, not for an editing surface or the
|
|
70
|
+
* vector PDF (which keeps real text from `<text>` SVG). Default false.
|
|
71
|
+
*/
|
|
72
|
+
textAsPaths?: boolean;
|
|
63
73
|
/** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
|
|
64
74
|
glyphFallback?: "chain" | "none";
|
|
65
75
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
package/dist/svg.js
CHANGED
|
@@ -540,7 +540,7 @@ function resolveContext(presentation, index, context) {
|
|
|
540
540
|
}
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
function bindSlide(presentation,
|
|
543
|
+
function bindSlide(presentation, index, context) {
|
|
544
544
|
const slidePath = `slides.${index}`;
|
|
545
545
|
// Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
|
|
546
546
|
// (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
|
|
@@ -548,6 +548,9 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
548
548
|
// `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
|
|
549
549
|
// or the render fails under `strictReferences`.
|
|
550
550
|
const slideContext = resolveContext(presentation, index, context);
|
|
551
|
+
// FA-31: every string the slide draws (titles, body text, table cells, notes) is the substituted slide: {{slide.number}}, {{slide.section}} and
|
|
552
|
+
// {{deck.slideCount}} hold this slide's number, its section and the deck's slide count, the same values the furniture draws. Core resolved them.
|
|
553
|
+
const slide = slideContext.slide;
|
|
551
554
|
const layout = slideContext.options.layout;
|
|
552
555
|
const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
|
|
553
556
|
const titleBindings = [];
|
|
@@ -637,9 +640,12 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
637
640
|
// `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
|
|
638
641
|
// `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
|
|
639
642
|
// measurement and drops the faces, for a host whose page already holds them.
|
|
643
|
+
// `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
|
|
640
644
|
function normalizeOptions(options) {
|
|
641
|
-
const { fonts, embedFonts, ...rest } = options ?? {};
|
|
642
|
-
|
|
645
|
+
const { fonts, embedFonts, textAsPaths, ...rest } = options ?? {};
|
|
646
|
+
if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
|
|
647
|
+
if (textAsPaths && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", "textAsPaths needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.", { option: "textAsPaths" });
|
|
648
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}) };
|
|
643
649
|
}
|
|
644
650
|
|
|
645
651
|
/**
|
|
@@ -657,7 +663,7 @@ function resolveDeck(input, options) {
|
|
|
657
663
|
}
|
|
658
664
|
|
|
659
665
|
const context = { presentation, options };
|
|
660
|
-
const slides = presentation.slides.map((
|
|
666
|
+
const slides = presentation.slides.map((_, index) => bindSlide(presentation, index, context));
|
|
661
667
|
|
|
662
668
|
return {
|
|
663
669
|
presentation,
|
|
@@ -700,7 +706,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
700
706
|
const { width, height } = bound.design.dimensions;
|
|
701
707
|
// The title may be TextRun[] (FA-10): the accessible name is its plain text.
|
|
702
708
|
const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
703
|
-
|
|
709
|
+
let content = [
|
|
704
710
|
renderBackground(bound, width, height, options),
|
|
705
711
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
706
712
|
...renderSlideContent(bound, width, height, options),
|
|
@@ -709,7 +715,14 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
709
715
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
710
716
|
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
711
717
|
].filter(Boolean);
|
|
712
|
-
|
|
718
|
+
// RR-64: text as glyph outlines. The used-face rule below then embeds only the faces of text that stays text.
|
|
719
|
+
let glyphs = "";
|
|
720
|
+
if (options.outlines) {
|
|
721
|
+
const outlined = options.outlines.outlineSlideText(content, { rootStyle: lang ? { lang } : {}, report: diagnostic => reportDiagnostic(diagnostic, options), fail: (code, message) => new OPFRenderError(code, message, { slideIndex }) });
|
|
722
|
+
content = outlined.content;
|
|
723
|
+
glyphs = outlined.defs;
|
|
724
|
+
}
|
|
725
|
+
const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
|
|
713
726
|
// FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
|
|
714
727
|
// 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
|
|
715
728
|
// would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
|
|
@@ -1732,12 +1745,30 @@ function nestedReset(style, run) {
|
|
|
1732
1745
|
return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
|
|
1733
1746
|
}
|
|
1734
1747
|
|
|
1735
|
-
|
|
1748
|
+
// RR-59 (#175): bidi rule L1 resets white space at the end of a line (with the isolate marks around it) to the paragraph level. A run
|
|
1749
|
+
// drawn as its own text chunk (a <text>, or a tspan with its own x) is such a line, and its paragraph is left to right, so the trailing
|
|
1750
|
+
// space of a right-to-left isolate drew at the run's right edge instead of its left, where it separates the run from the next one: at an
|
|
1751
|
+
// Arabic/Latin boundary the gap closed up (PowerPoint keeps it). A positioned run of a right-to-left line therefore draws without its
|
|
1752
|
+
// trailing white space, its glyphs starting after that white space's measured advance. Returns the white space ("" when there is none,
|
|
1753
|
+
// or when the text is all white space).
|
|
1754
|
+
function rtlTrailingSpace(text) {
|
|
1755
|
+
const found = /\S(\s+)$/u.exec(text);
|
|
1756
|
+
return found ? found[1] : "";
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* `runPlacement` ({x, width, fontSize}) positions the runs like `placement`, but only when the text plans into more than one run
|
|
1761
|
+
* (RR-59, #175): a list entry's line has no placed origin from core, and drawn as one chunk of nested tspans resvg shapes the whole
|
|
1762
|
+
* chunk with the outer face first and, when a fallback face covers every character, replaces every glyph with that face's (the Latin
|
|
1763
|
+
* phrase of an Arabic item took a wider fallback face and ran into the bullet).
|
|
1764
|
+
*/
|
|
1765
|
+
function scriptLine(text, style, bound, type, { rtl = false, placement, runPlacement, trace, fontSize } = {}) {
|
|
1736
1766
|
const value = String(text ?? "");
|
|
1737
1767
|
const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
|
|
1738
1768
|
rtl = rtl && value !== "";
|
|
1739
1769
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1740
1770
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
1771
|
+
if (!placement && runPlacement && runs.length > 1) placement = runPlacement;
|
|
1741
1772
|
// RR-38: PowerPoint's baseline of a line in Arabic Typesetting sits above core's (one em below the line top): the caller moves it up by `baselineShift` em.
|
|
1742
1773
|
const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
|
|
1743
1774
|
// FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
|
|
@@ -1763,16 +1794,20 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, f
|
|
|
1763
1794
|
advance += width;
|
|
1764
1795
|
const start = offset;
|
|
1765
1796
|
offset += run.symbol ? run.symbol.source.length : run.text.length;
|
|
1797
|
+
// RR-59: a right-to-left run's trailing white space sits at its left edge (see rtlTrailingSpace); the glyphs draw after it.
|
|
1798
|
+
const trail = rtl && !run.symbol ? rtlTrailingSpace(run.text) : "";
|
|
1799
|
+
const drawn = trail ? run.text.slice(0, -trail.length) : run.text;
|
|
1800
|
+
const gap = trail ? Math.min(width, Math.max(0, width - scripts.runWidths([{ ...run, text: drawn }], placement.fontSize, style)[0] * factor)) : 0;
|
|
1766
1801
|
// A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
|
|
1767
|
-
const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
|
|
1802
|
+
const pinned = run.symbol ? natural[index] > width + 1e-6 : width - gap > 0;
|
|
1768
1803
|
return tag("tspan", {
|
|
1769
|
-
x: stableNumber(placement.x + left),
|
|
1770
|
-
textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1804
|
+
x: stableNumber(placement.x + left + gap),
|
|
1805
|
+
textLength: pinned ? stableNumber(width - gap) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1771
1806
|
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1772
1807
|
"font-size": adjusted(run),
|
|
1773
1808
|
style: run.own ? undefined : nestedReset(style, run),
|
|
1774
|
-
...(trace ? trace(start, offset) : {})
|
|
1775
|
-
}, isolate(escapeText(
|
|
1809
|
+
...(trace ? trace(start, offset - trail.length) : {})
|
|
1810
|
+
}, isolate(escapeText(drawn)));
|
|
1776
1811
|
}).join("");
|
|
1777
1812
|
return { content, positioned: true, ...shiftOf(runs) };
|
|
1778
1813
|
}
|
|
@@ -1959,16 +1994,29 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1959
1994
|
? (linked ? bound.design.colors.hyperlink : config.fill)
|
|
1960
1995
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1961
1996
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1997
|
+
// RR-59 (#175): measured fragments of a line core did not place (a list entry) keep their measured origin; a mixed-script one
|
|
1998
|
+
// positions its runs (scriptLine runPlacement) as a placed line does.
|
|
1999
|
+
const measuredLine=!asFlow&&!naturalFlow&&fragment.kind!=='tab'&&fragment.width>0;
|
|
2000
|
+
// A right-to-left fragment draws without its trailing white space, after that white space's measured advance (rtlTrailingSpace).
|
|
2001
|
+
const trail=rtl&&measuredLine&&fragment.kind!=='marker'?rtlTrailingSpace(fragment.text):'';
|
|
2002
|
+
let gap=0;
|
|
2003
|
+
if(trail){
|
|
2004
|
+
const measure=textWidthMeasurer(fragment.style,config.options.textMeasurement),full=measure(fragment.text,fragment.fontSize);
|
|
2005
|
+
gap=full>0?Math.min(fragment.width,Math.max(0,fragment.width*(1-measure(fragment.text.slice(0,-trail.length),fragment.fontSize)/full))):0;
|
|
2006
|
+
if(!asFlow)position.x=stableNumber(originX+fragmentX+gap);
|
|
2007
|
+
}
|
|
2008
|
+
const drawnText=trail?fragment.text.slice(0,-trail.length):fragment.text,drawnWidth=fragment.width-gap;
|
|
1962
2009
|
// FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
|
|
1963
2010
|
const runLang=fragment.kind!=='tab'&&fragment.style.lang?(bound.scriptFontsFor?.(fragment.style.lang)?.profile.bcp47??fragment.style.lang):undefined;
|
|
1964
|
-
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(
|
|
1965
|
-
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:
|
|
2011
|
+
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(drawnText,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
|
|
2012
|
+
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,
|
|
2013
|
+
runPlacement:measuredLine&&!fixedAdvance?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1966
2014
|
if(scripted.positioned)fixedAdvance=false;
|
|
1967
2015
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1968
2016
|
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1969
2017
|
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1970
2018
|
const marker=fragment.kind==='marker';
|
|
1971
|
-
const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(
|
|
2019
|
+
const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end-trail.length,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(drawnWidth):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,run.code===true?'monospace':bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1972
2020
|
if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1973
2021
|
return rendered;
|
|
1974
2022
|
};
|
|
@@ -0,0 +1,278 @@
|
|
|
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 } from "./pdf-fonts.js";
|
|
15
|
+
import { layoutText } from "./pdf-text.js";
|
|
16
|
+
import { ROOT_STYLE, attributesOf, inheritStyle, parseLength } from "./pdf-style.js";
|
|
17
|
+
import { decodeEntities, escapeAttribute, parseXml } from "./pdf-xml.js";
|
|
18
|
+
|
|
19
|
+
const TOKENS = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
|
|
20
|
+
const ATTRIBUTE = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
21
|
+
// Attributes that only mean something on text: the outline group does not carry them.
|
|
22
|
+
const TEXT_ONLY = new Set(["x", "y", "dx", "dy", "rotate", "textLength", "lengthAdjust", "text-anchor", "text-rendering", "xml:space", "font-family", "font-size",
|
|
23
|
+
"font-weight", "font-style", "font-variant", "font-stretch", "font-feature-settings", "font-variant-ligatures", "letter-spacing", "word-spacing", "text-decoration",
|
|
24
|
+
"dominant-baseline", "alignment-baseline", "baseline-shift", "direction", "unicode-bidi", "writing-mode"]);
|
|
25
|
+
const COLOUR_TABLES = ["COLR", "CBDT", "sbix", "SVG "];
|
|
26
|
+
const libraries = new WeakMap();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The outline font library of a fonts handle: every face of its registry (`registry.exportFaces()`), or the faces of its
|
|
30
|
+
* `embeddedFonts` list. Built once per registry and extended when the registry gains faces (`ensure`, `addFaces`). Null when
|
|
31
|
+
* the handle holds no font bytes.
|
|
32
|
+
*/
|
|
33
|
+
export function outlineLibrary(fonts) {
|
|
34
|
+
const registry = fonts?.registry;
|
|
35
|
+
if (typeof registry?.exportFaces === "function") {
|
|
36
|
+
const exported = registry.exportFaces();
|
|
37
|
+
const described = typeof registry.describeFaces === "function" ? registry.describeFaces() : [];
|
|
38
|
+
let entry = libraries.get(registry);
|
|
39
|
+
if (!entry) { entry = { library: newLibrary(fonts), added: 0 }; libraries.set(registry, entry); }
|
|
40
|
+
// Registries only ever append faces.
|
|
41
|
+
for (let index = entry.added; index < exported.length; index++) addFace(entry.library, exported[index].data, exported[index].family, described[index]);
|
|
42
|
+
entry.added = exported.length;
|
|
43
|
+
return entry.library;
|
|
44
|
+
}
|
|
45
|
+
const list = fonts?.embeddedFonts;
|
|
46
|
+
if (!Array.isArray(list) || !list.length) return null;
|
|
47
|
+
let library = libraries.get(list);
|
|
48
|
+
if (!library) {
|
|
49
|
+
library = newLibrary(fonts);
|
|
50
|
+
for (const font of list) {
|
|
51
|
+
const match = /^data:font\/[a-z0-9]+;base64,([A-Za-z0-9+/=]+)$/.exec(String(font?.dataUrl ?? ""));
|
|
52
|
+
if (match) addFace(library, decodeBase64(match[1]), font.family, font);
|
|
53
|
+
}
|
|
54
|
+
libraries.set(list, library);
|
|
55
|
+
}
|
|
56
|
+
return library;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function newLibrary(fonts) {
|
|
60
|
+
const fallback = fonts?.registry?.fallbackFamily ?? "Roboto";
|
|
61
|
+
return new FontLibrary({ genericFamilies: () => [fallback] });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The face is matched by the family, weight and style the registry gives it, the ones the SVG names.
|
|
65
|
+
function addFace(library, data, family, described) {
|
|
66
|
+
const face = library.addData(data, "fonts", family);
|
|
67
|
+
if (!face || face.outlineStyled) return;
|
|
68
|
+
if (Number.isInteger(described?.weight)) face.weight = described.weight;
|
|
69
|
+
if (typeof described?.italic === "boolean") face.italic = described.italic;
|
|
70
|
+
face.outlineStyled = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function decodeBase64(text) {
|
|
74
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(text, "base64"));
|
|
75
|
+
const binary = atob(text), bytes = new Uint8Array(binary.length);
|
|
76
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
77
|
+
return bytes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Replace the <text> elements of the slide's content strings by glyph outlines. `library` comes from `outlineLibrary`,
|
|
82
|
+
* `rootStyle` holds what the <svg> element gives its children (lang), `report(diagnostic)` receives what cannot be outlined
|
|
83
|
+
* and `fail(code, message)` builds the error thrown when no face draws a text. Returns the new content and the <defs> markup.
|
|
84
|
+
*/
|
|
85
|
+
export function outlineSlideText(content, { library, rootStyle = {}, report = () => {}, fail }) {
|
|
86
|
+
const defs = new Map();
|
|
87
|
+
const reported = new Set();
|
|
88
|
+
const env = {
|
|
89
|
+
fonts: library,
|
|
90
|
+
defaults: [],
|
|
91
|
+
splitByOwner: true,
|
|
92
|
+
// The renderer reports its own font substitutions and missing glyphs while it lays the slide out.
|
|
93
|
+
diagnostic: () => {},
|
|
94
|
+
missing: () => {},
|
|
95
|
+
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.`),
|
|
96
|
+
};
|
|
97
|
+
const base = inheritStyle(ROOT_STYLE, rootStyle);
|
|
98
|
+
const keep = (family) => {
|
|
99
|
+
if (reported.has(family)) return;
|
|
100
|
+
reported.add(family);
|
|
101
|
+
report({ code: "text-as-paths-kept-text", fontFamily: family, message: `'${family}' is a colour or bitmap font, which has no plain outlines; its text stays text and the SVG embeds the face.` });
|
|
102
|
+
};
|
|
103
|
+
const outlined = content.map((markup) => typeof markup === "string" && markup.includes("<text") ? outlineMarkup(markup, base, env, defs, keep) : markup);
|
|
104
|
+
const definitions = [...defs].map(([id, d]) => `<path id="${id}" d="${d}"/>`).join("");
|
|
105
|
+
return { content: outlined, defs: definitions ? `<defs>${definitions}</defs>` : "" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Splices each <text> element of `markup`; the style it inherits is folded from its ancestors' attributes.
|
|
109
|
+
function outlineMarkup(markup, base, env, defs, keep) {
|
|
110
|
+
const ancestors = [];
|
|
111
|
+
let out = "", from = 0, start = -1, depth = 0, parentStyle = null;
|
|
112
|
+
for (const match of markup.matchAll(TOKENS)) {
|
|
113
|
+
const [token, close, name, attributes = "", selfClose] = match;
|
|
114
|
+
if (name === undefined) continue;
|
|
115
|
+
if (start >= 0) {
|
|
116
|
+
if (name === "text") depth += close ? -1 : selfClose ? 0 : 1;
|
|
117
|
+
if (depth > 0) continue;
|
|
118
|
+
const end = match.index + token.length;
|
|
119
|
+
out += markup.slice(from, start) + outlineText(markup.slice(start, end), parentStyle, env, defs, keep);
|
|
120
|
+
from = end;
|
|
121
|
+
start = -1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (close) { ancestors.pop(); continue; }
|
|
125
|
+
if (name === "text" && selfClose) {
|
|
126
|
+
// An empty <text/> (an empty table cell) draws nothing: it becomes an empty group with the element's other attributes.
|
|
127
|
+
const end = match.index + token.length;
|
|
128
|
+
out += markup.slice(from, match.index) + outlineText(token, base, env, defs, keep);
|
|
129
|
+
from = end;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (name === "text") {
|
|
133
|
+
start = match.index;
|
|
134
|
+
depth = 1;
|
|
135
|
+
parentStyle = ancestors.reduce((style, attrs) => inheritStyle(style, attrs), base);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!selfClose) ancestors.push(attributesOf({ attrs: parseAttributes(attributes) }));
|
|
139
|
+
}
|
|
140
|
+
return from === 0 ? markup : out + markup.slice(from);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseAttributes(text) {
|
|
144
|
+
const attrs = {};
|
|
145
|
+
for (const attribute of text.matchAll(ATTRIBUTE)) attrs[attribute[1]] = decodeEntities(attribute[2] ?? attribute[3] ?? "");
|
|
146
|
+
return attrs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function outlineText(source, parentStyle, env, defs, keep) {
|
|
150
|
+
const node = parseXml(`<svg>${source}</svg>`)?.children.find((child) => child.name === "text");
|
|
151
|
+
if (!node) return source;
|
|
152
|
+
const { runs } = layoutText(node, env, parentStyle, null);
|
|
153
|
+
const colour = runs.find((run) => COLOUR_TABLES.some((table) => run.face.font.directory?.tables?.[table]));
|
|
154
|
+
if (colour) { keep([...colour.face.names][0]); return source; }
|
|
155
|
+
const attrs = Object.entries(node.attrs).filter(([key]) => !TEXT_ONLY.has(key));
|
|
156
|
+
const hidden = node.attrs["aria-hidden"] === "true", words = logicalText(runs);
|
|
157
|
+
const label = hidden || !words ? [] : [["role", "img"], ["aria-label", words]];
|
|
158
|
+
const groups = [];
|
|
159
|
+
for (const run of runs) {
|
|
160
|
+
const drawn = drawRun(run, defs);
|
|
161
|
+
if (!drawn) continue;
|
|
162
|
+
const owner = run.owner !== node && run.owner ? run.owner : null;
|
|
163
|
+
const last = groups.at(-1);
|
|
164
|
+
if (last && last.owner === owner) last.parts.push(drawn);
|
|
165
|
+
else groups.push({ owner, parts: [drawn] });
|
|
166
|
+
}
|
|
167
|
+
const body = groups.map(({ owner, parts }) => {
|
|
168
|
+
const trace = owner ? Object.entries(owner.attrs).filter(([key]) => key.startsWith("data-")) : [];
|
|
169
|
+
return trace.length ? element("g", trace, parts.join("")) : parts.join("");
|
|
170
|
+
}).join("");
|
|
171
|
+
return element("g", [...attrs, ...label], body);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The text in reading order: runs are placed in visual order, so a right-to-left chunk is read from its logical text.
|
|
175
|
+
function logicalText(runs) {
|
|
176
|
+
const chunks = [];
|
|
177
|
+
for (const run of runs) if (!chunks.includes(run.chunk)) chunks.push(run.chunk);
|
|
178
|
+
return chunks.map((chunk) => chunk.logical).join(" ").replace(/\s+/g, " ").trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function drawRun(run, defs) {
|
|
182
|
+
const style = run.style, size = run.size, face = run.face, upem = face.upem;
|
|
183
|
+
const scale = size / upem, extra = run.extraSpacing ?? 0, scaleX = run.scaleX ?? 1;
|
|
184
|
+
// The run is drawn in font units (y up) under one transform; each glyph is a <use> at its pen position in font units.
|
|
185
|
+
const uses = [];
|
|
186
|
+
let pen = 0;
|
|
187
|
+
for (const glyph of run.glyphs) {
|
|
188
|
+
const id = glyphId(face, glyph.gid, defs);
|
|
189
|
+
if (id) {
|
|
190
|
+
const x = units(pen / scale + glyph.xOffset), y = units(glyph.yOffset);
|
|
191
|
+
uses.push(`<use href="#${id}"${x === "0" ? "" : ` x="${x}"`}${y === "0" ? "" : ` y="${y}"`}/>`);
|
|
192
|
+
}
|
|
193
|
+
pen += glyph.advance * scale + glyph.spacing + extra;
|
|
194
|
+
}
|
|
195
|
+
const decorations = [...run.decoration].map((kind) => decorationRect(kind, run, scale));
|
|
196
|
+
if (!uses.length && !decorations.length) return "";
|
|
197
|
+
const paint = [["fill", style.fill]];
|
|
198
|
+
if (style["fill-opacity"] !== undefined && style["fill-opacity"] !== "1") paint.push(["fill-opacity", style["fill-opacity"]]);
|
|
199
|
+
if (style.stroke && style.stroke !== "none") {
|
|
200
|
+
// Glyphs are drawn at the em scale, so the stroke width is given in font units.
|
|
201
|
+
const width = parseLength(style["stroke-width"], { fontSize: size }) ?? 1;
|
|
202
|
+
paint.push(["stroke", style.stroke], ["stroke-width", precise(width / scale)]);
|
|
203
|
+
if (style["stroke-opacity"] !== undefined && style["stroke-opacity"] !== "1") paint.push(["stroke-opacity", style["stroke-opacity"]]);
|
|
204
|
+
}
|
|
205
|
+
if (style.visibility && style.visibility !== "visible") paint.push(["visibility", style.visibility]);
|
|
206
|
+
const glyphs = uses.length ? `<g transform="matrix(${precise(scale * scaleX)} 0 0 ${precise(-scale)} ${fixed(run.x)} ${fixed(run.y)})">${uses.join("")}</g>` : "";
|
|
207
|
+
const drawn = element("g", paint, glyphs + decorations.join(""));
|
|
208
|
+
// A link inside the text keeps its element's attributes (href, rel, target).
|
|
209
|
+
return run.link ? element("a", Object.entries(run.link.node?.attrs ?? { href: run.link.href }), drawn) : drawn;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Underline, line-through and overline in the text colour, from the font's own metrics (the vector PDF draws the same).
|
|
213
|
+
function decorationRect(kind, run, scale) {
|
|
214
|
+
const font = run.face.font, upem = run.face.upem;
|
|
215
|
+
let position, thickness;
|
|
216
|
+
if (kind === "underline") { position = -(font.underlinePosition || -upem * 0.1) * scale; thickness = (font.underlineThickness || upem * 0.05) * scale; }
|
|
217
|
+
else if (kind === "line-through") { position = -(font["OS/2"]?.yStrikeoutPosition || upem * 0.3) * scale; thickness = (font["OS/2"]?.yStrikeoutSize || upem * 0.05) * scale; }
|
|
218
|
+
else { position = -font.ascent * scale; thickness = (font.underlineThickness || upem * 0.05) * scale; }
|
|
219
|
+
return `<rect x="${fixed(run.x)}" y="${fixed(run.y + position - thickness / 2)}" width="${fixed(run.width)}" height="${fixed(thickness)}" stroke="none"/>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The outline of a glyph, once per slide, in font units (y up); null for a glyph that draws nothing (a space).
|
|
223
|
+
function glyphId(face, gid, defs) {
|
|
224
|
+
const id = `opf-g-${face.hash.slice(0, 12)}-${gid}`;
|
|
225
|
+
if (defs.has(id)) return id;
|
|
226
|
+
const d = outlinePath(face.font.getGlyph(gid).path.commands);
|
|
227
|
+
if (!d) return null;
|
|
228
|
+
defs.set(id, d);
|
|
229
|
+
return id;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Relative commands, each delta taken from the previous rounded point, so rounding to a tenth of a unit never drifts.
|
|
233
|
+
function outlinePath(commands) {
|
|
234
|
+
const parts = [];
|
|
235
|
+
let x = 0, y = 0, startX = 0, startY = 0, draws = false;
|
|
236
|
+
for (const { command, args } of commands) {
|
|
237
|
+
const letter = { moveTo: "m", lineTo: "l", quadraticCurveTo: "q", bezierCurveTo: "c", closePath: "z" }[command];
|
|
238
|
+
if (!letter) continue;
|
|
239
|
+
if (letter === "z") { parts.push("z"); x = startX; y = startY; continue; }
|
|
240
|
+
const points = [];
|
|
241
|
+
for (let index = 0; index < args.length; index += 2) points.push([round(args[index]), round(args[index + 1])]);
|
|
242
|
+
parts.push(letter + numbers(points.flatMap(([px, py]) => [px - x, py - y])));
|
|
243
|
+
[x, y] = points.at(-1);
|
|
244
|
+
if (letter === "m") { startX = x; startY = y; } else draws = true;
|
|
245
|
+
}
|
|
246
|
+
return draws ? parts.join("") : "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function round(value) { return Math.round(value * 10) / 10; }
|
|
250
|
+
// Numbers joined the short SVG way: no separator before a minus sign.
|
|
251
|
+
function numbers(values) {
|
|
252
|
+
return values.map((value, index) => { const text = units(Math.round(value * 10) / 10); return index && !text.startsWith("-") ? " " + text : text; }).join("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function element(name, attrs, body) {
|
|
256
|
+
const text = attrs.filter(([, value]) => value !== undefined && value !== null).map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join("");
|
|
257
|
+
return body ? `<${name}${text}>${body}</${name}>` : `<${name}${text}/>`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Font units to a tenth; positions to a thousandth of a pixel; the em scale to six significant digits.
|
|
261
|
+
function units(value) { const text = String(Math.round(value * 10) / 10); return text === "-0" ? "0" : text; }
|
|
262
|
+
function fixed(value) { const text = Number(value).toFixed(3).replace(/\.?0+$/, ""); return text === "-0" ? "0" : text; }
|
|
263
|
+
function precise(value) { return String(Number(Number(value).toPrecision(6))); }
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The outline engine a fonts handle carries as `outlines` (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws each
|
|
268
|
+
* 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.
|
|
269
|
+
*/
|
|
270
|
+
export function textOutlines(fonts) {
|
|
271
|
+
return Object.freeze({
|
|
272
|
+
outlineSlideText: (content, options) => {
|
|
273
|
+
const library = outlineLibrary(fonts);
|
|
274
|
+
if (!library) throw options.fail("text-as-paths-needs-fonts", "textAsPaths needs font faces to outline: pass the handle loadFonts() returns as `fonts`.");
|
|
275
|
+
return outlineSlideText(content, { ...options, library });
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -139,7 +139,7 @@
|
|
|
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
145
|
"pako": "1.0.11"
|