@openpresentation/opf-render 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/dist/deck-runtime.js +5 -1
- package/dist/drawn-faces.js +38 -0
- package/dist/element.js +21 -8
- package/dist/font-fstype.js +70 -0
- package/dist/font-registry.js +6 -3
- package/dist/font-subset.js +173 -0
- package/dist/fonts-browser.d.ts +11 -0
- package/dist/fonts-browser.js +33 -2
- package/dist/fonts-node.js +25 -0
- package/dist/fonts.d.ts +10 -0
- package/dist/hb-shape.js +121 -0
- package/dist/index.d.ts +22 -0
- package/dist/pdf-text.js +13 -7
- package/dist/pdf-vector.js +3 -0
- package/dist/script-font-pack.js +23 -16
- package/dist/svg.d.ts +22 -0
- package/dist/svg.js +93 -44
- package/dist/text-paths.js +330 -0
- package/package.json +3 -3
package/dist/hb-shape.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// RR-64 phase 2: HarfBuzz shaping for text drawn as outlines. Browsers shape with HarfBuzz, and fontkit differs from it on some
|
|
2
|
+
// scripts (Thai tone marks, Bengali and Khmer conjunct forms, Myanmar medials, Telugu offsets, Hebrew points, Japanese combining
|
|
3
|
+
// sound marks), so outlines shaped by HarfBuzz are what the browser draws. This drives `harfbuzz.wasm` of the pinned harfbuzzjs
|
|
4
|
+
// package (MIT) through HarfBuzz's C API. The module needs only five small host functions, so it is instantiated synchronously
|
|
5
|
+
// from its bytes and the renderer stays synchronous. Glyphs come back in the shape `pdf-fonts.js` `shape()` returns: left to
|
|
6
|
+
// right, `{gid, codePoints, advance, xOffset, yOffset}` in font units.
|
|
7
|
+
|
|
8
|
+
const HB_MEMORY_MODE_READONLY = 1;
|
|
9
|
+
const HB_DIRECTION = { ltr: 4, rtl: 5 };
|
|
10
|
+
const INFO_SIZE = 20, POSITION_SIZE = 20, FEATURE_SIZE = 16;
|
|
11
|
+
|
|
12
|
+
// The five host functions the module imports, bound to the instance's memory once it exists.
|
|
13
|
+
function hostImports() {
|
|
14
|
+
const state = { memory: null };
|
|
15
|
+
const imports = {
|
|
16
|
+
env: {
|
|
17
|
+
// Emscripten grows the heap through the host: grow the memory to the requested size.
|
|
18
|
+
emscripten_resize_heap: (requested) => {
|
|
19
|
+
try {
|
|
20
|
+
const missing = (requested >>> 0) - state.memory.buffer.byteLength;
|
|
21
|
+
if (missing > 0) state.memory.grow(Math.ceil(missing / 65536));
|
|
22
|
+
return 1;
|
|
23
|
+
} catch { return 0; }
|
|
24
|
+
},
|
|
25
|
+
_abort_js: () => { throw new Error("HarfBuzz aborted."); },
|
|
26
|
+
_setitimer_js: () => 0,
|
|
27
|
+
_emscripten_runtime_keepalive_clear: () => {},
|
|
28
|
+
},
|
|
29
|
+
wasi_snapshot_preview1: { proc_exit: (code) => { throw new Error(`HarfBuzz exited (${code}).`); } },
|
|
30
|
+
};
|
|
31
|
+
return { imports, state };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A synchronous HarfBuzz shaper over `harfbuzz.wasm` (its bytes or a compiled WebAssembly.Module): Node. */
|
|
35
|
+
export function createShaper(wasm) {
|
|
36
|
+
const { imports, state } = hostImports();
|
|
37
|
+
return shaperOver(new WebAssembly.Instance(wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm), imports), state);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The same shaper, instantiated asynchronously (a browser may refuse to compile a large module synchronously). */
|
|
41
|
+
export async function instantiateShaper(wasm) {
|
|
42
|
+
const { imports, state } = hostImports();
|
|
43
|
+
const result = await WebAssembly.instantiate(wasm, imports);
|
|
44
|
+
return shaperOver(result instanceof WebAssembly.Instance ? result : result.instance, state);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shaperOver(instance, state) {
|
|
48
|
+
const hb = instance.exports;
|
|
49
|
+
const memory = state.memory = hb.memory;
|
|
50
|
+
hb.__wasm_call_ctors?.();
|
|
51
|
+
const fonts = new Map();
|
|
52
|
+
const utf8 = new TextEncoder();
|
|
53
|
+
const string = (text) => {
|
|
54
|
+
const bytes = utf8.encode(text), pointer = hb.malloc(bytes.length + 1);
|
|
55
|
+
const view = new Uint8Array(memory.buffer, pointer, bytes.length + 1);
|
|
56
|
+
view.set(bytes);
|
|
57
|
+
view[bytes.length] = 0;
|
|
58
|
+
return { pointer, length: bytes.length };
|
|
59
|
+
};
|
|
60
|
+
// One HarfBuzz font per face, kept for the shaper's lifetime (faces are few and shaped again and again).
|
|
61
|
+
const fontFor = (key, data) => {
|
|
62
|
+
let font = fonts.get(key);
|
|
63
|
+
if (font) return font;
|
|
64
|
+
const pointer = hb.malloc(data.length);
|
|
65
|
+
new Uint8Array(memory.buffer, pointer, data.length).set(data);
|
|
66
|
+
const blob = hb.hb_blob_create(pointer, data.length, HB_MEMORY_MODE_READONLY, 0, 0);
|
|
67
|
+
const face = hb.hb_face_create(blob, 0);
|
|
68
|
+
font = hb.hb_font_create(face);
|
|
69
|
+
fonts.set(key, font);
|
|
70
|
+
return font;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
/** Shape `text` with the face (`key` names it, `data` is its bytes); `features` is an object of OpenType feature booleans. */
|
|
74
|
+
shape(key, data, text, { direction = "ltr", language, features } = {}) {
|
|
75
|
+
const font = fontFor(key, data);
|
|
76
|
+
const buffer = hb.hb_buffer_create();
|
|
77
|
+
const units = text.length, textPointer = hb.malloc(units * 2 + 2);
|
|
78
|
+
const view = new Uint16Array(memory.buffer, textPointer, units);
|
|
79
|
+
for (let index = 0; index < units; index++) view[index] = text.charCodeAt(index);
|
|
80
|
+
const allocated = [textPointer];
|
|
81
|
+
try {
|
|
82
|
+
hb.hb_buffer_add_utf16(buffer, textPointer, units, 0, units);
|
|
83
|
+
hb.hb_buffer_set_direction(buffer, HB_DIRECTION[direction] ?? HB_DIRECTION.ltr);
|
|
84
|
+
if (language) { const tag = string(language); allocated.push(tag.pointer); hb.hb_buffer_set_language(buffer, hb.hb_language_from_string(tag.pointer, tag.length)); }
|
|
85
|
+
hb.hb_buffer_guess_segment_properties(buffer);
|
|
86
|
+
const entries = Object.entries(features ?? {});
|
|
87
|
+
let featurePointer = 0;
|
|
88
|
+
if (entries.length) {
|
|
89
|
+
featurePointer = hb.malloc(entries.length * FEATURE_SIZE);
|
|
90
|
+
allocated.push(featurePointer);
|
|
91
|
+
entries.forEach(([tag, on], index) => { const spec = string(`${on ? "+" : "-"}${tag}`); allocated.push(spec.pointer); hb.hb_feature_from_string(spec.pointer, spec.length, featurePointer + index * FEATURE_SIZE); });
|
|
92
|
+
}
|
|
93
|
+
hb.hb_shape(font, buffer, featurePointer, entries.length);
|
|
94
|
+
const lengthPointer = hb.malloc(4);
|
|
95
|
+
allocated.push(lengthPointer);
|
|
96
|
+
const infos = hb.hb_buffer_get_glyph_infos(buffer, lengthPointer);
|
|
97
|
+
const count = new Uint32Array(memory.buffer, lengthPointer, 1)[0];
|
|
98
|
+
const positions = hb.hb_buffer_get_glyph_positions(buffer, 0);
|
|
99
|
+
const data32 = new DataView(memory.buffer);
|
|
100
|
+
const glyphs = [];
|
|
101
|
+
for (let index = 0; index < count; index++) {
|
|
102
|
+
const info = infos + index * INFO_SIZE, position = positions + index * POSITION_SIZE;
|
|
103
|
+
glyphs.push({ gid: data32.getUint32(info, true), cluster: data32.getUint32(info + 8, true), advance: data32.getInt32(position, true), xOffset: data32.getInt32(position + 8, true), yOffset: data32.getInt32(position + 12, true) });
|
|
104
|
+
}
|
|
105
|
+
// The characters of each cluster go to its first glyph in output order; later glyphs of the cluster carry none.
|
|
106
|
+
const starts = [...new Set(glyphs.map((glyph) => glyph.cluster))].sort((a, b) => a - b);
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const glyph of glyphs) {
|
|
109
|
+
if (seen.has(glyph.cluster)) { glyph.codePoints = []; continue; }
|
|
110
|
+
seen.add(glyph.cluster);
|
|
111
|
+
const end = starts[starts.indexOf(glyph.cluster) + 1] ?? units;
|
|
112
|
+
glyph.codePoints = [...text.slice(glyph.cluster, end)].map((character) => character.codePointAt(0));
|
|
113
|
+
}
|
|
114
|
+
return glyphs.map(({ gid, codePoints, advance, xOffset, yOffset }) => ({ gid, codePoints, advance, xOffset, yOffset }));
|
|
115
|
+
} finally {
|
|
116
|
+
hb.hb_buffer_destroy(buffer);
|
|
117
|
+
for (const pointer of allocated) hb.free(pointer);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,28 @@ 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 edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
|
|
68
|
+
* given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
|
|
69
|
+
* drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
|
|
70
|
+
* A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
|
|
71
|
+
* (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
|
|
72
|
+
* layout's measurement disagree on it (`"shaping"`). Use it for thumbnails, previews and portable SVG files, not for an editing
|
|
73
|
+
* surface or the vector PDF (which keeps real text from `<text>` SVG). Default false.
|
|
74
|
+
*/
|
|
75
|
+
textAsPaths?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
|
|
78
|
+
* handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
|
|
79
|
+
* 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).
|
|
80
|
+
* Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
|
|
81
|
+
* Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
|
|
82
|
+
* false for an SVG whose text will be edited outside the renderer.
|
|
83
|
+
*/
|
|
84
|
+
subsetFonts?: boolean;
|
|
63
85
|
/** `"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
86
|
glyphFallback?: "chain" | "none";
|
|
65
87
|
/** 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.
|
|
@@ -165,7 +166,7 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
|
|
|
165
166
|
for (const char of chunk.chars) { levels.push(embedding.levels[unit]); unit += char.ch.length; }
|
|
166
167
|
const mirrored = bidi.getMirroredCharactersMap(text, embedding.levels);
|
|
167
168
|
const runs = [];
|
|
168
|
-
const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join("") };
|
|
169
|
+
const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join(""), rtl: baseDirection === "rtl" || /^[]/.test(text) };
|
|
169
170
|
let current = null;
|
|
170
171
|
unit = 0;
|
|
171
172
|
chunk.chars.forEach((char, index) => {
|
|
@@ -174,9 +175,12 @@ 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
|
-
|
|
182
|
+
// `start`: the run's first character in logical order, so a reader can put runs back in reading order (RR-64).
|
|
183
|
+
current = { chunk: chunkInfo, start: index, 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
184
|
runs.push(current);
|
|
181
185
|
}
|
|
182
186
|
current.logical += char.ch;
|
|
@@ -241,7 +245,9 @@ function shapeRun(run, env) {
|
|
|
241
245
|
const size = parseFloat(style["font-size"]) || 16;
|
|
242
246
|
const direction = run.level % 2 === 1 ? "rtl" : "ltr";
|
|
243
247
|
const language = openTypeLanguage(style.lang ?? style["xml:lang"]);
|
|
244
|
-
|
|
248
|
+
// RR-64: outlined text is shaped by HarfBuzz (env.shape), the browser's shaper; the vector PDF keeps fontkit.
|
|
249
|
+
// HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
|
|
250
|
+
const glyphs = (env.shape ?? shape)(run.face, run.text, { features: featuresOf(style), language, lang: style.lang ?? style["xml:lang"], direction });
|
|
245
251
|
const scale = size / run.face.upem;
|
|
246
252
|
const letterSpacing = parseLength(style["letter-spacing"], { fontSize: size }) ?? 0;
|
|
247
253
|
const wordSpacing = parseLength(style["word-spacing"], { fontSize: size }) ?? 0;
|
package/dist/pdf-vector.js
CHANGED
|
@@ -744,6 +744,9 @@ class Converter {
|
|
|
744
744
|
run.fill = await this.paint(run.style.fill, run.style, ctx, box, "fill");
|
|
745
745
|
run.stroke = await this.paint(run.style.stroke, run.style, ctx, box, "stroke");
|
|
746
746
|
}
|
|
747
|
+
// Text with neither fill nor stroke draws nothing (an outlined slide's invisible readable layer, RR-64): it adds no font,
|
|
748
|
+
// no glyph and no structure to the PDF.
|
|
749
|
+
if (!runs.some((run) => run.fill || run.stroke)) return;
|
|
747
750
|
// Runs are drawn in visual (left to right) order. Right-to-left runs are marked /ReversedChars so extractors read
|
|
748
751
|
// their characters back to front, and a cluster whose glyph-to-Unicode map cannot give its text (a ligature, a
|
|
749
752
|
// letter split into base and dots, a mirrored bracket) carries /ActualText; a left-to-right run the map cannot
|
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,28 @@ 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 edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
|
|
68
|
+
* given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
|
|
69
|
+
* drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
|
|
70
|
+
* A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
|
|
71
|
+
* (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
|
|
72
|
+
* layout's measurement disagree on it (`"shaping"`). Use it for thumbnails, previews and portable SVG files, not for an editing
|
|
73
|
+
* surface or the vector PDF (which keeps real text from `<text>` SVG). Default false.
|
|
74
|
+
*/
|
|
75
|
+
textAsPaths?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
|
|
78
|
+
* handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
|
|
79
|
+
* 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).
|
|
80
|
+
* Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
|
|
81
|
+
* Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
|
|
82
|
+
* false for an SVG whose text will be edited outside the renderer.
|
|
83
|
+
*/
|
|
84
|
+
subsetFonts?: boolean;
|
|
63
85
|
/** `"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
86
|
glyphFallback?: "chain" | "none";
|
|
65
87
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
package/dist/svg.js
CHANGED
|
@@ -22,6 +22,8 @@ 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";
|
|
25
27
|
|
|
26
28
|
export const packageName = "@openpresentation/opf-render";
|
|
27
29
|
|
|
@@ -540,7 +542,7 @@ function resolveContext(presentation, index, context) {
|
|
|
540
542
|
}
|
|
541
543
|
}
|
|
542
544
|
|
|
543
|
-
function bindSlide(presentation,
|
|
545
|
+
function bindSlide(presentation, index, context) {
|
|
544
546
|
const slidePath = `slides.${index}`;
|
|
545
547
|
// Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
|
|
546
548
|
// (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
|
|
@@ -548,6 +550,9 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
548
550
|
// `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
|
|
549
551
|
// or the render fails under `strictReferences`.
|
|
550
552
|
const slideContext = resolveContext(presentation, index, context);
|
|
553
|
+
// FA-31: every string the slide draws (titles, body text, table cells, notes) is the substituted slide: {{slide.number}}, {{slide.section}} and
|
|
554
|
+
// {{deck.slideCount}} hold this slide's number, its section and the deck's slide count, the same values the furniture draws. Core resolved them.
|
|
555
|
+
const slide = slideContext.slide;
|
|
551
556
|
const layout = slideContext.options.layout;
|
|
552
557
|
const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
|
|
553
558
|
const titleBindings = [];
|
|
@@ -637,9 +642,15 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
637
642
|
// `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
|
|
638
643
|
// `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
|
|
639
644
|
// measurement and drops the faces, for a host whose page already holds them.
|
|
645
|
+
// `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
|
|
646
|
+
// `subsetFonts: false` (RR-65) embeds whole faces; by default a handle with a subset engine (`fonts.subsets`) cuts each embedded
|
|
647
|
+
// face to the characters the slide draws.
|
|
640
648
|
function normalizeOptions(options) {
|
|
641
|
-
const { fonts, embedFonts, ...rest } = options ?? {};
|
|
642
|
-
|
|
649
|
+
const { fonts, embedFonts, textAsPaths, subsetFonts, ...rest } = options ?? {};
|
|
650
|
+
if (subsetFonts !== undefined && typeof subsetFonts !== "boolean") throw new OPFRenderError("invalid-render-options", "subsetFonts must be true or false.", { option: "subsetFonts" });
|
|
651
|
+
if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
|
|
652
|
+
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" });
|
|
653
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}), ...(subsetFonts !== false && fonts?.subsets ? { subsets: fonts.subsets } : {}) };
|
|
643
654
|
}
|
|
644
655
|
|
|
645
656
|
/**
|
|
@@ -657,7 +668,7 @@ function resolveDeck(input, options) {
|
|
|
657
668
|
}
|
|
658
669
|
|
|
659
670
|
const context = { presentation, options };
|
|
660
|
-
const slides = presentation.slides.map((
|
|
671
|
+
const slides = presentation.slides.map((_, index) => bindSlide(presentation, index, context));
|
|
661
672
|
|
|
662
673
|
return {
|
|
663
674
|
presentation,
|
|
@@ -700,7 +711,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
700
711
|
const { width, height } = bound.design.dimensions;
|
|
701
712
|
// The title may be TextRun[] (FA-10): the accessible name is its plain text.
|
|
702
713
|
const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
703
|
-
|
|
714
|
+
let content = [
|
|
704
715
|
renderBackground(bound, width, height, options),
|
|
705
716
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
706
717
|
...renderSlideContent(bound, width, height, options),
|
|
@@ -709,7 +720,14 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
709
720
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
710
721
|
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
711
722
|
].filter(Boolean);
|
|
712
|
-
|
|
723
|
+
// RR-64: text as glyph outlines. The used-face rule below then embeds only the faces of text that stays text.
|
|
724
|
+
let glyphs = "";
|
|
725
|
+
if (options.outlines) {
|
|
726
|
+
const outlined = options.outlines.outlineSlideText(content, { rootStyle: lang ? { lang } : {}, report: diagnostic => reportDiagnostic(diagnostic, options), fail: (code, message) => new OPFRenderError(code, message, { slideIndex }) });
|
|
727
|
+
content = outlined.content;
|
|
728
|
+
glyphs = outlined.defs;
|
|
729
|
+
}
|
|
730
|
+
const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content, options.subsets, diagnostic => reportDiagnostic(diagnostic, options))), ...content].filter(Boolean);
|
|
713
731
|
// FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
|
|
714
732
|
// 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
|
|
715
733
|
// would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
|
|
@@ -1732,12 +1750,30 @@ function nestedReset(style, run) {
|
|
|
1732
1750
|
return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
|
|
1733
1751
|
}
|
|
1734
1752
|
|
|
1735
|
-
|
|
1753
|
+
// 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
|
|
1754
|
+
// 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
|
|
1755
|
+
// 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
|
|
1756
|
+
// Arabic/Latin boundary the gap closed up (PowerPoint keeps it). A positioned run of a right-to-left line therefore draws without its
|
|
1757
|
+
// trailing white space, its glyphs starting after that white space's measured advance. Returns the white space ("" when there is none,
|
|
1758
|
+
// or when the text is all white space).
|
|
1759
|
+
function rtlTrailingSpace(text) {
|
|
1760
|
+
const found = /\S(\s+)$/u.exec(text);
|
|
1761
|
+
return found ? found[1] : "";
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* `runPlacement` ({x, width, fontSize}) positions the runs like `placement`, but only when the text plans into more than one run
|
|
1766
|
+
* (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
|
|
1767
|
+
* chunk with the outer face first and, when a fallback face covers every character, replaces every glyph with that face's (the Latin
|
|
1768
|
+
* phrase of an Arabic item took a wider fallback face and ran into the bullet).
|
|
1769
|
+
*/
|
|
1770
|
+
function scriptLine(text, style, bound, type, { rtl = false, placement, runPlacement, trace, fontSize } = {}) {
|
|
1736
1771
|
const value = String(text ?? "");
|
|
1737
1772
|
const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
|
|
1738
1773
|
rtl = rtl && value !== "";
|
|
1739
1774
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1740
1775
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
1776
|
+
if (!placement && runPlacement && runs.length > 1) placement = runPlacement;
|
|
1741
1777
|
// 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
1778
|
const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
|
|
1743
1779
|
// FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
|
|
@@ -1763,16 +1799,20 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, f
|
|
|
1763
1799
|
advance += width;
|
|
1764
1800
|
const start = offset;
|
|
1765
1801
|
offset += run.symbol ? run.symbol.source.length : run.text.length;
|
|
1802
|
+
// RR-59: a right-to-left run's trailing white space sits at its left edge (see rtlTrailingSpace); the glyphs draw after it.
|
|
1803
|
+
const trail = rtl && !run.symbol ? rtlTrailingSpace(run.text) : "";
|
|
1804
|
+
const drawn = trail ? run.text.slice(0, -trail.length) : run.text;
|
|
1805
|
+
const gap = trail ? Math.min(width, Math.max(0, width - scripts.runWidths([{ ...run, text: drawn }], placement.fontSize, style)[0] * factor)) : 0;
|
|
1766
1806
|
// 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;
|
|
1807
|
+
const pinned = run.symbol ? natural[index] > width + 1e-6 : width - gap > 0;
|
|
1768
1808
|
return tag("tspan", {
|
|
1769
|
-
x: stableNumber(placement.x + left),
|
|
1770
|
-
textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1809
|
+
x: stableNumber(placement.x + left + gap),
|
|
1810
|
+
textLength: pinned ? stableNumber(width - gap) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1771
1811
|
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1772
1812
|
"font-size": adjusted(run),
|
|
1773
1813
|
style: run.own ? undefined : nestedReset(style, run),
|
|
1774
|
-
...(trace ? trace(start, offset) : {})
|
|
1775
|
-
}, isolate(escapeText(
|
|
1814
|
+
...(trace ? trace(start, offset - trail.length) : {})
|
|
1815
|
+
}, isolate(escapeText(drawn)));
|
|
1776
1816
|
}).join("");
|
|
1777
1817
|
return { content, positioned: true, ...shiftOf(runs) };
|
|
1778
1818
|
}
|
|
@@ -1826,10 +1866,11 @@ function codeSyntax(item, layout, bound, options) {
|
|
|
1826
1866
|
// the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
|
|
1827
1867
|
// embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
|
|
1828
1868
|
// a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
|
|
1829
|
-
function embeddedFontsFor(fonts = [], content) {
|
|
1869
|
+
function embeddedFontsFor(fonts = [], content, subsets, report = () => {}) {
|
|
1830
1870
|
if (!fonts.length) return fonts;
|
|
1831
1871
|
fonts.forEach(assertEmbeddableFont);
|
|
1832
|
-
const
|
|
1872
|
+
const characters = subsets ? new Map() : undefined;
|
|
1873
|
+
const drawn = drawnFaces(content.join("\n"), characters);
|
|
1833
1874
|
const wanted = font => {
|
|
1834
1875
|
const triples = drawn.get(String(font.family).toLowerCase());
|
|
1835
1876
|
if (!triples) return false;
|
|
@@ -1839,33 +1880,28 @@ function embeddedFontsFor(fonts = [], content) {
|
|
|
1839
1880
|
return matching.length ? matching.includes(font) : true;
|
|
1840
1881
|
});
|
|
1841
1882
|
};
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
text: top.text || name === "text",
|
|
1865
|
-
};
|
|
1866
|
-
if (!selfClose) stack.push(next);
|
|
1867
|
-
}
|
|
1868
|
-
return drawn;
|
|
1883
|
+
const drawnOrAlways = fonts.filter(font => font?.embed === "always" || wanted(font));
|
|
1884
|
+
const used = drawnOrAlways.filter(embeddable);
|
|
1885
|
+
const refused = drawnOrAlways.filter(font => !embeddable(font));
|
|
1886
|
+
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 })),
|
|
1887
|
+
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.` });
|
|
1888
|
+
// RR-65: each face cut to the characters the slide draws in its family (the handle's subset engine decides whether the face
|
|
1889
|
+
// may be subset at all). A face no text draws (embed: "always") stays whole.
|
|
1890
|
+
if (!subsets) return used;
|
|
1891
|
+
return used.map(font => {
|
|
1892
|
+
const points = characters.get(String(font.family).toLowerCase());
|
|
1893
|
+
return points?.size ? { ...font, dataUrl: subsets.subsetDataUrl(font, points) } : font;
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// RR-76: a face whose OS/2 fsType forbids embedding (restricted, or bitmap-only: an SVG carries outlines) is never written as
|
|
1898
|
+
// @font-face data. The SVG still names its family, so a viewer without the font draws the generic family; a diagnostic says
|
|
1899
|
+
// why. The check reads only the font's table directory and its fsType bytes, once per face object and data URL.
|
|
1900
|
+
const embeddingChecked = new WeakMap();
|
|
1901
|
+
function embeddable(font) {
|
|
1902
|
+
let checked = embeddingChecked.get(font);
|
|
1903
|
+
if (!checked || checked.dataUrl !== font.dataUrl) { const fsType = dataUrlFsType(font.dataUrl); checked = { dataUrl: font.dataUrl, fsType, allowed: embeddingAllowed(fsType) }; embeddingChecked.set(font, checked); }
|
|
1904
|
+
return checked.allowed;
|
|
1869
1905
|
}
|
|
1870
1906
|
|
|
1871
1907
|
// Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
|
|
@@ -1959,16 +1995,29 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1959
1995
|
? (linked ? bound.design.colors.hyperlink : config.fill)
|
|
1960
1996
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1961
1997
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1998
|
+
// RR-59 (#175): measured fragments of a line core did not place (a list entry) keep their measured origin; a mixed-script one
|
|
1999
|
+
// positions its runs (scriptLine runPlacement) as a placed line does.
|
|
2000
|
+
const measuredLine=!asFlow&&!naturalFlow&&fragment.kind!=='tab'&&fragment.width>0;
|
|
2001
|
+
// A right-to-left fragment draws without its trailing white space, after that white space's measured advance (rtlTrailingSpace).
|
|
2002
|
+
const trail=rtl&&measuredLine&&fragment.kind!=='marker'?rtlTrailingSpace(fragment.text):'';
|
|
2003
|
+
let gap=0;
|
|
2004
|
+
if(trail){
|
|
2005
|
+
const measure=textWidthMeasurer(fragment.style,config.options.textMeasurement),full=measure(fragment.text,fragment.fontSize);
|
|
2006
|
+
gap=full>0?Math.min(fragment.width,Math.max(0,fragment.width*(1-measure(fragment.text.slice(0,-trail.length),fragment.fontSize)/full))):0;
|
|
2007
|
+
if(!asFlow)position.x=stableNumber(originX+fragmentX+gap);
|
|
2008
|
+
}
|
|
2009
|
+
const drawnText=trail?fragment.text.slice(0,-trail.length):fragment.text,drawnWidth=fragment.width-gap;
|
|
1962
2010
|
// FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
|
|
1963
2011
|
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:
|
|
2012
|
+
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,
|
|
2013
|
+
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,
|
|
2014
|
+
runPlacement:measuredLine&&!fixedAdvance?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1966
2015
|
if(scripted.positioned)fixedAdvance=false;
|
|
1967
2016
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1968
2017
|
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1969
2018
|
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1970
2019
|
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(
|
|
2020
|
+
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
2021
|
if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1973
2022
|
return rendered;
|
|
1974
2023
|
};
|