@openpresentation/opf-render 0.17.0 → 0.18.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/dist/svg.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { RenderFonts, ScriptFonts } from "./fonts.js";
2
- import type { Catalog, Finding, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
2
+ import type { Catalog, Finding, SlideSelection, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
3
+ export type { SlideSelection } from "@openpresentation/opf";
3
4
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
4
5
  export declare const packageName = "@openpresentation/opf-render";
5
6
 
@@ -49,8 +50,8 @@ export type RenderDiagnostic = LayoutDiagnostic | {
49
50
  placeholder: "label" | "icon";
50
51
  };
51
52
 
52
- export interface RenderSvgOptions {
53
- /** Leave out slides marked `hidden: true`, as the player does. Default false: one SVG per slide. With true the result holds only the visible slides, in order, so an index no longer names the slide at that index. Read by the deck-level `renderSvg`; `renderSlideSvg` draws the slide it is given. */
53
+ export interface ToSvgOptions {
54
+ /** Leave out slides marked `hidden: true` when drawing the whole deck, as the player does. Default false: one SVG per slide. A slide number or selection draws exactly the slides it names. */
54
55
  skipHidden?: boolean;
55
56
  /**
56
57
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
@@ -58,18 +59,32 @@ export interface RenderSvgOptions {
58
59
  * core's portable width estimate and the SVG names the design fonts without embedding them.
59
60
  */
60
61
  fonts?: RenderFonts;
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
- embedFonts?: boolean;
63
62
  /**
64
- * `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
63
+ * How the SVG draws text (RR-74). `"fonts"` (default): each SVG embeds, as `@font-face` data, the faces of `fonts.embeddedFonts` its
64
+ * own text draws (RR-61). `"system"`: no `@font-face` data; the fonts handle still measures and the SVG still names the families,
65
+ * for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document).
66
+ *
67
+ * `"paths"`: every `<text>` drawn as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
65
68
  * 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
69
  * 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.
70
+ * cannot be edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
71
+ * given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
72
+ * drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
73
+ * A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
74
+ * (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
75
+ * layout's measurement disagree on it (`"shaping"`). Use it for thumbnails, previews and portable SVG files, not for an editing
76
+ * surface or the vector PDF (which keeps real text from `<text>` SVG).
71
77
  */
72
- textAsPaths?: boolean;
78
+ text?: "fonts" | "system" | "paths";
79
+ /**
80
+ * `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
81
+ * handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
82
+ * browser draws it exactly like the whole face (over the core example decks a slide's SVG falls from 2.4 MB to 59 KB on average).
83
+ * Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
84
+ * Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
85
+ * false for an SVG whose text will be edited outside the renderer.
86
+ */
87
+ subsetFonts?: boolean;
73
88
  /** `"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`. */
74
89
  glyphFallback?: "chain" | "none";
75
90
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
@@ -107,20 +122,8 @@ export interface RenderSvgOptions {
107
122
  strictReferences?: boolean;
108
123
  }
109
124
 
110
- export interface SvgToPngOptions {
111
- /** The fonts the conversion draws with (Node): see `SvgToPngOptions` in the main entry. */
112
- fonts?: RenderFonts;
113
- scale?: number;
114
- background?: string;
115
- dpi?: number;
116
- /** Extra directories of font files to draw with. */
117
- fontDirs?: string[];
118
- defaultFontFamily?: string;
119
- sansSerifFamily?: string;
120
- monospaceFamily?: string;
121
- }
122
-
123
- export interface SvgToPdfOptions extends SvgToPngOptions {}
125
+ /** The Node conversion options (types only: `toPng` and `toPdf` run in Node, or from `/export-browser`). */
126
+ export type { ToPngOptions, ToPdfOptions } from "./index.js";
124
127
 
125
128
  export interface ResolvedPresentation {
126
129
  presentation: unknown;
@@ -173,12 +176,16 @@ export declare class OPFRenderError extends Error {
173
176
  constructor(code: string, message: string, details?: Record<string, unknown>);
174
177
  }
175
178
 
176
- export declare function resolvePresentation(input: unknown, options?: RenderSvgOptions): ResolvedPresentation;
177
-
178
- /** The SVG of every slide of the deck, in order. */
179
- export declare function renderSvg(input: unknown, options?: RenderSvgOptions): string[];
180
-
181
- /** The SVG of one slide of the deck (`index` is zero-based; out of range throws `slide-index-out-of-range`). */
182
- export declare function renderSlideSvg(input: unknown, index: number, options?: RenderSvgOptions): string;
179
+ export declare function resolvePresentation(input: unknown, options?: ToSvgOptions): ResolvedPresentation;
183
180
 
181
+ /** An SVG slide the renderer drew: its text, or its bytes. */
182
+ export type SvgSlide = string | Uint8Array;
184
183
 
184
+ /**
185
+ * The deck as SVG (RR-73). `toSvg(deck)`: every slide, in order (without the hidden ones under `skipHidden`). `toSvg(deck, 3)`: the
186
+ * third slide (slides count from 1; out of range throws `slide-out-of-range`). `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])`: the
187
+ * selected slides (a malformed selection throws `invalid-slide-selection`). The second argument may be the options instead.
188
+ */
189
+ export declare function toSvg(input: unknown, options?: ToSvgOptions): string[];
190
+ export declare function toSvg(input: unknown, slide: number, options?: ToSvgOptions): string;
191
+ export declare function toSvg(input: unknown, slides: Exclude<SlideSelection, number>, options?: ToSvgOptions): string[];
package/dist/svg.js CHANGED
@@ -22,6 +22,9 @@ import { disabledFeaturesStyle } from "./font-compatibility.js";
22
22
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
23
23
  import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
24
24
  import { renderCaption, renderFootnotes } from "./annotations.js";
25
+ import { drawnFaces } from "./drawn-faces.js";
26
+ import { dataUrlFsType, embeddingAllowed } from "./font-fstype.js";
27
+ import { rejectRenamedOptions, selectSlides } from "./slide-sources.js";
25
28
 
26
29
  export const packageName = "@openpresentation/opf-render";
27
30
 
@@ -638,19 +641,24 @@ function bindSlide(presentation, index, context) {
638
641
  }
639
642
 
640
643
  // `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
641
- // `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
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).
644
+ // `embeddedFonts` list). The renderer reads the two as plain options from here on. `text` (RR-74) says how the SVG draws text:
645
+ // `"fonts"` (default) embeds the faces the slide draws, cut to its glyphs when the handle carries `subsets` (RR-61, RR-65);
646
+ // `"system"` embeds no face and names the families, for a host whose page already holds them; `"paths"` draws glyph outlines with
647
+ // the handle's `outlines` engine, so the SVG needs no font (RR-64). `subsetFonts: false` embeds whole faces.
648
+ const TEXT_MODES = ["fonts", "system", "paths"];
644
649
  function normalizeOptions(options) {
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 } : {}) };
650
+ rejectRenamedOptions(options, "invalid-render-options");
651
+ const { fonts, text = "fonts", subsetFonts, ...rest } = options ?? {};
652
+ if (!TEXT_MODES.includes(text)) throw new OPFRenderError("invalid-render-options", 'text must be "fonts", "system" or "paths".', { option: "text", value: text });
653
+ if (subsetFonts !== undefined && typeof subsetFonts !== "boolean") throw new OPFRenderError("invalid-render-options", "subsetFonts must be true or false.", { option: "subsetFonts" });
654
+ if (typeof fonts === "string" || Array.isArray(fonts)) throw new OPFRenderError("invalid-render-options", "Drawing a deck needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser): it measures the text. Font folders are read by toPng and toPdf for SVG input.", { option: "fonts" });
655
+ if (text === "paths" && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", 'text: "paths" needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.', { option: "text" });
656
+ return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: text === "system" ? undefined : fonts?.embeddedFonts, ...(text === "paths" ? { outlines: fonts.outlines } : {}), ...(subsetFonts !== false && fonts?.subsets ? { subsets: fonts.subsets } : {}) };
649
657
  }
650
658
 
651
659
  /**
652
660
  * Resolve a presentation for drawing: template variables filled, the boundary check passed, and every slide bound to its
653
- * layout, design, script fonts and composed geometry. `renderSvg` and `renderSlideSvg` draw from this.
661
+ * layout, design, script fonts and composed geometry. `toSvg` draws from this.
654
662
  */
655
663
  export function resolvePresentation(input, options = {}) {
656
664
  return resolveDeck(input, normalizeOptions(options));
@@ -673,27 +681,24 @@ function resolveDeck(input, options) {
673
681
  }
674
682
 
675
683
  /**
676
- * The SVG of every slide of the deck, in order. `skipHidden: true` leaves out slides marked `hidden` (the sequence the player
677
- * presents); the result is then shorter than the deck, so an index no longer names the slide at that index.
684
+ * The deck as SVG (RR-73): `toSvg(deck)` gives every slide, in order, as an array; `toSvg(deck, 3)` the third slide as one string
685
+ * (slides count from 1); `toSvg(deck, "1-3")` (`"2,4"`, `"5-"`, the CLI's --slides syntax, or a list such as `[1, 3]`) the selected slides as an array. The
686
+ * second argument may be the options instead, told apart by type. `skipHidden: true` leaves slides marked `hidden` out of the
687
+ * whole deck (the sequence the player presents); a selection draws exactly the slides it names.
678
688
  */
679
- export function renderSvg(input, options = {}) {
680
- const drawn = normalizeOptions(options);
689
+ export function toSvg(input, slides, options) {
690
+ if (slides !== null && typeof slides === "object" && !Array.isArray(slides)) { options = slides; slides = undefined; }
691
+ const drawn = normalizeOptions(options ?? {});
681
692
  const resolved = resolveDeck(input, drawn);
682
- const indexes = resolved.slides.map((_, index) => index).filter(index => !(options.skipHidden === true && resolved.presentation.slides[index]?.hidden === true));
683
- return indexes.map(index => renderResolvedSlide(resolved, index, drawn));
684
- }
685
-
686
- /** The SVG of one slide of the deck (a zero-based index). */
687
- export function renderSlideSvg(input, index, options = {}) {
688
- const drawn = normalizeOptions(options);
689
- const resolved = resolveDeck(input, drawn);
690
- if (!Number.isInteger(index) || index < 0 || index >= resolved.slides.length) {
691
- throw new OPFRenderError("slide-index-out-of-range", `Slide index ${index} is out of range.`, {
692
- slideIndex: index,
693
- slideCount: resolved.slides.length
694
- });
693
+ const count = resolved.slides.length;
694
+ if (typeof slides === "number") {
695
+ if (!Number.isInteger(slides) || slides < 1 || slides > count) throw new OPFRenderError("slide-out-of-range", `Slide ${slides} is not in the presentation, which has ${count} slide${count === 1 ? "" : "s"} (slides count from 1).`, { slide: slides, slideCount: count });
696
+ return renderResolvedSlide(resolved, slides - 1, drawn);
695
697
  }
696
- return renderResolvedSlide(resolved, index, drawn);
698
+ const numbers = slides === undefined
699
+ ? resolved.slides.map((_, index) => index + 1).filter(number => !(options?.skipHidden === true && resolved.presentation.slides[number - 1]?.hidden === true))
700
+ : selectSlides(slides, count);
701
+ return numbers.map(number => renderResolvedSlide(resolved, number - 1, drawn));
697
702
  }
698
703
 
699
704
  function renderResolvedSlide(resolved, slideIndex, options) {
@@ -722,7 +727,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
722
727
  content = outlined.content;
723
728
  glyphs = outlined.defs;
724
729
  }
725
- const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
730
+ const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content, options.subsets, diagnostic => reportDiagnostic(diagnostic, options))), ...content].filter(Boolean);
726
731
  // FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
727
732
  // 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
728
733
  // would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
@@ -1692,12 +1697,27 @@ function renderBranding(bound,presentation,width,height,options) {
1692
1697
  // The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
1693
1698
  pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain',imageDecorative:true})));
1694
1699
  }
1695
- // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1700
+ // Cover and section slides: the logo core composed (the primary organization's full logo, or the design.logo override, in the
1701
+ // variant for this slide's background) at the top-left of the free area, anchored left. RR-71: the picture is named by its
1702
+ // asset's alt text, else by the organization it belongs to ("Acme Corp logo"); an unnamed organization still says "Logo".
1696
1703
  const logo=bound.geometry.logo;
1697
- if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:'Logo',imageGenerated:true}));
1704
+ if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:logoLabel(presentation,logo.path),imageGenerated:true}));
1698
1705
  return pieces.join('');
1699
1706
  }
1700
1707
 
1708
+ // The organization a logo path (`organization.logo.full`, `organization.1.logo.icon.onDark`) belongs to: the record at the index the path
1709
+ // names, else the single organization object.
1710
+ function logoOrganization(presentation,path){
1711
+ const match=/^organization(?:\.(\d+))?\.logo(?:\.|$)/.exec(path??'');
1712
+ if(!match)return undefined;
1713
+ const organization=presentation?.organization;
1714
+ return Array.isArray(organization)?organization[Number(match[1])]:match[1]===undefined?organization:undefined;
1715
+ }
1716
+ function logoLabel(presentation,path){
1717
+ const name=logoOrganization(presentation,path)?.name;
1718
+ return typeof name==='string'&&name.trim()?`${name.trim()} logo`:'Logo';
1719
+ }
1720
+
1701
1721
  // A family name is written unquoted only when it is a valid sequence of CSS identifiers; a word that starts with a digit
1702
1722
  // ("Source Sans 3", "Noto Sans Symbols 2") or any other non-identifier character makes an unquoted name invalid CSS, which
1703
1723
  // drops the whole font-family in a browser (FF-45). Such names are single-quoted (the attribute is double-quoted).
@@ -1861,10 +1881,11 @@ function codeSyntax(item, layout, bound, options) {
1861
1881
  // the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
1862
1882
  // embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
1863
1883
  // a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
1864
- function embeddedFontsFor(fonts = [], content) {
1884
+ function embeddedFontsFor(fonts = [], content, subsets, report = () => {}) {
1865
1885
  if (!fonts.length) return fonts;
1866
1886
  fonts.forEach(assertEmbeddableFont);
1867
- const drawn = drawnFaces(content.join("\n"));
1887
+ const characters = subsets ? new Map() : undefined;
1888
+ const drawn = drawnFaces(content.join("\n"), characters);
1868
1889
  const wanted = font => {
1869
1890
  const triples = drawn.get(String(font.family).toLowerCase());
1870
1891
  if (!triples) return false;
@@ -1874,33 +1895,28 @@ function embeddedFontsFor(fonts = [], content) {
1874
1895
  return matching.length ? matching.includes(font) : true;
1875
1896
  });
1876
1897
  };
1877
- return fonts.filter(font => font?.embed === "always" || wanted(font));
1878
- }
1879
-
1880
- const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
1881
- /** family (lowercase) to the set of "weight|style" pairs the markup draws text in. */
1882
- function drawnFaces(markup) {
1883
- const drawn = new Map();
1884
- const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
1885
- const stack = [{ families: [], weight: "400", style: "normal", text: false }];
1886
- const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
1887
- for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
1888
- const top = stack.at(-1);
1889
- if (name === undefined) {
1890
- if (top.text && /\S/.test(token)) for (const family of top.families) { const set = drawn.get(family) ?? new Set(); set.add(`${top.weight}|${top.style}`); drawn.set(family, set); }
1891
- continue;
1892
- }
1893
- if (close) { if (stack.length > 1) stack.pop(); continue; }
1894
- const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
1895
- const next = {
1896
- families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^&quot;|&quot;$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
1897
- weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
1898
- style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
1899
- text: top.text || name === "text",
1900
- };
1901
- if (!selfClose) stack.push(next);
1902
- }
1903
- return drawn;
1898
+ const drawnOrAlways = fonts.filter(font => font?.embed === "always" || wanted(font));
1899
+ const used = drawnOrAlways.filter(embeddable);
1900
+ const refused = drawnOrAlways.filter(font => !embeddable(font));
1901
+ if (refused.length) report({ code: "font-embedding-restricted", faces: refused.map(font => ({ fontFamily: font.family, weight: font.weight, italic: Boolean(font.italic), fsType: embeddingChecked.get(font).fsType })),
1902
+ message: `${[...new Set(refused.map(font => `'${font.family}'`))].join(", ")} forbid${refused.length === 1 ? "s" : ""} embedding (OS/2 fsType), so the SVG names ${refused.length === 1 ? "it" : "them"} without the font data; a viewer without the font draws the generic family.` });
1903
+ // RR-65: each face cut to the characters the slide draws in its family (the handle's subset engine decides whether the face
1904
+ // may be subset at all). A face no text draws (embed: "always") stays whole.
1905
+ if (!subsets) return used;
1906
+ return used.map(font => {
1907
+ const points = characters.get(String(font.family).toLowerCase());
1908
+ return points?.size ? { ...font, dataUrl: subsets.subsetDataUrl(font, points) } : font;
1909
+ });
1910
+ }
1911
+
1912
+ // RR-76: a face whose OS/2 fsType forbids embedding (restricted, or bitmap-only: an SVG carries outlines) is never written as
1913
+ // @font-face data. The SVG still names its family, so a viewer without the font draws the generic family; a diagnostic says
1914
+ // why. The check reads only the font's table directory and its fsType bytes, once per face object and data URL.
1915
+ const embeddingChecked = new WeakMap();
1916
+ function embeddable(font) {
1917
+ let checked = embeddingChecked.get(font);
1918
+ if (!checked || checked.dataUrl !== font.dataUrl) { const fsType = dataUrlFsType(font.dataUrl); checked = { dataUrl: font.dataUrl, fsType, allowed: embeddingAllowed(fsType) }; embeddingChecked.set(font, checked); }
1919
+ return checked.allowed;
1904
1920
  }
1905
1921
 
1906
1922
  // Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
@@ -1,4 +1,4 @@
1
- // RR-64: `textAsPaths: true` draws every <text> of a slide as glyph outlines, so the SVG needs no font. The renderer has
1
+ // RR-64: `text: "paths"` draws every <text> of a slide as glyph outlines, so the SVG needs no font. The renderer has
2
2
  // already decided every line, position and width (x, y, text-anchor, textLength); the vector PDF export's SVG text layout
3
3
  // (layoutText: text chunks, bidirectional runs, per-character fallback, fontkit shaping, textLength) turns that into positioned
4
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
@@ -11,10 +11,11 @@
11
11
  // the font's own metrics. Text drawn with a colour or bitmap font (COLR, CBDT, sbix, SVG tables) stays <text>, reported as
12
12
  // `text-as-paths-kept-text`, and the SVG then embeds that face as usual.
13
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";
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";
18
19
 
19
20
  const TOKENS = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
20
21
  const ATTRIBUTE = /([^\s=/>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
@@ -58,15 +59,20 @@ export function outlineLibrary(fonts) {
58
59
 
59
60
  function newLibrary(fonts) {
60
61
  const fallback = fonts?.registry?.fallbackFamily ?? "Roboto";
61
- return new FontLibrary({ genericFamilies: () => [fallback] });
62
+ const library = new FontLibrary({ genericFamilies: () => [fallback] });
63
+ library.restricted = new Set();
64
+ return library;
62
65
  }
63
66
 
64
- // The face is matched by the family, weight and style the registry gives it, the ones the SVG names.
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.
65
69
  function addFace(library, data, family, described) {
66
70
  const face = library.addData(data, "fonts", family);
67
71
  if (!face || face.outlineStyled) return;
68
72
  if (Number.isInteger(described?.weight)) face.weight = described.weight;
69
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);
70
76
  face.outlineStyled = true;
71
77
  }
72
78
 
@@ -82,23 +88,30 @@ function decodeBase64(text) {
82
88
  * `rootStyle` holds what the <svg> element gives its children (lang), `report(diagnostic)` receives what cannot be outlined
83
89
  * and `fail(code, message)` builds the error thrown when no face draws a text. Returns the new content and the <defs> markup.
84
90
  */
85
- export function outlineSlideText(content, { library, rootStyle = {}, report = () => {}, fail }) {
91
+ export function outlineSlideText(content, { library, shaper, rootStyle = {}, report = () => {}, fail }) {
86
92
  const defs = new Map();
87
93
  const reported = new Set();
88
94
  const env = {
89
95
  fonts: library,
90
96
  defaults: [],
91
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 }) } : {}),
92
100
  // The renderer reports its own font substitutions and missing glyphs while it lays the slide out.
93
101
  diagnostic: () => {},
94
102
  missing: () => {},
95
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.`),
96
104
  };
97
105
  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.` });
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.` });
102
115
  };
103
116
  const outlined = content.map((markup) => typeof markup === "string" && markup.includes("<text") ? outlineMarkup(markup, base, env, defs, keep) : markup);
104
117
  const definitions = [...defs].map(([id, d]) => `<path id="${id}" d="${d}"/>`).join("");
@@ -150,14 +163,14 @@ function outlineText(source, parentStyle, env, defs, keep) {
150
163
  const node = parseXml(`<svg>${source}</svg>`)?.children.find((child) => child.name === "text");
151
164
  if (!node) return source;
152
165
  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
166
  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 = [];
167
+ const hidden = node.attrs["aria-hidden"] === "true";
168
+ const groups = [], outlined = [];
159
169
  for (const run of runs) {
160
- const drawn = drawRun(run, defs);
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);
161
174
  if (!drawn) continue;
162
175
  const owner = run.owner !== node && run.owner ? run.owner : null;
163
176
  const last = groups.at(-1);
@@ -168,14 +181,53 @@ function outlineText(source, parentStyle, env, defs, keep) {
168
181
  const trace = owner ? Object.entries(owner.attrs).filter(([key]) => key.startsWith("data-")) : [];
169
182
  return trace.length ? element("g", trace, parts.join("")) : parts.join("");
170
183
  }).join("");
171
- return element("g", [...attrs, ...label], body);
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;
172
220
  }
173
221
 
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();
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;
179
231
  }
180
232
 
181
233
  function drawRun(run, defs) {
@@ -204,9 +256,9 @@ function drawRun(run, defs) {
204
256
  }
205
257
  if (style.visibility && style.visibility !== "visible") paint.push(["visibility", style.visibility]);
206
258
  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;
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;
210
262
  }
211
263
 
212
264
  // Underline, line-through and overline in the text colour, from the font's own metrics (the vector PDF draws the same).
@@ -264,15 +316,15 @@ function precise(value) { return String(Number(Number(value).toPrecision(6))); }
264
316
 
265
317
 
266
318
  /**
267
- * The outline engine a fonts handle carries as `outlines` (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws each
319
+ * The outline engine a fonts handle carries as `outlines` (RR-64): `toSvg(deck, { fonts, text: "paths" })` draws each
268
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.
269
321
  */
270
322
  export function textOutlines(fonts) {
271
323
  return Object.freeze({
272
324
  outlineSlideText: (content, options) => {
273
325
  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 });
326
+ if (!library) throw options.fail("text-as-paths-needs-fonts", 'text: "paths" needs font faces to outline: pass the handle loadFonts() returns as `fonts`.');
327
+ return outlineSlideText(content, { ...options, library, shaper: fonts.shaper });
276
328
  },
277
329
  });
278
330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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.17.0",
142
+ "@openpresentation/opf": "^0.18.1",
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",