@openpresentation/opf-render 0.14.0 → 0.16.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 +145 -22
- package/dist/annotations.js +1 -1
- package/dist/charts.js +8 -12
- package/dist/converters.js +41 -0
- package/dist/deck-runtime.js +6 -3
- package/dist/element.d.ts +6 -0
- package/dist/element.js +31 -8
- package/dist/export-browser.d.ts +6 -0
- package/dist/export-browser.js +16 -2
- package/dist/font-registry.js +3 -3
- package/dist/fonts-node.js +24 -5
- package/dist/fonts.d.ts +3 -3
- package/dist/index.d.ts +21 -34
- package/dist/pdf-vector.js +2 -0
- package/dist/pdf.d.ts +2 -0
- package/dist/pdf.js +4 -0
- package/dist/player.d.ts +2 -1
- package/dist/png.d.ts +2 -0
- package/dist/png.js +4 -0
- package/dist/preview-fonts-node.js +2 -2
- package/dist/raster-images.js +3 -1
- package/dist/raster.js +4 -20
- package/dist/script-font-pack.js +9 -8
- package/dist/svg.d.ts +21 -34
- package/dist/svg.js +215 -179
- package/package.json +70 -17
package/dist/svg.js
CHANGED
|
@@ -5,10 +5,12 @@ import {
|
|
|
5
5
|
resolveColorRef as resolveCoreColorRef, resolveScriptFonts, paragraphDirection, physicalAlignment, patternRuns,
|
|
6
6
|
metricTrendMark as coreMetricTrendMark, tokenizeCode, codeLineRuns, codeSyntaxPaletteForScheme, resolveChartData,
|
|
7
7
|
resolveColorRoles, defaultSlideBackground, timelineMarkerShapes, timelineTextColor, layoutWatermark,
|
|
8
|
-
codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors
|
|
8
|
+
codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors, fitImage, intrinsicImageAspect, intrinsicImageSize,
|
|
9
|
+
ENGINE_DEFAULT_THEME, ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_CHART_TYPES
|
|
9
10
|
} from "@openpresentation/opf/composition";
|
|
11
|
+
// The renderer is a library: it never imports a catalog. Hosts register theirs (`options.catalogs`, core `Catalog[]`, for
|
|
12
|
+
// example `defaultCatalog` from `@openpresentation/opf/catalog`) and core resolves every reference against them.
|
|
10
13
|
import {
|
|
11
|
-
catalogs as bundledCatalogs,
|
|
12
14
|
hasContentVariables,
|
|
13
15
|
isTemplate,
|
|
14
16
|
resolveSlideContext,
|
|
@@ -37,34 +39,13 @@ export const runtimePolicy = Object.freeze({
|
|
|
37
39
|
deterministicLocalExecution: true
|
|
38
40
|
});
|
|
39
41
|
|
|
42
|
+
// What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart
|
|
43
|
+
// without one previews as.
|
|
40
44
|
export const engineDefaults = Object.freeze({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
fontSchemes: Object.freeze({ source: "https://www.pptx.gallery/font-schemes" }),
|
|
46
|
-
languages: Object.freeze({ source: "https://www.pptx.gallery/languages" }),
|
|
47
|
-
layouts: Object.freeze({ source: "https://www.pptx.gallery/layouts" }),
|
|
48
|
-
chartTypes: Object.freeze({ source: "https://www.pptx.gallery/chart-types" }),
|
|
49
|
-
tones: Object.freeze({ source: "https://www.pptx.gallery/tones" }),
|
|
50
|
-
audiences: Object.freeze({ source: "https://www.pptx.gallery/audiences" }),
|
|
51
|
-
socialPlatforms: Object.freeze({ source: "https://www.pptx.gallery/social-platforms" })
|
|
52
|
-
}),
|
|
53
|
-
theme: "minimal",
|
|
54
|
-
colorScheme: "cool-horizon",
|
|
55
|
-
language: "english",
|
|
56
|
-
narrative: "classic-story",
|
|
57
|
-
tone: "formal",
|
|
58
|
-
audience: "executives",
|
|
59
|
-
fontScheme: Object.freeze({
|
|
60
|
-
pptx: Object.freeze({ latin: "aptos", ea: "microsoft-yahei", cs: "nirmala-ui" }),
|
|
61
|
-
google: Object.freeze({ latin: "roboto", ea: "noto-sans-sc", cs: "noto-sans" })
|
|
62
|
-
}),
|
|
63
|
-
chartTypes: Object.freeze([
|
|
64
|
-
"stacked-column",
|
|
65
|
-
"stacked-area",
|
|
66
|
-
"line-with-markers"
|
|
67
|
-
])
|
|
45
|
+
theme: ENGINE_DEFAULT_THEME,
|
|
46
|
+
colorScheme: ENGINE_DEFAULT_COLOR_SCHEME,
|
|
47
|
+
fontScheme: ENGINE_DEFAULT_FONT_SCHEME,
|
|
48
|
+
chartType: ENGINE_DEFAULT_CHART_TYPES[0]
|
|
68
49
|
});
|
|
69
50
|
|
|
70
51
|
/**
|
|
@@ -164,7 +145,6 @@ const FIELD_TYPE = Object.freeze({
|
|
|
164
145
|
});
|
|
165
146
|
|
|
166
147
|
const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
|
|
167
|
-
const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
168
148
|
|
|
169
149
|
|
|
170
150
|
function parseInput(input) {
|
|
@@ -220,8 +200,8 @@ function resolveTemplateInput(presentation, options) {
|
|
|
220
200
|
|
|
221
201
|
// The boundary check is the format check only (schema and the cross-field rules a schema cannot say): layout, accessibility
|
|
222
202
|
// and content findings are `validate`'s job, and drawing a slide reports what it cannot fit.
|
|
223
|
-
function assertValidBoundary(presentation) {
|
|
224
|
-
const report = validate(presentation, { only: ["format"] });
|
|
203
|
+
function assertValidBoundary(presentation, options) {
|
|
204
|
+
const report = validate(presentation, { only: ["format"], ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}) });
|
|
225
205
|
if (!report.valid) {
|
|
226
206
|
throw new OPFRenderError("invalid-opf", "OPF validation failed.", {
|
|
227
207
|
findings: report.findings.filter((finding) => finding.severity === "error"),
|
|
@@ -246,63 +226,6 @@ function isPlainObject(value) {
|
|
|
246
226
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
247
227
|
}
|
|
248
228
|
|
|
249
|
-
function normalizeSourceRecords(value) {
|
|
250
|
-
if (!value) return [];
|
|
251
|
-
if (Array.isArray(value)) return value;
|
|
252
|
-
if (Array.isArray(value.records)) return value.records;
|
|
253
|
-
return [];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function defaultCatalogFor(kind) {
|
|
257
|
-
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// CatalogEntry.source is one source or an ordered search path (first match wins; the
|
|
261
|
-
// default catalog is appended by the callers). An array concatenates each source's
|
|
262
|
-
// records in order, so the findById chain keeps the first match. Non-string entries are
|
|
263
|
-
// ignored. Nothing is fetched: a source resolves from options.catalogSources or, for the
|
|
264
|
-
// bundled default prefixes, from the bundled snapshot.
|
|
265
|
-
function sourceRecordsFor(kind, source, options) {
|
|
266
|
-
if (Array.isArray(source)) return source.flatMap((entry) => sourceRecordsFor(kind, entry, options));
|
|
267
|
-
if (typeof source !== "string" || !source) return [];
|
|
268
|
-
const bySource = options.catalogSources?.[source];
|
|
269
|
-
if (bySource) return normalizeSourceRecords(bySource);
|
|
270
|
-
if (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) {
|
|
271
|
-
return defaultCatalogFor(kind);
|
|
272
|
-
}
|
|
273
|
-
return [];
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// The records a host adds to core's `resolveSlideContext`, which looks a layout, theme, colour scheme or font scheme up in the
|
|
277
|
-
// deck's inline records, then these, then its bundled catalogs. In the renderer's order they are the records a
|
|
278
|
-
// `catalogs.<kind>.source` names (a bundled source, or `options.catalogSources`), then `options.catalogs`.
|
|
279
|
-
const CONTEXT_CATALOG_KINDS = ["themes", "colorSchemes", "fontSchemes", "layouts"];
|
|
280
|
-
function contextCatalogs(context) {
|
|
281
|
-
if (context.contextCatalogs) return context.contextCatalogs;
|
|
282
|
-
const out = {};
|
|
283
|
-
for (const kind of CONTEXT_CATALOG_KINDS) {
|
|
284
|
-
const records = [
|
|
285
|
-
...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
|
|
286
|
-
...normalizeSourceRecords(context.options.catalogs?.[kind]),
|
|
287
|
-
...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options)
|
|
288
|
-
];
|
|
289
|
-
if (records.length) out[kind] = records;
|
|
290
|
-
}
|
|
291
|
-
return (context.contextCatalogs = out);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Generated socials furniture formats handles in core. Core applies inline
|
|
295
|
-
// document records first; the host supplies the rest in resolution order, ahead of the bundled ones.
|
|
296
|
-
function socialPlatformRecords(context, bundled) {
|
|
297
|
-
const kind = "socialPlatforms";
|
|
298
|
-
return [
|
|
299
|
-
...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
|
|
300
|
-
...normalizeSourceRecords(context.options.catalogs?.[kind]),
|
|
301
|
-
...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
|
|
302
|
-
...bundled
|
|
303
|
-
];
|
|
304
|
-
}
|
|
305
|
-
|
|
306
229
|
function colorFromScheme(scheme, slot, fallback) {
|
|
307
230
|
if (!slot) return fallback;
|
|
308
231
|
if (typeof slot === "string") {
|
|
@@ -368,12 +291,15 @@ function resolveBackgroundColor(value, design, fallback) {
|
|
|
368
291
|
return resolveColorRefIn(value, { colorScheme: design.colorScheme, colors: { primary, secondary, accent }, variables: design.variables }, fallback);
|
|
369
292
|
}
|
|
370
293
|
|
|
294
|
+
// Image sources the background string shorthand accepts (a cover image); any other string is a theme slot or a hex colour.
|
|
295
|
+
const IMAGE_BACKGROUND_SOURCE = /^(asset:|https:\/\/|data:|\.\/|\.\.\/)/;
|
|
296
|
+
|
|
371
297
|
// The slide's single background color, or null when there is none (a gradient, or no color the engine can read).
|
|
372
298
|
// With no definition, or a picture, the scheme's default slide background (core defaultSlideBackground) is the canvas.
|
|
373
299
|
function resolveBackground(background, colorScheme, design) {
|
|
374
300
|
const canvas = defaultSlideBackground(colorScheme);
|
|
375
301
|
if (!background) return canvas;
|
|
376
|
-
if (typeof background === "string") return colorFromScheme(colorScheme, background, canvas);
|
|
302
|
+
if (typeof background === "string") return IMAGE_BACKGROUND_SOURCE.test(background) ? canvas : colorFromScheme(colorScheme, background, canvas);
|
|
377
303
|
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, canvas);
|
|
378
304
|
if (background.type === "solid") return resolveBackgroundColor(background.color, design, "#FFFFFF");
|
|
379
305
|
if (background.type === "gradient") return null;
|
|
@@ -410,10 +336,9 @@ const LINK_URL = /^(https?:|mailto:|tel:)/i;
|
|
|
410
336
|
|
|
411
337
|
// What the renderer paints with, from the records core's `resolveSlideContext` resolved (slide design, then deck design, then
|
|
412
338
|
// theme, then the engine default, per field: the one order every engine uses). The theme and the colour scheme keep their
|
|
413
|
-
// sorted keys, so the output does not depend on the order a catalog lists a field in.
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
// as render diagnostics.
|
|
339
|
+
// sorted keys, so the output does not depend on the order a catalog lists a field in. A reference that resolves nowhere never
|
|
340
|
+
// throws (unless `strictReferences`): the context falls back to core's engine defaults (ENGINE_DEFAULT_THEME, _COLOR_SCHEME,
|
|
341
|
+
// _FONT_SCHEME; a layout to automatic composition) and reports `unresolved-reference`, which is forwarded as a render diagnostic.
|
|
417
342
|
function resolveDesign(presentation, slide, slideContext) {
|
|
418
343
|
const deckDesign = presentation.design ?? {};
|
|
419
344
|
const slideDesign = slide.design ?? {};
|
|
@@ -468,7 +393,7 @@ function resolveDesign(presentation, slide, slideContext) {
|
|
|
468
393
|
*/
|
|
469
394
|
function scriptProfile(presentation, index, design, context, language) {
|
|
470
395
|
let resolved;
|
|
471
|
-
try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(language === undefined ? {} : { language }) }); }
|
|
396
|
+
try { resolved = resolveScriptFonts(presentation, { slideIndex: index, ...(context.options.catalogs !== undefined ? { catalogs: context.options.catalogs } : {}), ...(language === undefined ? {} : { language }) }); }
|
|
472
397
|
catch (error) {
|
|
473
398
|
reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
|
|
474
399
|
message: "Script fonts could not be resolved (" + (error instanceof Error ? error.message : String(error)) + "), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right." });
|
|
@@ -594,16 +519,35 @@ function normalizeFutureContent(slide, slidePath) {
|
|
|
594
519
|
});
|
|
595
520
|
}
|
|
596
521
|
|
|
522
|
+
// The core catalog options every resolution takes: the host's catalogs, unchanged, and its strict reference policy.
|
|
523
|
+
function catalogOptions(options) {
|
|
524
|
+
return { ...(options.catalogs !== undefined ? { catalogs: options.catalogs } : {}), ...(options.strictReferences === true ? { strictReferences: true } : {}) };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function resolveContext(presentation, index, context) {
|
|
528
|
+
try {
|
|
529
|
+
return resolveSlideContext(presentation, index, {
|
|
530
|
+
fonts: { textMeasurement: context.options.textMeasurement },
|
|
531
|
+
...catalogOptions(context.options),
|
|
532
|
+
date: context.options.date
|
|
533
|
+
});
|
|
534
|
+
} catch (error) {
|
|
535
|
+
// Strict references: core refuses a reference that resolves nowhere (OPFUnresolvedReferenceError) instead of falling back.
|
|
536
|
+
if (Array.isArray(error?.diagnostics) && error.diagnostics.some(entry => entry?.code === "unresolved-reference")) {
|
|
537
|
+
throw new OPFRenderError("unresolved-reference", error.message, { diagnostics: error.diagnostics, path: error.diagnostics[0].path, cause: error.message });
|
|
538
|
+
}
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
597
543
|
function bindSlide(presentation, slide, index, context) {
|
|
598
544
|
const slidePath = `slides.${index}`;
|
|
599
545
|
// Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
|
|
600
|
-
// (slide design, deck design, theme, default)
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
date: context.options.date
|
|
606
|
-
});
|
|
546
|
+
// (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
|
|
547
|
+
// registered (`options.catalogs`, passed unchanged); `fonts` carries the measurement the host loaded. A slide with no
|
|
548
|
+
// `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
|
|
549
|
+
// or the render fails under `strictReferences`.
|
|
550
|
+
const slideContext = resolveContext(presentation, index, context);
|
|
607
551
|
const layout = slideContext.options.layout;
|
|
608
552
|
const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
|
|
609
553
|
const titleBindings = [];
|
|
@@ -670,7 +614,7 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
670
614
|
const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
671
615
|
design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
|
|
672
616
|
}
|
|
673
|
-
const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement
|
|
617
|
+
const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
|
|
674
618
|
return {
|
|
675
619
|
scriptFonts,
|
|
676
620
|
scriptFontsFor,
|
|
@@ -691,10 +635,11 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
691
635
|
}
|
|
692
636
|
|
|
693
637
|
// `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
|
|
694
|
-
// `embeddedFonts` list). The renderer reads the two as plain options from here on.
|
|
638
|
+
// `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
|
|
639
|
+
// measurement and drops the faces, for a host whose page already holds them.
|
|
695
640
|
function normalizeOptions(options) {
|
|
696
|
-
const { fonts, ...rest } = options ?? {};
|
|
697
|
-
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: fonts?.embeddedFonts };
|
|
641
|
+
const { fonts, embedFonts, ...rest } = options ?? {};
|
|
642
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts };
|
|
698
643
|
}
|
|
699
644
|
|
|
700
645
|
/**
|
|
@@ -708,7 +653,7 @@ export function resolvePresentation(input, options = {}) {
|
|
|
708
653
|
function resolveDeck(input, options) {
|
|
709
654
|
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
710
655
|
if (options.validate !== false) {
|
|
711
|
-
assertValidBoundary(presentation);
|
|
656
|
+
assertValidBoundary(presentation, options);
|
|
712
657
|
}
|
|
713
658
|
|
|
714
659
|
const context = { presentation, options };
|
|
@@ -757,7 +702,6 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
757
702
|
const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
758
703
|
const content = [
|
|
759
704
|
renderBackground(bound, width, height, options),
|
|
760
|
-
renderSlideImage(bound, options),
|
|
761
705
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
762
706
|
...renderSlideContent(bound, width, height, options),
|
|
763
707
|
// RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
|
|
@@ -777,7 +721,9 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
777
721
|
"svg",
|
|
778
722
|
{
|
|
779
723
|
xmlns: "http://www.w3.org/2000/svg",
|
|
780
|
-
|
|
724
|
+
// FA-30: a labelled container, not an image: role="img" makes every text node inside presentational.
|
|
725
|
+
role: "group",
|
|
726
|
+
"aria-roledescription": "slide",
|
|
781
727
|
"aria-label": title,
|
|
782
728
|
viewBox: `0 0 ${width} ${height}`,
|
|
783
729
|
width,
|
|
@@ -793,6 +739,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
793
739
|
|
|
794
740
|
function renderBackground(bound, width, height, options) {
|
|
795
741
|
const background = bound.design.background;
|
|
742
|
+
// An image background (core SlideComposition.backgroundImage) fills the canvas and moves nothing.
|
|
743
|
+
if (bound.geometry.backgroundImage) return renderBackgroundImage(bound.geometry.backgroundImage, bound, width, height, options);
|
|
796
744
|
if (isPlainObject(background) && background.type === "gradient") {
|
|
797
745
|
const id = `opf-s${bound.index + 1}-background`;
|
|
798
746
|
const stops = Array.isArray(background.gradient?.stops) ? background.gradient.stops : [];
|
|
@@ -811,6 +759,7 @@ function renderBackground(bound, width, height, options) {
|
|
|
811
759
|
y2: `${50+dy}%`
|
|
812
760
|
}, stopTags.join(""))),
|
|
813
761
|
tag("rect", {
|
|
762
|
+
...HIDDEN,
|
|
814
763
|
x: 0,
|
|
815
764
|
y: 0,
|
|
816
765
|
width,
|
|
@@ -821,15 +770,6 @@ function renderBackground(bound, width, height, options) {
|
|
|
821
770
|
})
|
|
822
771
|
].join("\n");
|
|
823
772
|
}
|
|
824
|
-
|
|
825
|
-
if(isPlainObject(background) && background.type==='image'){
|
|
826
|
-
const imageItem={value:background.image,path:`${bound.path}.design.background.image`};
|
|
827
|
-
if(background.image.fit==='tile'){
|
|
828
|
-
const size=Math.min(width,height)/4,id=`opf-s${bound.index+1}-image-tile`;
|
|
829
|
-
return tag('g',{opacity:background.opacity??1},tag('defs',{},tag('pattern',{id,width:size,height:size,patternUnits:'userSpaceOnUse'},renderImage(imageItem,{x:0,y:0,width:size,height:size},bound,options)))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
830
|
-
}
|
|
831
|
-
return tag('g',{opacity:background.opacity??1},renderImage(imageItem,{x:0,y:0,width,height},bound,{...options,imageFit:background.image.fit??'cover'}));
|
|
832
|
-
}
|
|
833
773
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
834
774
|
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
|
|
835
775
|
// RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
|
|
@@ -837,50 +777,89 @@ function renderBackground(bound, width, height, options) {
|
|
|
837
777
|
const runs=patternRuns(preset);
|
|
838
778
|
const mark=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'}):'';
|
|
839
779
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
840
|
-
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})`}));
|
|
780
|
+
return tag('g',{...HIDDEN,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})`}));
|
|
841
781
|
}
|
|
842
782
|
return tag("rect", {
|
|
783
|
+
...HIDDEN,
|
|
843
784
|
x: 0,
|
|
844
785
|
y: 0,
|
|
845
786
|
width,
|
|
846
787
|
height,
|
|
847
|
-
opacity:
|
|
788
|
+
opacity: isPlainObject(background) && background.type !== 'image' ? background.opacity ?? 1 : 1,
|
|
848
789
|
fill: bound.design.backgroundColor ?? bound.design.colors.background,
|
|
849
790
|
...traceAttrs(options, `${bound.path}.design.background`)
|
|
850
791
|
});
|
|
851
792
|
}
|
|
852
793
|
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
// and
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
const
|
|
862
|
-
const picture =
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
794
|
+
// FA-22 image background, in the paint order of the geometry contract: the canvas colour (the colour scheme's default slide
|
|
795
|
+
// background), the picture (recolor and opacity on its pixels only, as for an image block), then the overlay (the whole canvas
|
|
796
|
+
// or an edge band). cover, contain
|
|
797
|
+
// and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
|
|
798
|
+
// reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
|
|
799
|
+
function renderBackgroundImage(background, bound, width, height, options) {
|
|
800
|
+
const canvas = tag("rect", { ...HIDDEN, x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
|
|
801
|
+
const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
|
|
802
|
+
const drawOptions = { ...options, imageDecorative: background.alt === undefined || background.alt === "" };
|
|
803
|
+
const picture = background.fit === "tile"
|
|
804
|
+
? renderTiledImage(item, background.box, bound, drawOptions)
|
|
805
|
+
: renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
|
|
806
|
+
// An unresolved source draws the ordinary placeholder at the background's opacity (as 0.14 did), without recolor or overlay.
|
|
807
|
+
if (isImagePlaceholder(picture)) return [canvas, background.opacity !== undefined ? tag("g", { opacity: background.opacity }, picture) : picture].join("\n");
|
|
808
|
+
const id = `opf-s${bound.index + 1}-background`, matrix = imageRecolorMatrix(background.recolor, bound);
|
|
809
|
+
const defs = matrix ? tag("defs", {}, tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix }))) : "";
|
|
810
|
+
const pixels = matrix || background.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: background.opacity === undefined ? undefined : preciseNumber(background.opacity) }, picture) : picture;
|
|
811
|
+
return [canvas, defs, pixels, background.overlay ? renderImageOverlay(background.overlay, bound, options) : ""].filter(Boolean).join("\n");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// tile: a pattern cell of the picture's intrinsic size, anchored at the canvas origin.
|
|
815
|
+
function renderTiledImage(item, box, bound, options) {
|
|
816
|
+
const { source, drawable } = resolveImageSource(item, bound, options);
|
|
817
|
+
if (!drawable) return renderImage(item, box, bound, options);
|
|
818
|
+
const size = intrinsicImageSize(source) ?? { width: Math.min(box.width, box.height) / 4, height: Math.min(box.width, box.height) / 4 };
|
|
819
|
+
const id = `opf-s${bound.index + 1}-background-tile`;
|
|
820
|
+
const cell = tag("image", { x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), href: source, preserveAspectRatio: "none" });
|
|
821
|
+
return tag("defs", {}, tag("pattern", { id, x: 0, y: 0, width: stableNumber(size.width), height: stableNumber(size.height), patternUnits: "userSpaceOnUse" }, cell))
|
|
822
|
+
+ tag("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: `url(#${id})`, ...imageAccessibility(normalizeAsset(item.value), options), ...traceAttrs(options, item.path) });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// FA-22 image block (core ComposedItem.image): the picture in its frame (image.box) with the block's fit and focus, clipped to
|
|
826
|
+
// the frame's shape unless it is a rectangle, with recolor and opacity on the pixels only; then the line centered on the shape
|
|
827
|
+
// outline; then the overlay (the frame's shape, or an edge band). The outlines are core's imageShape() paths, so the preview
|
|
828
|
+
// draws exactly the native preset geometry, in the paint order the export writes. A placed block (image.placement) arrives
|
|
829
|
+
// with its band as the region and needs nothing else. An unresolved source keeps the ordinary placeholder without treatments.
|
|
830
|
+
function renderImageBlock(item, bound, options) {
|
|
831
|
+
const image = item.image;
|
|
832
|
+
if (!image) throw new OPFRenderError("missing-image-geometry", "Image rendering requires a coordinated core build with image block geometry (ComposedItem.image).", { path: item.path });
|
|
833
|
+
const picture = renderImage(item, image.box, bound, { ...options, imageFit: image.fit, imageFocus: image.focus, imagePicture: image.picture });
|
|
834
|
+
const trace = options.trace && image.placement ? { "data-opf-image-placement": image.placement.edge } : {};
|
|
835
|
+
if (isImagePlaceholder(picture)) return Object.keys(trace).length ? tag("g", trace, picture) : picture;
|
|
836
|
+
const id = `opf-s${bound.index + 1}-image-${item.path.replace(/[^A-Za-z0-9]+/g, "-")}`, shape = image.shape, defs = [];
|
|
837
|
+
const masked = shape && shape.preset !== "rect";
|
|
838
|
+
if (masked) defs.push(tag("clipPath", { id: `${id}-clip` }, tag("path", { d: shape.path })));
|
|
839
|
+
const matrix = imageRecolorMatrix(image.recolor, bound);
|
|
840
|
+
if (matrix) defs.push(tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix })));
|
|
841
|
+
let pixels = matrix || image.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
|
|
842
|
+
if (masked) pixels = tag("g", { "clip-path": `url(#${id}-clip)` }, pixels);
|
|
843
|
+
const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
|
|
873
844
|
if (image.border) {
|
|
874
|
-
const paint =
|
|
875
|
-
children.push(tag(
|
|
845
|
+
const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
|
|
846
|
+
children.push(tag("path", { ...HIDDEN, d: shape?.path ?? rectanglePath(image.box), fill: "none", stroke: paint.color, "stroke-opacity": paint.alpha < 1 ? preciseNumber(paint.alpha) : undefined, "stroke-width": stableNumber(image.border.width), "stroke-linejoin": "miter", "stroke-miterlimit": 8 }));
|
|
876
847
|
}
|
|
877
|
-
if (image.overlay)
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
848
|
+
if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
|
|
849
|
+
if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
|
|
850
|
+
return tag("g", trace, children.filter(Boolean).join(""));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// One overlay (shared by image backgrounds and image blocks): its outline filled with the colour, the colour's alpha times the
|
|
854
|
+
// overlay opacity.
|
|
855
|
+
function renderImageOverlay(overlay, bound, options) {
|
|
856
|
+
const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
|
|
857
|
+
return tag("path", { ...HIDDEN, d: overlay.shape?.path ?? rectanglePath(overlay.box), fill: paint.color, "fill-opacity": preciseNumber(paint.alpha * overlay.opacity), ...(options.trace ? { "data-opf-image-overlay": overlay.path } : {}) });
|
|
882
858
|
}
|
|
883
859
|
|
|
860
|
+
// The placeholder renderImage draws for a source it cannot resolve.
|
|
861
|
+
const isImagePlaceholder = svg => svg.startsWith("<g") && svg.includes('data-opf-asset-status="unresolved"');
|
|
862
|
+
|
|
884
863
|
// Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
|
|
885
864
|
const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
|
|
886
865
|
|
|
@@ -889,18 +868,18 @@ function rectanglePath(box) {
|
|
|
889
868
|
}
|
|
890
869
|
|
|
891
870
|
// ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
|
|
892
|
-
function
|
|
871
|
+
function imagePaint(value, bound, fallback) {
|
|
893
872
|
const hex = resolveColorRef(value, bound, fallback) ?? fallback;
|
|
894
873
|
const raw = normalizeColor(hex, fallback).slice(1);
|
|
895
874
|
return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
|
|
896
875
|
}
|
|
897
876
|
|
|
898
877
|
// Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
|
|
899
|
-
function
|
|
878
|
+
function imageRecolorMatrix(recolor, bound) {
|
|
900
879
|
if (!recolor) return undefined;
|
|
901
880
|
const weights = [0.299, 0.587, 0.114];
|
|
902
881
|
const channels = recolor.type === 'duotone'
|
|
903
|
-
? [
|
|
882
|
+
? [imagePaint(recolor.dark, bound, '#000000'), imagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
|
|
904
883
|
: [[0, 0, 0], [1, 1, 1]];
|
|
905
884
|
const [dark, light] = channels;
|
|
906
885
|
const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
|
|
@@ -911,7 +890,7 @@ function renderSlideContent(bound, width, height, options) {
|
|
|
911
890
|
for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
|
|
912
891
|
return bound.geometry.items.map(item => {
|
|
913
892
|
const frame=item.frameBox;
|
|
914
|
-
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)}) : '';
|
|
893
|
+
const surface=frame ? tag('rect',{...HIDDEN,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)}) : '';
|
|
915
894
|
// RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
|
|
916
895
|
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
|
|
917
896
|
});
|
|
@@ -926,11 +905,13 @@ function reportDiagnostic(diagnostic, options) {
|
|
|
926
905
|
options.onDiagnostic?.(diagnostic);
|
|
927
906
|
}
|
|
928
907
|
|
|
929
|
-
// FA-09: Chart.alt
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
908
|
+
// FA-09, FA-27: Chart.alt and Table.alt name the drawn payload with an aria-label on a wrapping group. A chart is a picture: role="img",
|
|
909
|
+
// which also makes its marks and labels presentational. A table's alt is a summary (the cells are real text and stay readable, as in
|
|
910
|
+
// PowerPoint, where descr on a table frame hides nothing): role="group" names it without hiding its children. An empty alt marks the payload
|
|
911
|
+
// decorative (aria-hidden) for both. Without alt the SVG is unchanged.
|
|
912
|
+
function altText(drawn, alt, role) {
|
|
913
|
+
if (typeof alt !== "string" || !drawn) return drawn;
|
|
914
|
+
return alt === "" ? tag("g", { "aria-hidden": "true" }, drawn) : tag("g", { role, "aria-label": alt }, drawn);
|
|
934
915
|
}
|
|
935
916
|
|
|
936
917
|
function renderPayload(item, box, bound, options) {
|
|
@@ -938,11 +919,11 @@ function renderPayload(item, box, bound, options) {
|
|
|
938
919
|
if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
|
|
939
920
|
switch (item.type) {
|
|
940
921
|
case "chart":
|
|
941
|
-
return
|
|
922
|
+
return altText(renderChart(item, box, bound, options), item.value?.alt, "img");
|
|
942
923
|
case "table":
|
|
943
|
-
return renderTable(item, box, bound, options);
|
|
924
|
+
return altText(renderTable(item, box, bound, options), item.value?.alt, "group");
|
|
944
925
|
case "image":
|
|
945
|
-
return
|
|
926
|
+
return renderImageBlock(item, bound, options);
|
|
946
927
|
case "video":
|
|
947
928
|
return renderMedia(item, box, bound, options);
|
|
948
929
|
case "code":
|
|
@@ -1025,6 +1006,20 @@ function resolveImageSource(item, bound, options) {
|
|
|
1025
1006
|
return { asset, source: svg ?? source, drawable, missingReference };
|
|
1026
1007
|
}
|
|
1027
1008
|
|
|
1009
|
+
// FA-30: purely decorative drawing is hidden from assistive technology so the slide's text and real pictures are what a reader meets
|
|
1010
|
+
// (background, overlays, card frames, dividers, table cell fills and borders, accent shapes). SVG-AAM already leaves an unnamed shape
|
|
1011
|
+
// out of the tree; the attribute states the intent for the readers that do not.
|
|
1012
|
+
const HIDDEN = Object.freeze({ "aria-hidden": "true" });
|
|
1013
|
+
|
|
1014
|
+
// The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
|
|
1015
|
+
// without alt) is hidden from assistive technology.
|
|
1016
|
+
function imageAccessibility(asset, options) {
|
|
1017
|
+
// An empty alt marks the picture decorative (FA-30), as it does a chart; a picture that is only furniture (a header or footer
|
|
1018
|
+
// logo repeated on every slide) is decorative unless the author named it.
|
|
1019
|
+
if (options.imageDecorative || asset.alt === "" || (options.imageUnlabelled && asset.alt === undefined)) return HIDDEN;
|
|
1020
|
+
return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1028
1023
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
1029
1024
|
|
|
1030
1025
|
// The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
|
|
@@ -1171,12 +1166,29 @@ function resolveBulletImage(bulletImage, bound, options) {
|
|
|
1171
1166
|
return undefined;
|
|
1172
1167
|
}
|
|
1173
1168
|
|
|
1169
|
+
// A drawable picture in `box` with `options.imageFit`: cover (the default for content), contain or stretch. cover takes
|
|
1170
|
+
// `options.imageFocus`: centered, it is the native center crop; anywhere else the picture is placed by core's fit math
|
|
1171
|
+
// (`options.imagePicture`, core's ImageFitPlacement when core read the aspect, else fitImage on core's intrinsicImageAspect
|
|
1172
|
+
// of the resolved source, for pictures a host imageResolver supplies) and
|
|
1173
|
+
// clipped to the box by a nested viewport. A logo keeps its anchor (`options.imageAnchor`).
|
|
1174
1174
|
function renderImage(item, box, bound, options) {
|
|
1175
1175
|
const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
|
|
1176
1176
|
if (drawable) {
|
|
1177
|
+
const fit = options.imageFit ?? "contain", focus = options.imageFocus;
|
|
1178
|
+
const attrs = { href: source, ...imageAccessibility(asset, options), ...traceAttrs(options, item.path), ...generatedAttrs(options) };
|
|
1179
|
+
if (fit === "cover" && focus && !options.imageAnchor && (focus.x !== 0.5 || focus.y !== 0.5)) {
|
|
1180
|
+
// The drawable source (after asset references and the host imageResolver): core reads its size (intrinsicImageAspect).
|
|
1181
|
+
const aspect = options.imagePicture ? undefined : intrinsicImageAspect(source);
|
|
1182
|
+
const placement = options.imagePicture ?? (aspect ? fitImage(box, "cover", aspect, focus) : undefined);
|
|
1183
|
+
if (placement) {
|
|
1184
|
+
const picture = placement.image;
|
|
1185
|
+
return tag("svg", { x: box.x, y: box.y, width: box.width, height: box.height, overflow: "hidden" },
|
|
1186
|
+
tag("image", { x: stableNumber(picture.x - box.x), y: stableNumber(picture.y - box.y), width: stableNumber(picture.width), height: stableNumber(picture.height), preserveAspectRatio: "none", ...attrs }));
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1177
1189
|
return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
|
|
1178
|
-
|
|
1179
|
-
...
|
|
1190
|
+
preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : fit === "cover" ? "xMidYMid slice" : fit === "stretch" ? "none" : "xMidYMid meet",
|
|
1191
|
+
...attrs });
|
|
1180
1192
|
}
|
|
1181
1193
|
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
1182
1194
|
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.';
|
|
@@ -1229,6 +1241,7 @@ function renderMedia(item, box, bound, options) {
|
|
|
1229
1241
|
const iconBox = centeredBox(box, 72, 72);
|
|
1230
1242
|
const children = [
|
|
1231
1243
|
tag("rect", {
|
|
1244
|
+
...HIDDEN,
|
|
1232
1245
|
x: box.x,
|
|
1233
1246
|
y: box.y,
|
|
1234
1247
|
width: box.width,
|
|
@@ -1239,6 +1252,7 @@ function renderMedia(item, box, bound, options) {
|
|
|
1239
1252
|
...traceAttrs(options, item.path)
|
|
1240
1253
|
}),
|
|
1241
1254
|
tag("circle", {
|
|
1255
|
+
...HIDDEN,
|
|
1242
1256
|
cx: iconBox.x + iconBox.width / 2,
|
|
1243
1257
|
cy: iconBox.y + iconBox.height / 2,
|
|
1244
1258
|
r: 36,
|
|
@@ -1246,6 +1260,7 @@ function renderMedia(item, box, bound, options) {
|
|
|
1246
1260
|
...traceAttrs(options, item.path)
|
|
1247
1261
|
}),
|
|
1248
1262
|
tag("path", {
|
|
1263
|
+
...HIDDEN,
|
|
1249
1264
|
d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
|
|
1250
1265
|
fill: "#FFFFFF",
|
|
1251
1266
|
...traceAttrs(options, item.path)
|
|
@@ -1276,6 +1291,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1276
1291
|
const highlight = codeHighlight(item, layout, bound);
|
|
1277
1292
|
const children = [
|
|
1278
1293
|
tag("rect", {
|
|
1294
|
+
...HIDDEN,
|
|
1279
1295
|
x: box.x,
|
|
1280
1296
|
y: box.y,
|
|
1281
1297
|
width: box.width,
|
|
@@ -1292,6 +1308,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1292
1308
|
const body = layout.parts.find(part => part.role === "body");
|
|
1293
1309
|
for (const band of highlight.bands) {
|
|
1294
1310
|
children.push(tag("rect", {
|
|
1311
|
+
...HIDDEN,
|
|
1295
1312
|
x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
|
|
1296
1313
|
height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
|
|
1297
1314
|
...(options.trace ? { "data-opf-code-highlight": "true" } : {})
|
|
@@ -1398,7 +1415,7 @@ function renderQuote(item, box, bound, options) {
|
|
|
1398
1415
|
function renderQuotePhoto(photo, item, bound, options) {
|
|
1399
1416
|
const picture = renderImage({ value: photo.value, path: photo.path }, photo.box, bound, { ...options, imageFit: 'cover' });
|
|
1400
1417
|
// An unresolved source keeps the ordinary placeholder, unmasked, like the export.
|
|
1401
|
-
if (
|
|
1418
|
+
if (isImagePlaceholder(picture)) return picture;
|
|
1402
1419
|
const id = `opf-s${bound.index + 1}-quote-photo-${item.path.replace(/[^A-Za-z0-9]+/g, '-')}`;
|
|
1403
1420
|
return tag('g', {}, tag('defs', {}, tag('clipPath', { id }, tag('path', { d: photo.shape.path }))) + tag('g', { 'clip-path': `url(#${id})` }, picture));
|
|
1404
1421
|
}
|
|
@@ -1407,16 +1424,16 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1407
1424
|
const layout=item.timelineLayout;
|
|
1408
1425
|
if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
|
|
1409
1426
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1410
|
-
const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
1427
|
+
const children=[tag('line',{...HIDDEN,...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
1411
1428
|
// FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
|
|
1412
1429
|
const background=bound.design.backgroundColor??bound.design.colors.background;
|
|
1413
1430
|
const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
|
|
1414
1431
|
for(const marker of layout.markers){
|
|
1415
1432
|
if(!marker.status){
|
|
1416
|
-
children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
1433
|
+
children.push(tag('circle',{...HIDDEN,cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
1417
1434
|
continue;
|
|
1418
1435
|
}
|
|
1419
|
-
for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
|
|
1436
|
+
for(const shape of timelineMarkerShapes(marker,statusColors))children.push(tag('circle',{...HIDDEN,cx:shape.cx,cy:shape.cy,r:shape.radius,fill:shape.fill??'none',...(shape.stroke?{stroke:shape.stroke.color,'stroke-width':shape.stroke.width}:{}),...traceAttrs(options,marker.path),...(options.trace?{'data-opf-timeline-status':marker.status,'data-opf-timeline-shape':shape.role}:{})}));
|
|
1420
1437
|
}
|
|
1421
1438
|
for(const part of layout.parts){
|
|
1422
1439
|
if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,diagnostics:layout.diagnostics});
|
|
@@ -1447,6 +1464,7 @@ function renderTable(item, box, bound, options) {
|
|
|
1447
1464
|
? defaultText
|
|
1448
1465
|
: resolveColorRef(style.color, bound, defaultText);
|
|
1449
1466
|
children.push(tag("rect", {
|
|
1467
|
+
...HIDDEN,
|
|
1450
1468
|
x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
|
|
1451
1469
|
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
1452
1470
|
fill,
|
|
@@ -1502,6 +1520,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
|
|
|
1502
1520
|
? defaultColor
|
|
1503
1521
|
: resolveColorRef(border.color, bound, defaultColor);
|
|
1504
1522
|
return [tag('line', {
|
|
1523
|
+
...HIDDEN,
|
|
1505
1524
|
x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
|
|
1506
1525
|
stroke, 'stroke-width':stableNumber(width),
|
|
1507
1526
|
'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
|
|
@@ -1516,7 +1535,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1516
1535
|
const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox, reportDiagnostic });
|
|
1517
1536
|
if (rendered) return rendered;
|
|
1518
1537
|
const chart = item.value ?? {};
|
|
1519
|
-
const chartType = chart.type ?? engineDefaults.
|
|
1538
|
+
const chartType = chart.type ?? engineDefaults.chartType;
|
|
1520
1539
|
const data = inlineChartRows(legacyChartData(chart, bound));
|
|
1521
1540
|
// A dataset-backed chart has no data.rows of its own: its parts report the chart's authored path.
|
|
1522
1541
|
const part = isDatasetChart(item, bound) ? () => item.path : (path) => path;
|
|
@@ -1639,7 +1658,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
|
|
|
1639
1658
|
}
|
|
1640
1659
|
return tag('g',{},layout.parts.filter(part=>part.kind===kind).map(part=>{
|
|
1641
1660
|
const trace=options.trace?{'data-opf-furniture-kind':kind,'data-opf-furniture-field':part.field,'data-opf-furniture-generated':String(part.generated),'data-opf-furniture-editable':!part.generated?'true':undefined,'data-opf-furniture-source':part.sourcePath}:{};
|
|
1642
|
-
if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain'}));
|
|
1661
|
+
if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain',imageUnlabelled:true}));
|
|
1643
1662
|
return tag('g',trace,renderTextBox(part.text,part.box,bound,{path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.style.fontFamily,align:part.alignment,fill:bound.design.colors.mutedText,diagnosticsHandled:true,options}));
|
|
1644
1663
|
}).join(''));
|
|
1645
1664
|
}
|
|
@@ -1652,12 +1671,13 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1652
1671
|
if(mark){
|
|
1653
1672
|
const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
|
|
1654
1673
|
const cx=width/2,cy=height/2;
|
|
1655
|
-
pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
|
|
1674
|
+
pieces.push(tag('g',{...HIDDEN,opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
|
|
1656
1675
|
tag('text',{x:stableNumber(cx),y:stableNumber(cy+mark.fontSize*.35),'text-anchor':'middle','font-family':fontStack(mark.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(mark.fontSize),'font-weight':mark.fontWeight,
|
|
1657
1676
|
fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
|
|
1658
1677
|
}
|
|
1659
1678
|
} else if(design.watermark){
|
|
1660
|
-
|
|
1679
|
+
// The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
|
|
1680
|
+
pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain',imageDecorative:true})));
|
|
1661
1681
|
}
|
|
1662
1682
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
|
|
1663
1683
|
const logo=bound.geometry.logo;
|
|
@@ -1800,21 +1820,26 @@ function codeSyntax(item, layout, bound, options) {
|
|
|
1800
1820
|
return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
|
|
1801
1821
|
}
|
|
1802
1822
|
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1823
|
+
// RR-61: a face is embedded only when the slide's own markup draws it: the family in a font-family list, at a font-weight and
|
|
1824
|
+
// font-style some text of the slide takes (attributes are inherited down the element tree, as in SVG). A drawn family none of
|
|
1825
|
+
// whose faces matches a drawn weight and style keeps all its faces, so the browser can always choose. A face no font-family of
|
|
1826
|
+
// 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
|
+
// 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
|
+
// a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
|
|
1807
1829
|
function embeddedFontsFor(fonts = [], content) {
|
|
1808
|
-
if (!fonts.
|
|
1830
|
+
if (!fonts.length) return fonts;
|
|
1831
|
+
fonts.forEach(assertEmbeddableFont);
|
|
1809
1832
|
const drawn = drawnFaces(content.join("\n"));
|
|
1810
1833
|
const wanted = font => {
|
|
1811
1834
|
const triples = drawn.get(String(font.family).toLowerCase());
|
|
1812
1835
|
if (!triples) return false;
|
|
1813
|
-
const family = fonts.filter(other =>
|
|
1814
|
-
|
|
1815
|
-
|
|
1836
|
+
const family = fonts.filter(other => String(other?.family).toLowerCase() === String(font.family).toLowerCase());
|
|
1837
|
+
return [...triples].some(triple => {
|
|
1838
|
+
const matching = family.filter(other => triple === `${other.weight}|${other.italic ? "italic" : "normal"}`);
|
|
1839
|
+
return matching.length ? matching.includes(font) : true;
|
|
1840
|
+
});
|
|
1816
1841
|
};
|
|
1817
|
-
return fonts.filter(font => font?.embed
|
|
1842
|
+
return fonts.filter(font => font?.embed === "always" || wanted(font));
|
|
1818
1843
|
}
|
|
1819
1844
|
|
|
1820
1845
|
const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
|
|
@@ -1843,10 +1868,21 @@ function drawnFaces(markup) {
|
|
|
1843
1868
|
return drawn;
|
|
1844
1869
|
}
|
|
1845
1870
|
|
|
1871
|
+
// Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
|
|
1872
|
+
// object while its dataUrl is unchanged: the handle's list holds the same objects on every slide, and its URIs run to tens of
|
|
1873
|
+
// megabytes.
|
|
1874
|
+
const checkedDataUrls = new WeakMap();
|
|
1875
|
+
function assertEmbeddableFont(font) {
|
|
1876
|
+
const valid = typeof font?.family === "string" && !/[\u0000-\u001f"'\\<>;]/.test(font.family) && Number.isInteger(font.weight) && font.weight >= 1 && font.weight <= 1000
|
|
1877
|
+
&& (checkedDataUrls.get(font) === font.dataUrl || /^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl));
|
|
1878
|
+
if (!valid) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
|
|
1879
|
+
checkedDataUrls.set(font, font.dataUrl);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1846
1882
|
function renderEmbeddedFonts(fonts = []) {
|
|
1847
1883
|
if (!fonts.length) return "";
|
|
1848
1884
|
const css = fonts.map(font => {
|
|
1849
|
-
|
|
1885
|
+
assertEmbeddableFont(font);
|
|
1850
1886
|
return `@font-face{font-family:"${font.family}";font-weight:${font.weight};font-style:${font.italic ? "italic" : "normal"};src:url("${font.dataUrl}")}`;
|
|
1851
1887
|
}).join("\n");
|
|
1852
1888
|
const licenses = [...new Set(fonts.map(font=>font.license).filter(Boolean))];
|