@openpresentation/opf-render 0.11.8 → 0.12.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 +139 -9
- package/dist/annotations.js +45 -0
- package/dist/chart-options.js +177 -0
- package/dist/charts.js +194 -36
- package/dist/color-fonts.js +32 -0
- package/dist/deck-runtime.js +343 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +5 -0
- package/dist/element.d.ts +86 -0
- package/dist/element.js +521 -0
- package/dist/export-browser.d.ts +26 -0
- package/dist/export-browser.js +228 -0
- package/dist/font-manifest.js +158 -0
- package/dist/font-policy.js +232 -16
- package/dist/fonts.d.ts +30 -0
- package/dist/fonts.js +48 -22
- package/dist/index.d.ts +105 -2
- package/dist/pdf-fonts.js +333 -0
- package/dist/pdf-style.js +429 -0
- package/dist/pdf-subset.js +176 -0
- package/dist/pdf-text.js +338 -0
- package/dist/pdf-vector.js +1249 -0
- package/dist/pdf-writer.js +154 -0
- package/dist/pdf-xml.js +78 -0
- package/dist/player.d.ts +79 -0
- package/dist/player.js +706 -0
- package/dist/preview-fonts-cli.js +15 -0
- package/dist/preview-fonts-node.d.ts +14 -0
- package/dist/preview-fonts-node.js +86 -0
- package/dist/preview-fonts.d.ts +23 -0
- package/dist/preview-fonts.js +78 -0
- package/dist/raster-images.js +19 -6
- package/dist/raster-text.js +468 -0
- package/dist/raster.js +81 -12
- package/dist/script-font-pack.js +19 -1
- package/dist/script-fonts.js +214 -23
- package/dist/sha256.js +47 -0
- package/dist/svg.d.ts +14 -1
- package/dist/svg.js +457 -73
- package/dist/symbol-encodings.js +7 -0
- package/dist/symbol-fonts.js +96 -0
- package/package.json +84 -7
package/dist/svg.js
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
// Optional core exports are read from the namespace so an older published core
|
|
8
8
|
// still loads; resolveScriptFonts ships with core FF-18.
|
|
9
9
|
import * as opfCore from "@openpresentation/opf";
|
|
10
|
-
import { createScriptFonts } from "./script-fonts.js";
|
|
10
|
+
import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
|
|
11
11
|
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
12
|
+
import { fontPolicyFor } from "./font-policy.js";
|
|
12
13
|
import { renderCatalogChart } from "./charts.js";
|
|
14
|
+
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
13
15
|
|
|
14
16
|
export const packageName = "@openpresentation/opf-render";
|
|
15
17
|
|
|
@@ -181,6 +183,36 @@ function parseInput(input) {
|
|
|
181
183
|
throw new OPFRenderError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
// Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved
|
|
187
|
+
// to a concrete deck before composition, so the preview and the PPTX exporter see the same text, numbers, dates and
|
|
188
|
+
// images. A template previews with each unfilled variable's example; a normal deck with an unfilled required
|
|
189
|
+
// variable is refused. Decks without content variables are returned untouched. Read from the namespace so an older
|
|
190
|
+
// published core still loads.
|
|
191
|
+
function resolveTemplateInput(presentation, options) {
|
|
192
|
+
if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
|
|
193
|
+
const values = options.variables;
|
|
194
|
+
// variables: false draws the document as authored, tokens and var: references included (the template editing view).
|
|
195
|
+
if (values === false) return presentation;
|
|
196
|
+
if (values !== undefined && !isPlainObject(values)) {
|
|
197
|
+
throw new OPFRenderError("invalid-variables", "The variables option must be an object keyed by variable id.", { path: "options.variables" });
|
|
198
|
+
}
|
|
199
|
+
const template = opfCore.isTemplate(presentation);
|
|
200
|
+
if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
201
|
+
const result = opfCore.resolveVariables(presentation, values ?? {}, { examples: template });
|
|
202
|
+
const errors = result.diagnostics.filter((entry) => entry.severity === "error");
|
|
203
|
+
if (errors.length) {
|
|
204
|
+
const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
|
|
205
|
+
throw new OPFRenderError(unfilled ? "unfilled-variables" : "invalid-variables", errors[0].message, {
|
|
206
|
+
issues: errors,
|
|
207
|
+
path: errors[0].path
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
for (const entry of result.diagnostics) {
|
|
211
|
+
if (entry.code === "variable-example-used") options.onDiagnostic?.({ code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id });
|
|
212
|
+
}
|
|
213
|
+
return result.presentation;
|
|
214
|
+
}
|
|
215
|
+
|
|
184
216
|
function assertValidBoundary(presentation) {
|
|
185
217
|
const result = validatePresentation(presentation);
|
|
186
218
|
if (!result.valid) {
|
|
@@ -228,8 +260,14 @@ function defaultCatalogFor(kind) {
|
|
|
228
260
|
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
229
261
|
}
|
|
230
262
|
|
|
263
|
+
// CatalogEntry.source is one source or an ordered search path (first match wins; the
|
|
264
|
+
// default catalog is appended by the callers). An array concatenates each source's
|
|
265
|
+
// records in order, so the findById chain keeps the first match. Non-string entries are
|
|
266
|
+
// ignored. Nothing is fetched: a source resolves from options.catalogSources or, for the
|
|
267
|
+
// bundled default prefixes, from the bundled snapshot.
|
|
231
268
|
function sourceRecordsFor(kind, source, options) {
|
|
232
|
-
if (
|
|
269
|
+
if (Array.isArray(source)) return source.flatMap((entry) => sourceRecordsFor(kind, entry, options));
|
|
270
|
+
if (typeof source !== "string" || !source) return [];
|
|
233
271
|
const bySource = options.catalogSources?.[source];
|
|
234
272
|
if (bySource) return normalizeSourceRecords(bySource);
|
|
235
273
|
if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
|
|
@@ -341,6 +379,12 @@ function normalizeColor(value, fallback) {
|
|
|
341
379
|
|
|
342
380
|
/** Resolve content ColorRef via core; keep authored #RRGGBB / #RRGGBBAA casing. */
|
|
343
381
|
function resolveColorRef(value, bound, fallback) {
|
|
382
|
+
return resolveColorRefIn(value, bound.design, fallback);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// `design` supplies colorScheme, colors (roles) and variables; resolveDesign passes a
|
|
386
|
+
// partial one for the background, before the text-dependent roles exist.
|
|
387
|
+
function resolveColorRefIn(value, design, fallback) {
|
|
344
388
|
if (value == null || value === "") return fallback;
|
|
345
389
|
if (typeof value !== "string") return fallback;
|
|
346
390
|
const trimmed = value.trim();
|
|
@@ -348,9 +392,9 @@ function resolveColorRef(value, bound, fallback) {
|
|
|
348
392
|
// Packed-browser editor checks keep toolbar hex like #2563eb as authored.
|
|
349
393
|
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(trimmed)) return trimmed;
|
|
350
394
|
if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) return normalizeColor(trimmed, fallback);
|
|
351
|
-
const colors =
|
|
395
|
+
const colors = design.colors ?? {};
|
|
352
396
|
return resolveCoreColorRef(trimmed, {
|
|
353
|
-
colorScheme:
|
|
397
|
+
colorScheme: design.colorScheme ?? {},
|
|
354
398
|
roles: {
|
|
355
399
|
primary: colors.primary,
|
|
356
400
|
secondary: colors.secondary,
|
|
@@ -360,18 +404,30 @@ function resolveColorRef(value, bound, fallback) {
|
|
|
360
404
|
text: colors.text,
|
|
361
405
|
textSecondary: colors.mutedText
|
|
362
406
|
},
|
|
363
|
-
variables:
|
|
407
|
+
variables: design.variables,
|
|
364
408
|
fallback
|
|
365
409
|
});
|
|
366
410
|
}
|
|
367
411
|
|
|
368
|
-
|
|
412
|
+
// Background fills: a literal hex keeps normalizeColor's behaviour (uppercase, alpha kept);
|
|
413
|
+
// anything else is a ColorRef (var:id, scheme slot or role) resolved like table fills and run
|
|
414
|
+
// colours, falling back exactly as an unparseable literal did.
|
|
415
|
+
// Roles resolve through the colour scheme only: background, surface and text are derived from the
|
|
416
|
+
// background itself (the contrast text), so a reference must not see them.
|
|
417
|
+
function resolveBackgroundColor(value, design, fallback) {
|
|
418
|
+
const literal = normalizeColor(value, null);
|
|
419
|
+
if (literal) return literal;
|
|
420
|
+
const { primary, secondary, accent } = design.colors ?? {};
|
|
421
|
+
return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function resolveBackground(background, colorScheme, design) {
|
|
369
425
|
if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
370
426
|
if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
|
|
371
427
|
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, "#FFFFFF");
|
|
372
|
-
if (background.type === "solid") return
|
|
428
|
+
if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
|
|
373
429
|
if (background.type === "gradient") return null;
|
|
374
|
-
if (background.type === "pattern") return
|
|
430
|
+
if (background.type === "pattern") return resolveBackgroundColor(background.pattern?.backgroundColor, design, "#FFFFFF");
|
|
375
431
|
return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
376
432
|
}
|
|
377
433
|
|
|
@@ -385,6 +441,21 @@ function colorLuminance(color) {
|
|
|
385
441
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
386
442
|
}
|
|
387
443
|
|
|
444
|
+
// FF-61: WCAG 2.x contrast ratio, (L1 + 0.05) / (L2 + 0.05) with L1 the lighter relative luminance. The slide tag
|
|
445
|
+
// draws in the primary colour unless that is under 4.5:1 against the slide background; opf-pptx applies the same
|
|
446
|
+
// rule with the same arithmetic (test/tag-colour.mjs in both repositories pins the same colour pairs).
|
|
447
|
+
const TAG_MIN_CONTRAST = 4.5;
|
|
448
|
+
function contrastRatio(first, second) {
|
|
449
|
+
const a = colorLuminance(first), b = colorLuminance(second);
|
|
450
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
451
|
+
}
|
|
452
|
+
// The slide background the tag sits on: the resolved solid or pattern colour, else (gradient) the scheme light1,
|
|
453
|
+
// the colour the background rect also falls back to and opf-pptx uses for a background that is not one colour.
|
|
454
|
+
function tagFill(design) {
|
|
455
|
+
const background = design.backgroundColor ?? design.colors.background;
|
|
456
|
+
return contrastRatio(design.colors.primary, background) < TAG_MIN_CONTRAST ? design.colors.text : design.colors.primary;
|
|
457
|
+
}
|
|
458
|
+
|
|
388
459
|
function resolveDesign(presentation, slide, context, index) {
|
|
389
460
|
const deckDesign = presentation.design ?? {};
|
|
390
461
|
const slideDesign = slide.design ?? {};
|
|
@@ -411,7 +482,12 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
411
482
|
);
|
|
412
483
|
const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
|
|
413
484
|
const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
|
|
414
|
-
const
|
|
485
|
+
const primary = normalizeColor(colorScheme.primary, null) ?? colorFromScheme(colorScheme, "accent1", "#2563EB");
|
|
486
|
+
const secondary = normalizeColor(colorScheme.secondary, null) ?? colorFromScheme(colorScheme, "accent2", "#0F766E");
|
|
487
|
+
const accent = normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B");
|
|
488
|
+
const variables = presentation.variables ?? {};
|
|
489
|
+
// background/surface/text roles depend on the background itself, so a background reference sees only the scheme and the three accent roles.
|
|
490
|
+
const backgroundColor = resolveBackground(backgroundDefinition, colorScheme, { colorScheme, colors: { primary, secondary, accent }, variables });
|
|
415
491
|
const darkBackground = colorLuminance(backgroundColor ?? "#FFFFFF") < 0.179;
|
|
416
492
|
const textColor = colorFromScheme(colorScheme, darkBackground ? "light1" : "dark1", darkBackground ? "#FFFFFF" : "#111827");
|
|
417
493
|
|
|
@@ -422,7 +498,7 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
422
498
|
colorScheme,
|
|
423
499
|
fontScheme,
|
|
424
500
|
dimensions,
|
|
425
|
-
variables
|
|
501
|
+
variables,
|
|
426
502
|
background: backgroundDefinition,
|
|
427
503
|
backgroundColor,
|
|
428
504
|
colors: {
|
|
@@ -430,12 +506,13 @@ function resolveDesign(presentation, slide, context, index) {
|
|
|
430
506
|
surface: colorFromScheme(colorScheme, darkBackground ? "dark2" : "light2", darkBackground ? "#1E293B" : "#F8FAFC"),
|
|
431
507
|
text: textColor,
|
|
432
508
|
mutedText: colorFromScheme(colorScheme, darkBackground ? "light2" : "dark2", darkBackground ? "#E2E8F0" : "#334155"),
|
|
433
|
-
primary
|
|
434
|
-
secondary
|
|
435
|
-
accent
|
|
509
|
+
primary,
|
|
510
|
+
secondary,
|
|
511
|
+
accent,
|
|
436
512
|
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
437
513
|
},
|
|
438
514
|
fonts: resolveFontFamilies(fontScheme),
|
|
515
|
+
darkBackground,
|
|
439
516
|
diagnostics: fontSchemeDiagnostic ? [fontSchemeDiagnostic] : []
|
|
440
517
|
};
|
|
441
518
|
}
|
|
@@ -494,18 +571,26 @@ function reportLanguageDiagnostic(context, diagnostic) {
|
|
|
494
571
|
*/
|
|
495
572
|
function reportGlyphFallback(context, note) {
|
|
496
573
|
context.glyphFallbackDiagnostics ??= new Set();
|
|
497
|
-
const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
|
|
574
|
+
const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.placeholder ?? ""}\u0000${note.path ?? ""}`;
|
|
498
575
|
if (context.glyphFallbackDiagnostics.has(key)) return;
|
|
499
576
|
context.glyphFallbackDiagnostics.add(key);
|
|
577
|
+
// FF-45: a symbol-encoded family's note lists the codes drawn as their Unicode equivalents, or as the placeholder.
|
|
578
|
+
const symbol = Array.isArray(note.codes);
|
|
579
|
+
const codes = symbol ? note.codes.slice(0, 8).map(code => `0x${code}`).join(", ") : "";
|
|
500
580
|
context.options.onDiagnostic?.({
|
|
501
581
|
code: "font-glyph-fallback",
|
|
502
582
|
...(note.path ? { path: note.path } : {}),
|
|
503
583
|
// The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
|
|
504
|
-
message:
|
|
584
|
+
message: symbol
|
|
585
|
+
? (note.placeholder
|
|
586
|
+
? `'${note.fontFamily}' is symbol-encoded; codes such as ${codes} have no Unicode equivalent that a loaded face draws, so the preview draws the placeholder '${note.placeholder}' with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`
|
|
587
|
+
: `'${note.fontFamily}' is symbol-encoded and not bundled; the preview draws codes such as ${codes} as their Unicode equivalents with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`)
|
|
588
|
+
: `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
|
|
505
589
|
fontFamily: note.fontFamily,
|
|
506
590
|
fallbackFamily: note.fallbackFamily,
|
|
507
591
|
scripts: [...note.scripts],
|
|
508
|
-
characters: [...note.characters]
|
|
592
|
+
characters: [...note.characters],
|
|
593
|
+
...(symbol ? { codes: [...note.codes], ...(note.placeholder ? { placeholder: note.placeholder } : {}) } : {})
|
|
509
594
|
});
|
|
510
595
|
}
|
|
511
596
|
|
|
@@ -638,8 +723,15 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
638
723
|
onFallback: note => reportGlyphFallback(context, note)
|
|
639
724
|
});
|
|
640
725
|
const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
|
|
641
|
-
|
|
642
|
-
|
|
726
|
+
// fontScheme.accent (the tag and quote text) exists only with a core that resolves it; it takes the same look-alike policy.
|
|
727
|
+
// RR-17 (FF-41): a family that names its weight (Arial Black, Segoe UI Semibold and Light) keeps its own name through composition, so each run
|
|
728
|
+
// resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
|
|
729
|
+
// replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
|
|
730
|
+
for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
|
|
731
|
+
const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
732
|
+
design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
|
|
733
|
+
}
|
|
734
|
+
const geometry = composeSlide(slide, { ...design.dimensions, layout, presentation, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, darkBackground: design.darkBackground, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
|
|
643
735
|
return {
|
|
644
736
|
scriptFonts,
|
|
645
737
|
textMeasurement,
|
|
@@ -657,7 +749,7 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
657
749
|
}
|
|
658
750
|
|
|
659
751
|
export function resolvePresentation(input, options = {}) {
|
|
660
|
-
const presentation = parseInput(input);
|
|
752
|
+
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
661
753
|
if (options.validate !== false) {
|
|
662
754
|
assertValidBoundary(presentation);
|
|
663
755
|
}
|
|
@@ -706,10 +798,18 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
706
798
|
renderSlideImage(bound, options),
|
|
707
799
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
708
800
|
...renderSlideContent(bound, width, height, options),
|
|
801
|
+
// RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
|
|
802
|
+
renderFootnotes(bound, options, drawHelpers),
|
|
709
803
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
710
804
|
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
711
805
|
].filter(Boolean);
|
|
712
806
|
const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
|
|
807
|
+
// FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
|
|
808
|
+
// 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
|
|
809
|
+
// would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
|
|
810
|
+
// that draw such punctuation carry it, so other output is unchanged.
|
|
811
|
+
const drawn = content.join("");
|
|
812
|
+
const trimsPunctuation = /[ -〿-]/.test(drawn) || (script?.scriptRole === "eastAsian" && /[‘-”]/.test(drawn));
|
|
713
813
|
|
|
714
814
|
return tag(
|
|
715
815
|
"svg",
|
|
@@ -722,6 +822,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
722
822
|
height,
|
|
723
823
|
lang,
|
|
724
824
|
"xml:lang": lang,
|
|
825
|
+
style: trimsPunctuation ? "text-spacing-trim:space-all" : undefined,
|
|
725
826
|
...traceAttrs(options, bound.path)
|
|
726
827
|
},
|
|
727
828
|
`\n${children.join("\n")}\n`
|
|
@@ -737,7 +838,7 @@ function renderBackground(bound, width, height, options) {
|
|
|
737
838
|
const dx=Math.cos(angle)*50,dy=Math.sin(angle)*50;
|
|
738
839
|
const stopTags = stops.map((stop, index) => tag("stop", {
|
|
739
840
|
offset: stableNumber((stop.position ?? index / Math.max(1, stops.length - 1)) * 100) + "%",
|
|
740
|
-
"stop-color":
|
|
841
|
+
"stop-color": resolveBackgroundColor(stop.color, bound.design, bound.design.colors.background)
|
|
741
842
|
}));
|
|
742
843
|
return [
|
|
743
844
|
tag("defs", traceAttrs(options, `${bound.path}.design.background`), tag("linearGradient", {
|
|
@@ -768,13 +869,17 @@ function renderBackground(bound, width, height, options) {
|
|
|
768
869
|
return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
|
|
769
870
|
}
|
|
770
871
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
771
|
-
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=
|
|
772
|
-
//
|
|
773
|
-
// PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
872
|
+
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
|
|
873
|
+
// Without core's pattern table (an older core) five presets keep their hand-drawn approximation. diagStripe is the
|
|
874
|
+
// engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
774
875
|
const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
|
|
775
|
-
|
|
876
|
+
// RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
|
|
877
|
+
const runs=typeof opfCore.patternRuns==='function'?opfCore.patternRuns(preset):undefined;
|
|
878
|
+
const bitmap=runs?tag('path',{d:runs.map(run=>`M${run.x} ${run.y}h${run.width}v1h-${run.width}z`).join(''),fill:color,'shape-rendering':'crispEdges'}):'';
|
|
879
|
+
const legacy=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'||preset==='wdUpDiag'?tag('path',{d:'M-2 2L2 -2M0 8L8 0M6 10L10 6',stroke:color,'stroke-width':2}):preset==='pct5'?tag('circle',{cx:2,cy:2,r:.8,fill:color}):preset==='openDmnd'?stroke('M0 4L4 0L8 4L4 8Z'):preset==='wave'?stroke('M0 4C2 1 2 1 4 4S6 7 8 4'):'';
|
|
880
|
+
const mark=bitmap||legacy;
|
|
776
881
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
777
|
-
return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:
|
|
882
|
+
return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
778
883
|
}
|
|
779
884
|
return tag("rect", {
|
|
780
885
|
x: 0,
|
|
@@ -849,9 +954,12 @@ function renderSlideContent(bound, width, height, options) {
|
|
|
849
954
|
return bound.geometry.items.map(item => {
|
|
850
955
|
const frame=item.frameBox;
|
|
851
956
|
const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
|
|
852
|
-
|
|
957
|
+
// RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
|
|
958
|
+
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
|
|
853
959
|
});
|
|
854
960
|
}
|
|
961
|
+
// Draw helpers injected into src/annotations.js (captions and footnote areas).
|
|
962
|
+
const drawHelpers = { tag, renderTextBox, renderRichLines };
|
|
855
963
|
|
|
856
964
|
function reportDiagnostic(diagnostic, options) {
|
|
857
965
|
const key = `${diagnostic.code}:${diagnostic.path}:${diagnostic.reason ?? ''}`;
|
|
@@ -895,11 +1003,13 @@ function renderTextPayload(item, box, bound, options) {
|
|
|
895
1003
|
// follow design.titleAlignment only (unset is left, as in core composition).
|
|
896
1004
|
align: item.alignment ?? (item.field === "title" ? bound.design.titleAlignment ?? "left" : bound.design.contentAlignment),
|
|
897
1005
|
fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
|
|
898
|
-
|
|
1006
|
+
// fontScheme.accent draws the tag; core's textStyle (item.textStyle) already carries it, this is the fallback family.
|
|
1007
|
+
fontFamily: item.field === "title" ? bound.design.fonts.heading : item.field === "tag" ? bound.design.fonts.accent ?? bound.design.fonts.body : bound.design.fonts.body,
|
|
899
1008
|
fontWeight: item.field === "title" ? 700 : 400,
|
|
900
1009
|
// The slide tag is the eyebrow label: it draws in the primary colour (scheme accent1), as the
|
|
901
|
-
// PPTX export writes it
|
|
902
|
-
|
|
1010
|
+
// PPTX export writes it, or in the text colour when the primary is under 4.5:1 against the
|
|
1011
|
+
// slide background (FF-61). Every other text payload here uses the text colour.
|
|
1012
|
+
fill: item.field === "tag" ? tagFill(bound.design) : bound.design.colors.text,
|
|
903
1013
|
fit: item.text,
|
|
904
1014
|
textStyle: item.textStyle,
|
|
905
1015
|
diagnosticsHandled: Boolean(item.text),
|
|
@@ -909,10 +1019,13 @@ function renderTextPayload(item, box, bound, options) {
|
|
|
909
1019
|
|
|
910
1020
|
function renderList(item, box, bound, options) {
|
|
911
1021
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
912
|
-
const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement});
|
|
1022
|
+
const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement,...(item.payload?.numbering!==undefined?{numbering:item.payload.numbering}:{})});
|
|
913
1023
|
const children=[];
|
|
1024
|
+
// design.listBullet=image: core attaches the icon logo as item.bulletImage and its box (entry.bulletBox: 0.65 em, as PowerPoint draws a:buBlip). An icon that cannot be drawn keeps the glyph marker.
|
|
1025
|
+
const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
|
|
914
1026
|
for(const entry of fit.listEntries){
|
|
915
|
-
|
|
1027
|
+
if(bullet){const box=entry.bulletBox??{x:entry.marker.x,y:entry.marker.y-entry.marker.fontSize*.65,width:entry.marker.fontSize*.65,height:entry.marker.fontSize*.65};children.push(tag('image',{x:stableNumber(box.x),y:stableNumber(box.y),width:stableNumber(box.width),height:stableNumber(box.height),href:bullet,preserveAspectRatio:'xMidYMid meet','aria-hidden':'true',...(options.trace?{'data-opf-generated':'true'}:{})}));}
|
|
1028
|
+
else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),...numberMarkerStyle(entry.marker),fill:bound.design.colors.text,'aria-hidden':'true',...(entry.marker.anchor==='end'?{'text-anchor':'end'}:{})},escapeText(entry.marker.text)));
|
|
916
1029
|
const config={path:entry.textPath,align:'left',fill:bound.design.colors.text,rich:Array.isArray(entry.value),options};
|
|
917
1030
|
children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
|
|
918
1031
|
if(entry.description)children.push(renderRichLines(typeof entry.descriptionValue==='string'?[entry.descriptionValue]:entry.descriptionValue,entry.description,entry.descriptionBox,bound,{...config,path:entry.descriptionPath,rich:Array.isArray(entry.descriptionValue),fill:bound.design.colors.mutedText}));
|
|
@@ -920,7 +1033,15 @@ function renderList(item, box, bound, options) {
|
|
|
920
1033
|
return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},children.join('\n'));
|
|
921
1034
|
}
|
|
922
1035
|
|
|
923
|
-
|
|
1036
|
+
// A numbered list's marker (numbering) draws the number with the weight and slant core measured it at: PowerPoint draws an
|
|
1037
|
+
// auto-number in the first run's character formatting. Bullet markers carry no such attributes.
|
|
1038
|
+
function numberMarkerStyle(marker) {
|
|
1039
|
+
if (!marker.number) return {};
|
|
1040
|
+
return {'font-weight':marker.style.fontWeight===400?undefined:marker.style.fontWeight,'font-style':marker.style.italic?'italic':undefined};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Follows asset: references and the host imageResolver to the drawable source of an image value.
|
|
1044
|
+
function resolveImageSource(item, bound, options) {
|
|
924
1045
|
let asset = normalizeAsset(item.value);
|
|
925
1046
|
const seen = new Set();
|
|
926
1047
|
let missingReference;
|
|
@@ -933,13 +1054,168 @@ function renderImage(item, box, bound, options) {
|
|
|
933
1054
|
asset = {...normalizeAsset(bound.assets[id]),...overrides};
|
|
934
1055
|
}
|
|
935
1056
|
const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
|
|
936
|
-
|
|
1057
|
+
// An SVG data URI (base64 or text, any encoding) is drawn as an image too, as a base64 URI: an SVG used as an image never
|
|
1058
|
+
// runs scripts or loads anything outside itself, in a browser or in the PNG/PDF rasterizer.
|
|
1059
|
+
const svg = typeof source === "string" ? svgImageSource(source) : null;
|
|
1060
|
+
const drawable = svg !== null || (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source));
|
|
1061
|
+
return { asset, source: svg ?? source, drawable, missingReference };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
1065
|
+
|
|
1066
|
+
// The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
|
|
1067
|
+
// well-formed XML (and safe: no external or markup entity) with an <svg> root in the SVG namespace and an intrinsic size
|
|
1068
|
+
// (width and height, or a viewBox) - the same checks opf-pptx makes before it writes a native SVG picture over a PNG
|
|
1069
|
+
// fallback - so a document the export refuses (malformed, unsafe, no size) is the placeholder in the preview too, and the
|
|
1070
|
+
// two agree. The scripts, foreign objects and external references a valid document may carry are never run or loaded: an
|
|
1071
|
+
// SVG used as an image reaches nothing outside itself.
|
|
1072
|
+
function svgImageSource(uri) {
|
|
1073
|
+
const header = /^data:([^;,]*)((?:;[^;,]*)*),/i.exec(uri);
|
|
1074
|
+
if (!header || !/^image\/svg\+xml$/i.test(header[1])) return null;
|
|
1075
|
+
let bytes;
|
|
1076
|
+
try {
|
|
1077
|
+
const payload = uri.slice(header[0].length);
|
|
1078
|
+
bytes = /;base64/i.test(header[2]) ? Uint8Array.from(atob(payload.replace(/\s+/g, "")), char => char.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(payload));
|
|
1079
|
+
} catch { return null; }
|
|
1080
|
+
if (bytes.length > SVG_IMAGE_MAX_BYTES) return null;
|
|
1081
|
+
let text;
|
|
1082
|
+
try { text = decodeSvgText(bytes); } catch { return null; }
|
|
1083
|
+
const root = svgRootAttributes(text);
|
|
1084
|
+
if (!root || !svgHasIntrinsicSize(root)) return null;
|
|
1085
|
+
let binary = "";
|
|
1086
|
+
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
1087
|
+
return `data:image/svg+xml;base64,${btoa(binary)}`;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const SVG_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
1091
|
+
|
|
1092
|
+
// UTF-8 (with or without a BOM), UTF-16 with a BOM, or the encoding the XML declaration names; throws on bytes the encoding cannot decode.
|
|
1093
|
+
function decodeSvgText(bytes) {
|
|
1094
|
+
let start = 0, label = "utf-8";
|
|
1095
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) start = 3;
|
|
1096
|
+
else if (bytes[0] === 0xff && bytes[1] === 0xfe) { label = "utf-16le"; start = 2; }
|
|
1097
|
+
else if (bytes[0] === 0xfe && bytes[1] === 0xff) { label = "utf-16be"; start = 2; }
|
|
1098
|
+
else {
|
|
1099
|
+
const declared = /^\s*<\?xml\b[^>]*\bencoding\s*=\s*["']([A-Za-z0-9._-]+)["']/.exec(String.fromCharCode(...bytes.subarray(0, 200)))?.[1];
|
|
1100
|
+
if (declared && !/^utf-?8$/i.test(declared)) label = declared;
|
|
1101
|
+
}
|
|
1102
|
+
return new TextDecoder(label, { fatal: true }).decode(bytes.subarray(start));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const SVG_START_TAG = /<([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)((?:\s+[^\s=\/>"'<]+\s*=\s*(?:"[^"<]*"|'[^'<]*'))*)\s*(\/?)>/y;
|
|
1106
|
+
const SVG_ATTRIBUTE = /\s+([^\s=\/>"'<]+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
|
|
1107
|
+
const SVG_END_TAG = /<\/([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>/y;
|
|
1108
|
+
|
|
1109
|
+
// The root element's attributes (references expanded) of a well-formed, safe SVG document in the SVG namespace, else null.
|
|
1110
|
+
// A small XML check, not a parser: tags nest and close, attributes are quoted and unique, the one root is <svg>, text sits
|
|
1111
|
+
// inside it, and every reference is one of the five predefined entities, a numeric reference or a plain-text entity the
|
|
1112
|
+
// DOCTYPE declares. An entity that is external or contains markup, or expansions beyond the size limit, refuse the document.
|
|
1113
|
+
function svgRootAttributes(text) {
|
|
1114
|
+
const stack = [], entities = new Map();
|
|
1115
|
+
let index = 0, rootDone = false, rootAttributes = null, expansion = 0;
|
|
1116
|
+
const references = value => value.replace(/&([^;\s&<]*);?/g, (all, name) => {
|
|
1117
|
+
if (!all.endsWith(";")) throw new Error("reference");
|
|
1118
|
+
if (/^(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6})$/.test(name)) return all;
|
|
1119
|
+
if (!entities.has(name)) throw new Error("undeclared entity");
|
|
1120
|
+
const expanded = entities.get(name);
|
|
1121
|
+
if ((expansion += expanded.length) > SVG_IMAGE_MAX_BYTES) throw new Error("entity expansion");
|
|
1122
|
+
return expanded;
|
|
1123
|
+
});
|
|
1124
|
+
try {
|
|
1125
|
+
while (index < text.length) {
|
|
1126
|
+
if (text[index] !== "<") {
|
|
1127
|
+
const end = text.indexOf("<", index), chunk = text.slice(index, end < 0 ? text.length : end);
|
|
1128
|
+
index += chunk.length;
|
|
1129
|
+
if (!stack.length) { if (chunk.trim()) return null; }
|
|
1130
|
+
else { if (chunk.includes("]]>")) return null; references(chunk); }
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (text.startsWith("<!--", index)) {
|
|
1134
|
+
const end = text.indexOf("-->", index + 4);
|
|
1135
|
+
if (end < 0) return null;
|
|
1136
|
+
index = end + 3;
|
|
1137
|
+
} else if (text.startsWith("<![CDATA[", index)) {
|
|
1138
|
+
const end = text.indexOf("]]>", index + 9);
|
|
1139
|
+
if (end < 0 || !stack.length) return null;
|
|
1140
|
+
index = end + 3;
|
|
1141
|
+
} else if (text.startsWith("<?", index)) {
|
|
1142
|
+
const end = text.indexOf("?>", index + 2);
|
|
1143
|
+
if (end < 0) return null;
|
|
1144
|
+
index = end + 2;
|
|
1145
|
+
} else if (text.startsWith("<!DOCTYPE", index)) {
|
|
1146
|
+
if (rootDone || stack.length || rootAttributes) return null;
|
|
1147
|
+
const match = /^<!DOCTYPE\s+[^\[>]*(?:\[([\s\S]*?)\]\s*)?>/.exec(text.slice(index));
|
|
1148
|
+
if (!match) return null;
|
|
1149
|
+
for (const declaration of (match[1] ?? "").matchAll(/<!ENTITY\b[^>]*>/g)) {
|
|
1150
|
+
const entity = /^<!ENTITY\s+([A-Za-z_][\w.-]*)\s+(?:"([^"]*)"|'([^']*)')\s*>$/.exec(declaration[0]);
|
|
1151
|
+
const value = entity?.[2] ?? entity?.[3];
|
|
1152
|
+
if (!entity || value.includes("&") || value.includes("<")) return null;
|
|
1153
|
+
entities.set(entity[1], value);
|
|
1154
|
+
}
|
|
1155
|
+
index += match[0].length;
|
|
1156
|
+
} else if (text.startsWith("</", index)) {
|
|
1157
|
+
SVG_END_TAG.lastIndex = index;
|
|
1158
|
+
const match = SVG_END_TAG.exec(text);
|
|
1159
|
+
if (!match || stack.pop() !== match[1]) return null;
|
|
1160
|
+
index = SVG_END_TAG.lastIndex;
|
|
1161
|
+
if (!stack.length) rootDone = true;
|
|
1162
|
+
} else {
|
|
1163
|
+
SVG_START_TAG.lastIndex = index;
|
|
1164
|
+
const match = SVG_START_TAG.exec(text);
|
|
1165
|
+
if (!match) return null;
|
|
1166
|
+
index = SVG_START_TAG.lastIndex;
|
|
1167
|
+
const [, qualified, source, selfClosing] = match;
|
|
1168
|
+
const attributes = [], names = new Set();
|
|
1169
|
+
SVG_ATTRIBUTE.lastIndex = 0;
|
|
1170
|
+
for (let attribute; source && (attribute = SVG_ATTRIBUTE.exec(source));) {
|
|
1171
|
+
if (names.has(attribute[1])) return null;
|
|
1172
|
+
names.add(attribute[1]);
|
|
1173
|
+
attributes.push([attribute[1], references(attribute[2] ?? attribute[3])]);
|
|
1174
|
+
}
|
|
1175
|
+
if (!stack.length) {
|
|
1176
|
+
if (rootDone || rootAttributes) return null;
|
|
1177
|
+
const colon = qualified.indexOf(":");
|
|
1178
|
+
if (qualified.slice(colon + 1) !== "svg") return null;
|
|
1179
|
+
const namespace = colon < 0 ? "xmlns" : `xmlns:${qualified.slice(0, colon)}`;
|
|
1180
|
+
if (!attributes.some(([key, value]) => key === namespace && value === SVG_NAMESPACE)) return null;
|
|
1181
|
+
rootAttributes = Object.fromEntries(attributes);
|
|
1182
|
+
}
|
|
1183
|
+
if (selfClosing) { if (!stack.length) rootDone = true; }
|
|
1184
|
+
else stack.push(qualified);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
} catch { return null; }
|
|
1188
|
+
return stack.length || !rootAttributes ? null : rootAttributes;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// The root has an intrinsic size: width and height lengths, or a viewBox (the same rule core and opf-pptx use).
|
|
1192
|
+
function svgHasIntrinsicSize(attributes) {
|
|
1193
|
+
const length = value => { const match = value && /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|mm|cm|in|q)?\s*$/i.exec(value); return match && Number(match[1]) > 0 ? Number(match[1]) : undefined; };
|
|
1194
|
+
const box = (attributes.viewBox ?? "").trim().split(/[\s,]+/).map(Number);
|
|
1195
|
+
const view = box.length === 4 && box.every(Number.isFinite) && box[2] > 0 && box[3] > 0;
|
|
1196
|
+
return Boolean((length(attributes.width) && length(attributes.height)) || view);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// The drawable data URI of a picture bullet, or undefined (after reporting unresolved-asset once) so the glyph marker stays.
|
|
1200
|
+
function resolveBulletImage(bulletImage, bound, options) {
|
|
1201
|
+
const { drawable, source, asset, missingReference } = resolveImageSource({ value: bulletImage.source, path: bulletImage.path }, bound, options);
|
|
1202
|
+
if (drawable) return source;
|
|
1203
|
+
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
1204
|
+
const message=missingReference?`Bullet image asset ${missingReference} is missing; the preview draws the character bullet.`:'A picture bullet requires an embedded raster data URI or a host imageResolver; the preview draws the character bullet.';
|
|
1205
|
+
if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: bulletImage.path,reason });
|
|
1206
|
+
reportDiagnostic({code:'unresolved-asset',path:bulletImage.path,message,reason,source:asset.src,assetId:missingReference,description:asset.alt??asset.title??'Bullet image',placeholder:'glyph'},options);
|
|
1207
|
+
return undefined;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function renderImage(item, box, bound, options) {
|
|
1211
|
+
const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
|
|
1212
|
+
if (drawable) {
|
|
937
1213
|
return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
|
|
938
|
-
href: source, preserveAspectRatio: (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? "Image",
|
|
939
|
-
...traceAttrs(options, item.path) });
|
|
1214
|
+
href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
|
|
1215
|
+
...traceAttrs(options, item.path), ...generatedAttrs(options) });
|
|
940
1216
|
}
|
|
941
1217
|
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
942
|
-
const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster data URI or a host imageResolver.';
|
|
1218
|
+
const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster or SVG data URI, or a host imageResolver.';
|
|
943
1219
|
if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: item.path,reason });
|
|
944
1220
|
const fill = bound.design.colors.surface;
|
|
945
1221
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
@@ -981,7 +1257,7 @@ function renderImage(item, box, bound, options) {
|
|
|
981
1257
|
const size=Math.max(0,Math.min(inner.width,inner.height,24*scale)),icon=centeredBox(inner,size,size);
|
|
982
1258
|
children.push(tag('path',{d:`M ${icon.x} ${icon.y} L ${icon.x+size} ${icon.y+size} M ${icon.x+size} ${icon.y} L ${icon.x} ${icon.y+size}`,fill:'none',stroke:textColor,'stroke-width':Math.min(2*scale,size/8),'aria-hidden':'true'}));
|
|
983
1259
|
}
|
|
984
|
-
return tag("g", {...traceAttrs(options,item.path),'data-opf-asset-status':'unresolved',role:'img','aria-label':`Image unavailable: ${description}`},children.join("\n"));
|
|
1260
|
+
return tag("g", {...traceAttrs(options,item.path),...generatedAttrs(options),'data-opf-asset-status':'unresolved',role:'img','aria-label':`Image unavailable: ${description}`},children.join("\n"));
|
|
985
1261
|
}
|
|
986
1262
|
|
|
987
1263
|
function renderMedia(item, box, bound, options) {
|
|
@@ -1032,6 +1308,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1032
1308
|
const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1033
1309
|
if (invalid) throw new OPFRenderError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`, {path:part.path});
|
|
1034
1310
|
}
|
|
1311
|
+
const syntax = codeSyntax(item, layout, bound, options);
|
|
1035
1312
|
const children = [
|
|
1036
1313
|
tag("rect", {
|
|
1037
1314
|
x: box.x,
|
|
@@ -1059,7 +1336,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1059
1336
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
1060
1337
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1061
1338
|
// Code stays left to right; script runs still take their slot fonts.
|
|
1062
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
|
|
1339
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false,fontSize:part.fit.fontSize},part.role==='body'?syntax:undefined)).join('')));
|
|
1063
1340
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1064
1341
|
'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
|
|
1065
1342
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
@@ -1071,6 +1348,9 @@ function renderMetric(item, box, bound, options) {
|
|
|
1071
1348
|
const layout=item.metricLayout;
|
|
1072
1349
|
if (!layout) throw new OPFRenderError('missing-metric-layout','Metric rendering requires coordinated core metric geometry.',{path:item.path});
|
|
1073
1350
|
const children=[];
|
|
1351
|
+
// RR-07: a trend draws an arrow beside its word and colours the trend and delta text, from core's accepted geometry.
|
|
1352
|
+
const trendMark=metricTrendMark(layout,bound,options);
|
|
1353
|
+
const partFill=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color:part.role==='value'?bound.design.colors.primary:bound.design.colors.text;
|
|
1074
1354
|
for (const part of layout.parts) {
|
|
1075
1355
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1076
1356
|
if (invalid) throw new OPFRenderError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`,{path:part.path});
|
|
@@ -1086,30 +1366,37 @@ function renderMetric(item, box, bound, options) {
|
|
|
1086
1366
|
if(factor&&!line.segments.some(segment=>segment.kind==='tab'))return tag('text',{x:stableNumber(origin.x+line.width*factor),y:stableNumber(origin.baseline),'text-anchor':factor===1?'end':'middle',
|
|
1087
1367
|
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
1088
1368
|
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1089
|
-
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part
|
|
1369
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
|
|
1090
1370
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
1091
1371
|
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
1092
1372
|
},line.segments.map(segment=>segmentSpan({
|
|
1093
1373
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1094
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
1374
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
|
|
1095
1375
|
return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
|
|
1096
1376
|
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
1097
1377
|
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1098
|
-
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part
|
|
1378
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
|
|
1099
1379
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
1100
1380
|
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
1101
1381
|
},line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
|
|
1102
1382
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
1103
1383
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1104
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
1384
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
|
|
1105
1385
|
});
|
|
1106
1386
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
|
|
1107
1387
|
'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
|
|
1108
1388
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
1109
1389
|
}
|
|
1390
|
+
if (trendMark) children.push(tag('g',{role:'img','aria-label':trendMark.ariaLabel},tag('polygon',{points:trendMark.points.map(([x,y])=>`${stableNumber(x)},${stableNumber(y)}`).join(' '),fill:trendMark.color})));
|
|
1110
1391
|
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
|
|
1111
1392
|
}
|
|
1112
1393
|
|
|
1394
|
+
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend or without core support.
|
|
1395
|
+
function metricTrendMark(layout,bound,options) {
|
|
1396
|
+
if (typeof opfCore.metricTrendMark!=='function') return undefined;
|
|
1397
|
+
return opfCore.metricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1113
1400
|
function renderQuote(item, box, bound, options) {
|
|
1114
1401
|
const layout = item.quoteLayout;
|
|
1115
1402
|
if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
|
|
@@ -1142,7 +1429,7 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1142
1429
|
|
|
1143
1430
|
function renderTable(item, box, bound, options) {
|
|
1144
1431
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1145
|
-
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path});
|
|
1432
|
+
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
|
|
1146
1433
|
const children = [];
|
|
1147
1434
|
const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
|
|
1148
1435
|
const defaultEdges = [], explicitEdges = [];
|
|
@@ -1348,12 +1635,20 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1348
1635
|
if(design.watermark){
|
|
1349
1636
|
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'})));
|
|
1350
1637
|
}
|
|
1638
|
+
// Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
|
|
1639
|
+
const logo=bound.geometry.logo;
|
|
1640
|
+
if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:'Logo',imageGenerated:true}));
|
|
1351
1641
|
return pieces.join('');
|
|
1352
1642
|
}
|
|
1353
1643
|
|
|
1644
|
+
// A family name is written unquoted only when it is a valid sequence of CSS identifiers; a word that starts with a digit
|
|
1645
|
+
// ("Source Sans 3", "Noto Sans Symbols 2") or any other non-identifier character makes an unquoted name invalid CSS, which
|
|
1646
|
+
// drops the whole font-family in a browser (FF-45). Such names are single-quoted (the attribute is double-quoted).
|
|
1647
|
+
const cssIdentifierSequence = /^(?:-?(?:[A-Za-z_ -]|\\.)(?:[\w --]|\\.)*)(?: (?:-?(?:[A-Za-z_ -]|\\.)(?:[\w --]|\\.)*))*$/;
|
|
1648
|
+
const cssFamily = name => cssIdentifierSequence.test(name) && !/^(?:inherit|initial|unset|default|serif|sans-serif|monospace|cursive|fantasy|system-ui)$/i.test(name) ? name : `'${name.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
1354
1649
|
function fontStack(family, type) {
|
|
1355
1650
|
const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
|
|
1356
|
-
return `${[family].flat().join(", ")}, ${fallback}`;
|
|
1651
|
+
return `${[family].flat().map(cssFamily).join(", ")}, ${fallback}`;
|
|
1357
1652
|
}
|
|
1358
1653
|
|
|
1359
1654
|
// Unicode directional isolates (FF-19): each paragraph (text between hard line
|
|
@@ -1366,6 +1661,10 @@ const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
|
|
|
1366
1661
|
// Core owns the rule (paragraphDirection, core #134), so preview and export agree.
|
|
1367
1662
|
// Without it (published core 0.11.0) every paragraph is left to right, as in export.
|
|
1368
1663
|
const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
|
|
1664
|
+
// RR-05: authored alignment is logical for right-to-left text, so `left` is the start edge (drawn at the right of a
|
|
1665
|
+
// right-to-left line) and `right` the end edge. Core owns the rule; the fallback only serves a core without it, which
|
|
1666
|
+
// also reports no line directions, so it never flips.
|
|
1667
|
+
const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
|
|
1369
1668
|
/** Maps a source offset of the text to whether its paragraph is right to left. */
|
|
1370
1669
|
function paragraphRtl(bound, text) {
|
|
1371
1670
|
if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
|
|
@@ -1393,43 +1692,81 @@ function nestedReset(style, run) {
|
|
|
1393
1692
|
return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
|
|
1394
1693
|
}
|
|
1395
1694
|
|
|
1396
|
-
function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
|
|
1695
|
+
function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
|
|
1397
1696
|
const value = String(text ?? "");
|
|
1398
1697
|
const scripts = bound.scriptFonts;
|
|
1399
1698
|
rtl = rtl && value !== "";
|
|
1400
1699
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1401
1700
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
1402
|
-
|
|
1701
|
+
// 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.
|
|
1702
|
+
const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
|
|
1703
|
+
// FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
|
|
1704
|
+
// line is placed, one tspan per glyph at the verified symbol font's advance, so a single mapped glyph is never stretched to it.
|
|
1705
|
+
const symbols = runs.some(run => run.symbol);
|
|
1706
|
+
if (runs.length === 1 && !(symbols && placement && placement.width > 0)) {
|
|
1403
1707
|
const [run] = runs;
|
|
1404
|
-
|
|
1708
|
+
// RR-38: a replacement the policy scales (Arabic Typesetting -> Noto Naskh Arabic, 0.64) is drawn at the scaled size; the caller applies it.
|
|
1709
|
+
return { content: isolate(escapeText(run.own ? value : run.text)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type), ...(run.sizeAdjust ? { sizeAdjust: run.sizeAdjust } : {}), ...shiftOf(runs) };
|
|
1405
1710
|
}
|
|
1711
|
+
const baseSize = placement?.fontSize ?? fontSize;
|
|
1712
|
+
const adjusted = run => run.sizeAdjust && baseSize > 0 ? stableNumber(adjustedFontSize(baseSize, run.sizeAdjust)) : undefined;
|
|
1406
1713
|
const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
|
|
1407
1714
|
if (!widths) {
|
|
1408
1715
|
return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
|
|
1409
|
-
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
|
|
1716
|
+
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), "font-size": adjusted(run), style: nestedReset(style, run) }, escapeText(run.text))).join("")), ...shiftOf(runs) };
|
|
1410
1717
|
}
|
|
1718
|
+
const natural = symbols ? scripts.runWidths(runs, placement.fontSize, style, { natural: true }) : undefined;
|
|
1411
1719
|
const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
|
|
1412
1720
|
let advance = 0, offset = 0;
|
|
1413
1721
|
const content = runs.map((run, index) => {
|
|
1414
1722
|
const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
|
|
1415
1723
|
advance += width;
|
|
1416
1724
|
const start = offset;
|
|
1417
|
-
offset += run.text.length;
|
|
1725
|
+
offset += run.symbol ? run.symbol.source.length : run.text.length;
|
|
1726
|
+
// A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
|
|
1727
|
+
const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
|
|
1418
1728
|
return tag("tspan", {
|
|
1419
1729
|
x: stableNumber(placement.x + left),
|
|
1420
|
-
textLength:
|
|
1730
|
+
textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1421
1731
|
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1732
|
+
"font-size": adjusted(run),
|
|
1422
1733
|
style: run.own ? undefined : nestedReset(style, run),
|
|
1423
1734
|
...(trace ? trace(start, offset) : {})
|
|
1424
1735
|
}, isolate(escapeText(run.text)));
|
|
1425
1736
|
}).join("");
|
|
1426
|
-
return { content, positioned: true };
|
|
1737
|
+
return { content, positioned: true, ...shiftOf(runs) };
|
|
1427
1738
|
}
|
|
1428
1739
|
|
|
1429
1740
|
/** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
|
|
1430
|
-
function segmentSpan(attrs, segment, text, style, bound, type, options) {
|
|
1741
|
+
function segmentSpan(attrs, segment, text, style, bound, type, options, syntax) {
|
|
1431
1742
|
const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
|
|
1432
|
-
|
|
1743
|
+
// Syntax highlighting (RR-07): a coloured text segment becomes consecutive sibling tspans, one per run, each a
|
|
1744
|
+
// traced segment of its own source range holding one text node (the editor's caret mapping reads that), the first
|
|
1745
|
+
// keeping the accepted x and the rest flowing after it. Plain code keeps one tspan per accepted segment.
|
|
1746
|
+
const runs = syntax && segment.kind !== "tab" ? opfCore.codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
|
|
1747
|
+
if (runs?.some(run => run.kind)) {
|
|
1748
|
+
return runs.map((run, index) => {
|
|
1749
|
+
const sizeOf = line => line.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, line.sizeAdjust)) : undefined;
|
|
1750
|
+
const piece = scriptLine(text.slice(run.start - segment.start, run.end - segment.start), style, bound, type, options);
|
|
1751
|
+
const own = { ...attrs };
|
|
1752
|
+
if (index > 0) delete own.x;
|
|
1753
|
+
if (own["data-opf-text-start"] !== undefined) { own["data-opf-text-start"] = run.start; own["data-opf-text-end"] = run.end; }
|
|
1754
|
+
return tag("tspan", { ...own, fill: run.kind ? syntax.palette[run.kind] : undefined, "font-family": piece.family, "font-size": sizeOf(piece) }, piece.content);
|
|
1755
|
+
}).join("");
|
|
1756
|
+
}
|
|
1757
|
+
return tag("tspan", { ...attrs, "font-family": scripted.family, "font-size": scripted.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, scripted.sizeAdjust)) : undefined }, scripted.content);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
|
|
1761
|
+
// no language, or a core without the shared highlighter). The preview and the PPTX export read the same tables.
|
|
1762
|
+
function codeSyntax(item, layout, bound, options) {
|
|
1763
|
+
const body = layout.parts.find(part => part.role === "body");
|
|
1764
|
+
const language = typeof item.value?.language === "string" ? item.value.language : layout.parts.find(part => part.role === "language")?.text;
|
|
1765
|
+
if (!body || !language) return undefined;
|
|
1766
|
+
if (typeof opfCore.tokenizeCode !== "function") return undefined;
|
|
1767
|
+
const tokens = opfCore.tokenizeCode(body.text, language);
|
|
1768
|
+
if (!tokens.length) return undefined;
|
|
1769
|
+
return { tokens, palette: opfCore.codeSyntaxPaletteForScheme(bound.design.colorScheme) };
|
|
1433
1770
|
}
|
|
1434
1771
|
|
|
1435
1772
|
// Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
|
|
@@ -1492,8 +1829,30 @@ function renderRichTextBox(value, box, bound, config) {
|
|
|
1492
1829
|
return renderRichLines(value,fit,box,bound,config);
|
|
1493
1830
|
}
|
|
1494
1831
|
|
|
1832
|
+
// A rich line without its leading and trailing whitespace: fragments keep their order and styles, positions and widths follow the trimmed text.
|
|
1833
|
+
// Returns null when there is nothing to trim or the line holds a tab.
|
|
1834
|
+
function trimRichLineEdges(line,textMeasurement) {
|
|
1835
|
+
const fragments=line.fragments.map(fragment=>({...fragment}));
|
|
1836
|
+
if(!fragments.length||fragments.some(fragment=>fragment.kind==='tab'))return null;
|
|
1837
|
+
const width=(fragment,text)=>text?textWidthMeasurer(fragment.style,textMeasurement)(text,fragment.fontSize):0;
|
|
1838
|
+
let changed=false;
|
|
1839
|
+
while(fragments.length&&!/\S/.test(fragments[0].text)){fragments.shift();changed=true;}
|
|
1840
|
+
while(fragments.length&&!/\S/.test(fragments.at(-1).text)){fragments.pop();changed=true;}
|
|
1841
|
+
if(!fragments.length)return null;
|
|
1842
|
+
const first=fragments[0],last=fragments.at(-1);
|
|
1843
|
+
const lead=/^\s*/.exec(first.text)[0],trail=/\s*$/.exec(last.text)[0];
|
|
1844
|
+
if(lead){first.text=first.text.slice(lead.length);first.start+=lead.length;first.width-=width(first,lead);changed=true;}
|
|
1845
|
+
if(trail){last.text=last.text.slice(0,-trail.length);last.end-=trail.length;last.width-=width(last,trail);changed=true;}
|
|
1846
|
+
if(!changed)return null;
|
|
1847
|
+
let x=0;
|
|
1848
|
+
for(const fragment of fragments){fragment.x=x;x+=fragment.width;}
|
|
1849
|
+
return {...line,fragments,width:x};
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1495
1852
|
function renderRichLines(value,fit,box,bound,config) {
|
|
1496
|
-
const
|
|
1853
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1854
|
+
// Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
|
|
1855
|
+
const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
|
|
1497
1856
|
let textOffset=0;
|
|
1498
1857
|
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1499
1858
|
const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
|
|
@@ -1502,13 +1861,21 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1502
1861
|
// those estimates into visible gaps. Supplied measurements keep exact origins.
|
|
1503
1862
|
const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
|
|
1504
1863
|
const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
|
|
1505
|
-
const content=fit.richLines.map((
|
|
1864
|
+
const content=fit.richLines.map((sourceLine,lineIndex)=>{
|
|
1865
|
+
const alignment=lineAlignment(lineIndex);
|
|
1866
|
+
const sourceFirst=sourceLine.fragments[0],rtl=fit.directions?fit.directions[lineIndex]==='rtl':richRtl(sourceFirst?runOffsets[sourceFirst.runIndex]+sourceFirst.start:0);
|
|
1867
|
+
// PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line, so the glyph edge stays on the box edge (RR-05).
|
|
1868
|
+
const edgeTrim=rtl&&alignment==='right'?trimRichLineEdges(sourceLine,config.options.textMeasurement):null;
|
|
1869
|
+
const line=edgeTrim??sourceLine;
|
|
1506
1870
|
const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
|
|
1507
1871
|
const flow=naturalFlow&&!lineHasTab;
|
|
1508
1872
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1509
1873
|
const placed=fit.placement?.lines[lineIndex];
|
|
1510
|
-
|
|
1511
|
-
const
|
|
1874
|
+
// RR-38: a line with runs in a policy replacement (Arabic Typesetting) takes PowerPoint's baseline of the real font, above core's.
|
|
1875
|
+
const lineShift=bound.scriptFonts?baselineShift(...line.fragments.filter(fragment=>fragment.kind!=='tab'&&fragment.text).map(fragment=>bound.scriptFonts.plan(fragment.text,fragment.style))):0;
|
|
1876
|
+
const shiftPx=lineShift*Math.max(fit.fontSize,...line.fragments.map(fragment=>fragment.fontSize));
|
|
1877
|
+
const originX=placed?placed.x+(edgeTrim?sourceLine.width-line.width:0):box.x+offset,baseline=(placed?.baseline??box.y+line.baseline)-shiftPx;
|
|
1878
|
+
const firstFragment=line.fragments[0];
|
|
1512
1879
|
const renderFragment=(fragment,asFlow,edges={})=>{
|
|
1513
1880
|
const run=fragment.run;
|
|
1514
1881
|
// Accepted outline placement owns the horizontal advance. Geometric precision
|
|
@@ -1523,11 +1890,14 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1523
1890
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1524
1891
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1525
1892
|
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
|
|
1526
|
-
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
|
|
1893
|
+
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1527
1894
|
if(scripted.positioned)fixedAdvance=false;
|
|
1528
1895
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1529
|
-
|
|
1530
|
-
|
|
1896
|
+
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1897
|
+
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1898
|
+
const marker=fragment.kind==='marker';
|
|
1899
|
+
const rendered=tag(asFlow?'tspan':'text',{...(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(fragment.width):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,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?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1900
|
+
if(!marker&&run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1531
1901
|
return rendered;
|
|
1532
1902
|
};
|
|
1533
1903
|
if(naturalFlow&&lineHasTab) {
|
|
@@ -1544,13 +1914,14 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1544
1914
|
if(!flow)return fragments.join('\n');
|
|
1545
1915
|
const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
|
|
1546
1916
|
const first=line.fragments[0];
|
|
1547
|
-
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
|
|
1917
|
+
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline-shiftPx),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
|
|
1548
1918
|
});
|
|
1549
1919
|
let cursor=0;
|
|
1550
1920
|
const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
|
|
1551
1921
|
const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
|
|
1552
1922
|
if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
|
|
1553
1923
|
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1924
|
+
const alignment=lineAlignment(index);
|
|
1554
1925
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1555
1926
|
const placed=fit.placement?.lines[index];
|
|
1556
1927
|
return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
|
|
@@ -1577,42 +1948,50 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1577
1948
|
const totalHeight = fit.lines.length * fit.lineHeight;
|
|
1578
1949
|
const startY = config.verticalAlign === "middle"
|
|
1579
1950
|
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
|
|
1580
|
-
const
|
|
1581
|
-
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1582
|
-
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1951
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1583
1952
|
const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
|
|
1584
1953
|
const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
|
|
1585
1954
|
let cursor = 0;
|
|
1586
1955
|
const lines = fit.lines.map((line, index) => {
|
|
1956
|
+
// Logical alignment (RR-05): a right-to-left line starts at the right edge.
|
|
1957
|
+
const alignment=fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
|
|
1958
|
+
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1959
|
+
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1587
1960
|
const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1588
1961
|
// The line's paragraph decides its direction: source offsets when layout has them, else the next match.
|
|
1589
1962
|
const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
|
|
1590
|
-
const rtl=boxRtl(lineStart);
|
|
1963
|
+
const rtl=fit.directions?fit.directions[index]==='rtl':boxRtl(lineStart);
|
|
1591
1964
|
const origin=placed?.x??x-(sourceLine?.width??0)*factor;
|
|
1592
1965
|
const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
|
|
1593
|
-
let content,family,positioned=false;
|
|
1966
|
+
let content,family,positioned=false,trimWidth=0,sizeAdjust,shift=0;
|
|
1594
1967
|
if(tabs) content=sourceLine.segments.map(segment=>{
|
|
1595
1968
|
const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
|
|
1596
1969
|
const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
|
|
1597
1970
|
const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
|
|
1598
|
-
placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
|
|
1971
|
+
placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,fontSize:size,
|
|
1599
1972
|
trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
|
|
1600
1973
|
if(scripted.positioned)return scripted.content;
|
|
1601
1974
|
return tag('tspan',{
|
|
1602
1975
|
x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
|
|
1603
|
-
lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
|
|
1976
|
+
lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,'font-size':scripted.sizeAdjust?stableNumber(adjustedFontSize(size,scripted.sizeAdjust)):undefined,
|
|
1604
1977
|
...traced(segment.start,segment.end),
|
|
1605
1978
|
},scripted.content);
|
|
1606
1979
|
}).join('');
|
|
1607
|
-
else
|
|
1980
|
+
else {
|
|
1981
|
+
// PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line (the glyph edge stays on the box edge, native check
|
|
1982
|
+
// 2026-10-02), so a soft-wrapped line that keeps its trailing space, or a leading space, draws without it (RR-05).
|
|
1983
|
+
const edge=rtl&&alignment==='right'?/^(\s*)([\s\S]*?)(\s*)$/.exec(line):null,trimmed=edge&&edge[2]&&(edge[1]||edge[3])?edge:null;
|
|
1984
|
+
if(trimmed){const measureEdge=textWidthMeasurer(style,config.options.textMeasurement);trimWidth=measureEdge(trimmed[1],size)+measureEdge(trimmed[3],size);}
|
|
1985
|
+
({content,family,positioned=false,sizeAdjust,baselineShift:shift=0}=scriptLine(trimmed?trimmed[2]:line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x+trimWidth,width:placed.width-trimWidth,fontSize:size}:undefined,fontSize:size}));
|
|
1986
|
+
}
|
|
1608
1987
|
// Positioned script runs carry their own x and textLength (FF-19).
|
|
1609
1988
|
const start=tabs||positioned;
|
|
1610
1989
|
return tag("text", {
|
|
1611
|
-
x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
|
|
1990
|
+
x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber((placed?.baseline??startY + index * fit.lineHeight) - shift * size),
|
|
1612
1991
|
"text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
|
|
1613
|
-
"font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1992
|
+
"font-size": stableNumber(adjustedFontSize(size,sizeAdjust)), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1614
1993
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
|
|
1615
|
-
textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1994
|
+
textLength:!start&&placed?.width>0?stableNumber(placed.width-trimWidth):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1616
1995
|
...traceAttrs(config.options, config.path),
|
|
1617
1996
|
...(config.options.trace&&sourceLine?{'data-opf-source-start':sourceLine.start,'data-opf-source-end':sourceLine.end,'data-opf-source-next-start':sourceLine.nextStart,'data-opf-line-boundary':sourceLine.boundary}:{}),
|
|
1618
1997
|
}, content);
|
|
@@ -1681,6 +2060,11 @@ function traceAttrs(options, path) {
|
|
|
1681
2060
|
return options.trace ? { "data-opf-path": path } : {};
|
|
1682
2061
|
}
|
|
1683
2062
|
|
|
2063
|
+
// Generated pictures (the deck logo) are drawn from design fields, not authored content: editors skip them.
|
|
2064
|
+
function generatedAttrs(options) {
|
|
2065
|
+
return options.trace && options.imageGenerated ? { "data-opf-generated": "true" } : {};
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1684
2068
|
function stableNumber(value) {
|
|
1685
2069
|
if (Number.isInteger(value)) return String(value);
|
|
1686
2070
|
return Number(value).toFixed(3).replace(/\.?0+$/, "");
|
|
@@ -1706,7 +2090,7 @@ const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
|
|
|
1706
2090
|
const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
|
|
1707
2091
|
function withPolicyFeatures(name, attrs) {
|
|
1708
2092
|
if (attrs[NO_POLICY_FEATURES]) return attrs;
|
|
1709
|
-
const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
|
|
2093
|
+
const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim().replace(/^'(.*)'$/, "$1") : undefined;
|
|
1710
2094
|
const features = family && disabledFeaturesStyle(family);
|
|
1711
2095
|
return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
|
|
1712
2096
|
}
|