@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/README.md +80 -64
- package/dist/deck-runtime.js +4 -4
- package/dist/drawn-faces.js +38 -0
- package/dist/element.d.ts +12 -9
- package/dist/element.js +33 -18
- package/dist/export-browser.d.ts +21 -11
- package/dist/export-browser.js +33 -20
- package/dist/font-fstype.js +70 -0
- package/dist/font-subset.js +173 -0
- package/dist/fonts-browser.d.ts +33 -22
- package/dist/fonts-browser.js +43 -15
- package/dist/fonts-node.d.ts +9 -9
- package/dist/fonts-node.js +26 -4
- package/dist/fonts.d.ts +10 -6
- package/dist/hb-shape.js +121 -0
- package/dist/index.d.ts +66 -33
- package/dist/index.js +1 -1
- package/dist/lazy-fonts.js +10 -10
- package/dist/pdf-fonts.js +1 -1
- package/dist/pdf-text.js +6 -3
- package/dist/pdf-vector.js +5 -2
- package/dist/pdf.d.ts +2 -2
- package/dist/pdf.js +3 -3
- package/dist/player.d.ts +3 -3
- package/dist/player.js +1 -1
- package/dist/png.d.ts +2 -2
- package/dist/png.js +2 -2
- package/dist/raster.js +33 -28
- package/dist/script-font-pack.js +5 -5
- package/dist/slide-sources.js +47 -0
- package/dist/svg.d.ts +39 -32
- package/dist/svg.js +74 -58
- package/dist/text-paths.js +82 -30
- package/package.json +3 -3
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
|
|
53
|
-
/** Leave out slides marked `hidden: true
|
|
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
|
-
*
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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?:
|
|
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. `
|
|
642
|
-
//
|
|
643
|
-
// `
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
if (
|
|
648
|
-
|
|
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. `
|
|
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
|
|
677
|
-
*
|
|
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
|
|
680
|
-
|
|
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
|
|
683
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
package/dist/text-paths.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// RR-64: `
|
|
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 {
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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"
|
|
157
|
-
const
|
|
158
|
-
const groups = [];
|
|
167
|
+
const hidden = node.attrs["aria-hidden"] === "true";
|
|
168
|
+
const groups = [], outlined = [];
|
|
159
169
|
for (const run of runs) {
|
|
160
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
175
|
-
function
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
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): `
|
|
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", "
|
|
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.
|
|
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.
|
|
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",
|