@openpresentation/opf-render 0.15.0 → 0.17.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 +129 -9
- package/dist/annotations.js +1 -1
- package/dist/converters.js +41 -0
- package/dist/deck-runtime.js +11 -4
- 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 +9 -6
- package/dist/fonts-browser.js +3 -0
- package/dist/fonts-node.js +27 -5
- package/dist/fonts.d.ts +9 -3
- package/dist/index.d.ts +13 -1
- package/dist/pdf-text.js +8 -5
- 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 +23 -16
- package/dist/svg.d.ts +13 -1
- package/dist/svg.js +133 -47
- package/dist/text-paths.js +278 -0
- package/package.json +70 -17
package/dist/svg.js
CHANGED
|
@@ -540,7 +540,7 @@ function resolveContext(presentation, index, context) {
|
|
|
540
540
|
}
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
function bindSlide(presentation,
|
|
543
|
+
function bindSlide(presentation, index, context) {
|
|
544
544
|
const slidePath = `slides.${index}`;
|
|
545
545
|
// Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
|
|
546
546
|
// (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
|
|
@@ -548,6 +548,9 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
548
548
|
// `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
|
|
549
549
|
// or the render fails under `strictReferences`.
|
|
550
550
|
const slideContext = resolveContext(presentation, index, context);
|
|
551
|
+
// FA-31: every string the slide draws (titles, body text, table cells, notes) is the substituted slide: {{slide.number}}, {{slide.section}} and
|
|
552
|
+
// {{deck.slideCount}} hold this slide's number, its section and the deck's slide count, the same values the furniture draws. Core resolved them.
|
|
553
|
+
const slide = slideContext.slide;
|
|
551
554
|
const layout = slideContext.options.layout;
|
|
552
555
|
const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
|
|
553
556
|
const titleBindings = [];
|
|
@@ -635,10 +638,14 @@ function bindSlide(presentation, slide, index, context) {
|
|
|
635
638
|
}
|
|
636
639
|
|
|
637
640
|
// `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
|
|
638
|
-
// `embeddedFonts` list). The renderer reads the two as plain options from here on.
|
|
641
|
+
// `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
|
|
642
|
+
// measurement and drops the faces, for a host whose page already holds them.
|
|
643
|
+
// `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
|
|
639
644
|
function normalizeOptions(options) {
|
|
640
|
-
const { fonts, ...rest } = options ?? {};
|
|
641
|
-
|
|
645
|
+
const { fonts, embedFonts, textAsPaths, ...rest } = options ?? {};
|
|
646
|
+
if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
|
|
647
|
+
if (textAsPaths && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", "textAsPaths needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.", { option: "textAsPaths" });
|
|
648
|
+
return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}) };
|
|
642
649
|
}
|
|
643
650
|
|
|
644
651
|
/**
|
|
@@ -656,7 +663,7 @@ function resolveDeck(input, options) {
|
|
|
656
663
|
}
|
|
657
664
|
|
|
658
665
|
const context = { presentation, options };
|
|
659
|
-
const slides = presentation.slides.map((
|
|
666
|
+
const slides = presentation.slides.map((_, index) => bindSlide(presentation, index, context));
|
|
660
667
|
|
|
661
668
|
return {
|
|
662
669
|
presentation,
|
|
@@ -699,7 +706,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
699
706
|
const { width, height } = bound.design.dimensions;
|
|
700
707
|
// The title may be TextRun[] (FA-10): the accessible name is its plain text.
|
|
701
708
|
const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
702
|
-
|
|
709
|
+
let content = [
|
|
703
710
|
renderBackground(bound, width, height, options),
|
|
704
711
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
705
712
|
...renderSlideContent(bound, width, height, options),
|
|
@@ -708,7 +715,14 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
708
715
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
709
716
|
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
710
717
|
].filter(Boolean);
|
|
711
|
-
|
|
718
|
+
// RR-64: text as glyph outlines. The used-face rule below then embeds only the faces of text that stays text.
|
|
719
|
+
let glyphs = "";
|
|
720
|
+
if (options.outlines) {
|
|
721
|
+
const outlined = options.outlines.outlineSlideText(content, { rootStyle: lang ? { lang } : {}, report: diagnostic => reportDiagnostic(diagnostic, options), fail: (code, message) => new OPFRenderError(code, message, { slideIndex }) });
|
|
722
|
+
content = outlined.content;
|
|
723
|
+
glyphs = outlined.defs;
|
|
724
|
+
}
|
|
725
|
+
const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
|
|
712
726
|
// FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
|
|
713
727
|
// 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
|
|
714
728
|
// would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
|
|
@@ -720,7 +734,9 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
720
734
|
"svg",
|
|
721
735
|
{
|
|
722
736
|
xmlns: "http://www.w3.org/2000/svg",
|
|
723
|
-
|
|
737
|
+
// FA-30: a labelled container, not an image: role="img" makes every text node inside presentational.
|
|
738
|
+
role: "group",
|
|
739
|
+
"aria-roledescription": "slide",
|
|
724
740
|
"aria-label": title,
|
|
725
741
|
viewBox: `0 0 ${width} ${height}`,
|
|
726
742
|
width,
|
|
@@ -756,6 +772,7 @@ function renderBackground(bound, width, height, options) {
|
|
|
756
772
|
y2: `${50+dy}%`
|
|
757
773
|
}, stopTags.join(""))),
|
|
758
774
|
tag("rect", {
|
|
775
|
+
...HIDDEN,
|
|
759
776
|
x: 0,
|
|
760
777
|
y: 0,
|
|
761
778
|
width,
|
|
@@ -773,9 +790,10 @@ function renderBackground(bound, width, height, options) {
|
|
|
773
790
|
const runs=patternRuns(preset);
|
|
774
791
|
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'}):'';
|
|
775
792
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
776
|
-
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})`}));
|
|
793
|
+
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})`}));
|
|
777
794
|
}
|
|
778
795
|
return tag("rect", {
|
|
796
|
+
...HIDDEN,
|
|
779
797
|
x: 0,
|
|
780
798
|
y: 0,
|
|
781
799
|
width,
|
|
@@ -792,9 +810,9 @@ function renderBackground(bound, width, height, options) {
|
|
|
792
810
|
// and stretch use the shared fit math with the focus point; tile repeats the picture at its intrinsic size (1 picture px = 1
|
|
793
811
|
// reference px) from the canvas top-left. alt is the picture's accessible name; without alt the picture is decorative.
|
|
794
812
|
function renderBackgroundImage(background, bound, width, height, options) {
|
|
795
|
-
const canvas = tag("rect", { x: 0, y: 0, width, height, fill: bound.design.backgroundColor ?? bound.design.colors.background, ...traceAttrs(options, `${bound.path}.design.background`) });
|
|
813
|
+
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`) });
|
|
796
814
|
const item = { value: background.alt === undefined ? background.src : { src: background.src, alt: background.alt }, path: background.path };
|
|
797
|
-
const drawOptions = { ...options, imageDecorative: background.alt === undefined };
|
|
815
|
+
const drawOptions = { ...options, imageDecorative: background.alt === undefined || background.alt === "" };
|
|
798
816
|
const picture = background.fit === "tile"
|
|
799
817
|
? renderTiledImage(item, background.box, bound, drawOptions)
|
|
800
818
|
: renderImage(item, background.box, bound, { ...drawOptions, imageFit: background.fit, imageFocus: background.focus, imagePicture: background.picture });
|
|
@@ -838,7 +856,7 @@ function renderImageBlock(item, bound, options) {
|
|
|
838
856
|
const children = [defs.length ? tag("defs", {}, defs.join("")) : "", pixels];
|
|
839
857
|
if (image.border) {
|
|
840
858
|
const paint = imagePaint(image.border.color, bound, bound.design.colors.border);
|
|
841
|
-
children.push(tag("path", { 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 }));
|
|
859
|
+
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 }));
|
|
842
860
|
}
|
|
843
861
|
if (image.overlay) children.push(renderImageOverlay(image.overlay, bound, options));
|
|
844
862
|
if (children.length === 2 && !children[0] && !Object.keys(trace).length) return pixels;
|
|
@@ -849,7 +867,7 @@ function renderImageBlock(item, bound, options) {
|
|
|
849
867
|
// overlay opacity.
|
|
850
868
|
function renderImageOverlay(overlay, bound, options) {
|
|
851
869
|
const paint = imagePaint(overlay.color, bound, bound.design.colors.text);
|
|
852
|
-
return tag("path", { 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 } : {}) });
|
|
870
|
+
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 } : {}) });
|
|
853
871
|
}
|
|
854
872
|
|
|
855
873
|
// The placeholder renderImage draws for a source it cannot resolve.
|
|
@@ -885,7 +903,7 @@ function renderSlideContent(bound, width, height, options) {
|
|
|
885
903
|
for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
|
|
886
904
|
return bound.geometry.items.map(item => {
|
|
887
905
|
const frame=item.frameBox;
|
|
888
|
-
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)}) : '';
|
|
906
|
+
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)}) : '';
|
|
889
907
|
// RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
|
|
890
908
|
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
|
|
891
909
|
});
|
|
@@ -900,11 +918,13 @@ function reportDiagnostic(diagnostic, options) {
|
|
|
900
918
|
options.onDiagnostic?.(diagnostic);
|
|
901
919
|
}
|
|
902
920
|
|
|
903
|
-
// FA-09: Chart.alt
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
921
|
+
// 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",
|
|
922
|
+
// 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
|
|
923
|
+
// PowerPoint, where descr on a table frame hides nothing): role="group" names it without hiding its children. An empty alt marks the payload
|
|
924
|
+
// decorative (aria-hidden) for both. Without alt the SVG is unchanged.
|
|
925
|
+
function altText(drawn, alt, role) {
|
|
926
|
+
if (typeof alt !== "string" || !drawn) return drawn;
|
|
927
|
+
return alt === "" ? tag("g", { "aria-hidden": "true" }, drawn) : tag("g", { role, "aria-label": alt }, drawn);
|
|
908
928
|
}
|
|
909
929
|
|
|
910
930
|
function renderPayload(item, box, bound, options) {
|
|
@@ -912,9 +932,9 @@ function renderPayload(item, box, bound, options) {
|
|
|
912
932
|
if (item.field === "items" || item.field === "bullets") return renderList(item, box, bound, options);
|
|
913
933
|
switch (item.type) {
|
|
914
934
|
case "chart":
|
|
915
|
-
return
|
|
935
|
+
return altText(renderChart(item, box, bound, options), item.value?.alt, "img");
|
|
916
936
|
case "table":
|
|
917
|
-
return renderTable(item, box, bound, options);
|
|
937
|
+
return altText(renderTable(item, box, bound, options), item.value?.alt, "group");
|
|
918
938
|
case "image":
|
|
919
939
|
return renderImageBlock(item, bound, options);
|
|
920
940
|
case "video":
|
|
@@ -999,10 +1019,17 @@ function resolveImageSource(item, bound, options) {
|
|
|
999
1019
|
return { asset, source: svg ?? source, drawable, missingReference };
|
|
1000
1020
|
}
|
|
1001
1021
|
|
|
1022
|
+
// FA-30: purely decorative drawing is hidden from assistive technology so the slide's text and real pictures are what a reader meets
|
|
1023
|
+
// (background, overlays, card frames, dividers, table cell fills and borders, accent shapes). SVG-AAM already leaves an unnamed shape
|
|
1024
|
+
// out of the tree; the attribute states the intent for the readers that do not.
|
|
1025
|
+
const HIDDEN = Object.freeze({ "aria-hidden": "true" });
|
|
1026
|
+
|
|
1002
1027
|
// The accessible name of a drawn picture: its alt text, else the caller's label; a decorative picture (an image background
|
|
1003
1028
|
// without alt) is hidden from assistive technology.
|
|
1004
1029
|
function imageAccessibility(asset, options) {
|
|
1005
|
-
|
|
1030
|
+
// An empty alt marks the picture decorative (FA-30), as it does a chart; a picture that is only furniture (a header or footer
|
|
1031
|
+
// logo repeated on every slide) is decorative unless the author named it.
|
|
1032
|
+
if (options.imageDecorative || asset.alt === "" || (options.imageUnlabelled && asset.alt === undefined)) return HIDDEN;
|
|
1006
1033
|
return { role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image" };
|
|
1007
1034
|
}
|
|
1008
1035
|
|
|
@@ -1227,6 +1254,7 @@ function renderMedia(item, box, bound, options) {
|
|
|
1227
1254
|
const iconBox = centeredBox(box, 72, 72);
|
|
1228
1255
|
const children = [
|
|
1229
1256
|
tag("rect", {
|
|
1257
|
+
...HIDDEN,
|
|
1230
1258
|
x: box.x,
|
|
1231
1259
|
y: box.y,
|
|
1232
1260
|
width: box.width,
|
|
@@ -1237,6 +1265,7 @@ function renderMedia(item, box, bound, options) {
|
|
|
1237
1265
|
...traceAttrs(options, item.path)
|
|
1238
1266
|
}),
|
|
1239
1267
|
tag("circle", {
|
|
1268
|
+
...HIDDEN,
|
|
1240
1269
|
cx: iconBox.x + iconBox.width / 2,
|
|
1241
1270
|
cy: iconBox.y + iconBox.height / 2,
|
|
1242
1271
|
r: 36,
|
|
@@ -1244,6 +1273,7 @@ function renderMedia(item, box, bound, options) {
|
|
|
1244
1273
|
...traceAttrs(options, item.path)
|
|
1245
1274
|
}),
|
|
1246
1275
|
tag("path", {
|
|
1276
|
+
...HIDDEN,
|
|
1247
1277
|
d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
|
|
1248
1278
|
fill: "#FFFFFF",
|
|
1249
1279
|
...traceAttrs(options, item.path)
|
|
@@ -1274,6 +1304,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1274
1304
|
const highlight = codeHighlight(item, layout, bound);
|
|
1275
1305
|
const children = [
|
|
1276
1306
|
tag("rect", {
|
|
1307
|
+
...HIDDEN,
|
|
1277
1308
|
x: box.x,
|
|
1278
1309
|
y: box.y,
|
|
1279
1310
|
width: box.width,
|
|
@@ -1290,6 +1321,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1290
1321
|
const body = layout.parts.find(part => part.role === "body");
|
|
1291
1322
|
for (const band of highlight.bands) {
|
|
1292
1323
|
children.push(tag("rect", {
|
|
1324
|
+
...HIDDEN,
|
|
1293
1325
|
x: stableNumber(box.x + 1), y: stableNumber(body.box.y + band.first * body.fit.lineHeight), width: stableNumber(box.width - 2),
|
|
1294
1326
|
height: stableNumber((band.last - band.first + 1) * body.fit.lineHeight), fill: highlight.colors.band,
|
|
1295
1327
|
...(options.trace ? { "data-opf-code-highlight": "true" } : {})
|
|
@@ -1405,16 +1437,16 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1405
1437
|
const layout=item.timelineLayout;
|
|
1406
1438
|
if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
|
|
1407
1439
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1408
|
-
const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
1440
|
+
const children=[tag('line',{...HIDDEN,...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
|
|
1409
1441
|
// FA-11: status colors come from the deck (core's timelineMarkerShapes / timelineTextColor); a marker with no status draws the plain primary circle.
|
|
1410
1442
|
const background=bound.design.backgroundColor??bound.design.colors.background;
|
|
1411
1443
|
const statusColors={background,primary:bound.design.colors.primary,text:bound.design.colors.text,mutedText:bound.design.colors.mutedText};
|
|
1412
1444
|
for(const marker of layout.markers){
|
|
1413
1445
|
if(!marker.status){
|
|
1414
|
-
children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
1446
|
+
children.push(tag('circle',{...HIDDEN,cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
|
|
1415
1447
|
continue;
|
|
1416
1448
|
}
|
|
1417
|
-
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}:{})}));
|
|
1449
|
+
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}:{})}));
|
|
1418
1450
|
}
|
|
1419
1451
|
for(const part of layout.parts){
|
|
1420
1452
|
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});
|
|
@@ -1445,6 +1477,7 @@ function renderTable(item, box, bound, options) {
|
|
|
1445
1477
|
? defaultText
|
|
1446
1478
|
: resolveColorRef(style.color, bound, defaultText);
|
|
1447
1479
|
children.push(tag("rect", {
|
|
1480
|
+
...HIDDEN,
|
|
1448
1481
|
x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
|
|
1449
1482
|
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
1450
1483
|
fill,
|
|
@@ -1500,6 +1533,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
|
|
|
1500
1533
|
? defaultColor
|
|
1501
1534
|
: resolveColorRef(border.color, bound, defaultColor);
|
|
1502
1535
|
return [tag('line', {
|
|
1536
|
+
...HIDDEN,
|
|
1503
1537
|
x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
|
|
1504
1538
|
stroke, 'stroke-width':stableNumber(width),
|
|
1505
1539
|
'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
|
|
@@ -1637,7 +1671,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
|
|
|
1637
1671
|
}
|
|
1638
1672
|
return tag('g',{},layout.parts.filter(part=>part.kind===kind).map(part=>{
|
|
1639
1673
|
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}:{};
|
|
1640
|
-
if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain'}));
|
|
1674
|
+
if(part.type==='image')return tag('g',trace,renderImage({value:part.image,path:part.path},part.box,bound,{...options,imageFit:'contain',imageUnlabelled:true}));
|
|
1641
1675
|
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}));
|
|
1642
1676
|
}).join(''));
|
|
1643
1677
|
}
|
|
@@ -1650,12 +1684,13 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1650
1684
|
if(mark){
|
|
1651
1685
|
const opacity=typeof design.watermark.opacity==='number'&&Number.isFinite(design.watermark.opacity)?Math.min(1,Math.max(0,design.watermark.opacity)):.08;
|
|
1652
1686
|
const cx=width/2,cy=height/2;
|
|
1653
|
-
pieces.push(tag('g',{opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
|
|
1687
|
+
pieces.push(tag('g',{...HIDDEN,opacity:stableNumber(opacity),transform:`rotate(${stableNumber(mark.rotation)} ${stableNumber(cx)} ${stableNumber(cy)})`,...traceAttrs(options,rootFor('watermark'))},
|
|
1654
1688
|
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,
|
|
1655
1689
|
fill:bound.design.colors.text,'xml:space':'preserve','text-rendering':'geometricPrecision'},escapeText(mark.text))));
|
|
1656
1690
|
}
|
|
1657
1691
|
} else if(design.watermark){
|
|
1658
|
-
|
|
1692
|
+
// The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
|
|
1693
|
+
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})));
|
|
1659
1694
|
}
|
|
1660
1695
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
|
|
1661
1696
|
const logo=bound.geometry.logo;
|
|
@@ -1710,12 +1745,30 @@ function nestedReset(style, run) {
|
|
|
1710
1745
|
return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
|
|
1711
1746
|
}
|
|
1712
1747
|
|
|
1713
|
-
|
|
1748
|
+
// RR-59 (#175): bidi rule L1 resets white space at the end of a line (with the isolate marks around it) to the paragraph level. A run
|
|
1749
|
+
// drawn as its own text chunk (a <text>, or a tspan with its own x) is such a line, and its paragraph is left to right, so the trailing
|
|
1750
|
+
// space of a right-to-left isolate drew at the run's right edge instead of its left, where it separates the run from the next one: at an
|
|
1751
|
+
// Arabic/Latin boundary the gap closed up (PowerPoint keeps it). A positioned run of a right-to-left line therefore draws without its
|
|
1752
|
+
// trailing white space, its glyphs starting after that white space's measured advance. Returns the white space ("" when there is none,
|
|
1753
|
+
// or when the text is all white space).
|
|
1754
|
+
function rtlTrailingSpace(text) {
|
|
1755
|
+
const found = /\S(\s+)$/u.exec(text);
|
|
1756
|
+
return found ? found[1] : "";
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* `runPlacement` ({x, width, fontSize}) positions the runs like `placement`, but only when the text plans into more than one run
|
|
1761
|
+
* (RR-59, #175): a list entry's line has no placed origin from core, and drawn as one chunk of nested tspans resvg shapes the whole
|
|
1762
|
+
* chunk with the outer face first and, when a fallback face covers every character, replaces every glyph with that face's (the Latin
|
|
1763
|
+
* phrase of an Arabic item took a wider fallback face and ran into the bullet).
|
|
1764
|
+
*/
|
|
1765
|
+
function scriptLine(text, style, bound, type, { rtl = false, placement, runPlacement, trace, fontSize } = {}) {
|
|
1714
1766
|
const value = String(text ?? "");
|
|
1715
1767
|
const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
|
|
1716
1768
|
rtl = rtl && value !== "";
|
|
1717
1769
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1718
1770
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
1771
|
+
if (!placement && runPlacement && runs.length > 1) placement = runPlacement;
|
|
1719
1772
|
// 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.
|
|
1720
1773
|
const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
|
|
1721
1774
|
// FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
|
|
@@ -1741,16 +1794,20 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, f
|
|
|
1741
1794
|
advance += width;
|
|
1742
1795
|
const start = offset;
|
|
1743
1796
|
offset += run.symbol ? run.symbol.source.length : run.text.length;
|
|
1797
|
+
// RR-59: a right-to-left run's trailing white space sits at its left edge (see rtlTrailingSpace); the glyphs draw after it.
|
|
1798
|
+
const trail = rtl && !run.symbol ? rtlTrailingSpace(run.text) : "";
|
|
1799
|
+
const drawn = trail ? run.text.slice(0, -trail.length) : run.text;
|
|
1800
|
+
const gap = trail ? Math.min(width, Math.max(0, width - scripts.runWidths([{ ...run, text: drawn }], placement.fontSize, style)[0] * factor)) : 0;
|
|
1744
1801
|
// A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
|
|
1745
|
-
const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
|
|
1802
|
+
const pinned = run.symbol ? natural[index] > width + 1e-6 : width - gap > 0;
|
|
1746
1803
|
return tag("tspan", {
|
|
1747
|
-
x: stableNumber(placement.x + left),
|
|
1748
|
-
textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1804
|
+
x: stableNumber(placement.x + left + gap),
|
|
1805
|
+
textLength: pinned ? stableNumber(width - gap) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1749
1806
|
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1750
1807
|
"font-size": adjusted(run),
|
|
1751
1808
|
style: run.own ? undefined : nestedReset(style, run),
|
|
1752
|
-
...(trace ? trace(start, offset) : {})
|
|
1753
|
-
}, isolate(escapeText(
|
|
1809
|
+
...(trace ? trace(start, offset - trail.length) : {})
|
|
1810
|
+
}, isolate(escapeText(drawn)));
|
|
1754
1811
|
}).join("");
|
|
1755
1812
|
return { content, positioned: true, ...shiftOf(runs) };
|
|
1756
1813
|
}
|
|
@@ -1798,21 +1855,26 @@ function codeSyntax(item, layout, bound, options) {
|
|
|
1798
1855
|
return { tokens, palette: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
|
|
1799
1856
|
}
|
|
1800
1857
|
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1858
|
+
// 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
|
|
1859
|
+
// font-style some text of the slide takes (attributes are inherited down the element tree, as in SVG). A drawn family none of
|
|
1860
|
+
// whose faces matches a drawn weight and style keeps all its faces, so the browser can always choose. A face no font-family of
|
|
1861
|
+
// the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
|
|
1862
|
+
// embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
|
|
1863
|
+
// a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
|
|
1805
1864
|
function embeddedFontsFor(fonts = [], content) {
|
|
1806
|
-
if (!fonts.
|
|
1865
|
+
if (!fonts.length) return fonts;
|
|
1866
|
+
fonts.forEach(assertEmbeddableFont);
|
|
1807
1867
|
const drawn = drawnFaces(content.join("\n"));
|
|
1808
1868
|
const wanted = font => {
|
|
1809
1869
|
const triples = drawn.get(String(font.family).toLowerCase());
|
|
1810
1870
|
if (!triples) return false;
|
|
1811
|
-
const family = fonts.filter(other =>
|
|
1812
|
-
|
|
1813
|
-
|
|
1871
|
+
const family = fonts.filter(other => String(other?.family).toLowerCase() === String(font.family).toLowerCase());
|
|
1872
|
+
return [...triples].some(triple => {
|
|
1873
|
+
const matching = family.filter(other => triple === `${other.weight}|${other.italic ? "italic" : "normal"}`);
|
|
1874
|
+
return matching.length ? matching.includes(font) : true;
|
|
1875
|
+
});
|
|
1814
1876
|
};
|
|
1815
|
-
return fonts.filter(font => font?.embed
|
|
1877
|
+
return fonts.filter(font => font?.embed === "always" || wanted(font));
|
|
1816
1878
|
}
|
|
1817
1879
|
|
|
1818
1880
|
const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
|
|
@@ -1841,10 +1903,21 @@ function drawnFaces(markup) {
|
|
|
1841
1903
|
return drawn;
|
|
1842
1904
|
}
|
|
1843
1905
|
|
|
1906
|
+
// Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
|
|
1907
|
+
// object while its dataUrl is unchanged: the handle's list holds the same objects on every slide, and its URIs run to tens of
|
|
1908
|
+
// megabytes.
|
|
1909
|
+
const checkedDataUrls = new WeakMap();
|
|
1910
|
+
function assertEmbeddableFont(font) {
|
|
1911
|
+
const valid = typeof font?.family === "string" && !/[\u0000-\u001f"'\\<>;]/.test(font.family) && Number.isInteger(font.weight) && font.weight >= 1 && font.weight <= 1000
|
|
1912
|
+
&& (checkedDataUrls.get(font) === font.dataUrl || /^data:font\/(ttf|otf|woff|woff2);base64,[A-Za-z0-9+/=]+$/.test(font.dataUrl));
|
|
1913
|
+
if (!valid) throw new OPFRenderError("invalid-embedded-font", "Embedded fonts require a plain family name, valid weight, and a font data URI.");
|
|
1914
|
+
checkedDataUrls.set(font, font.dataUrl);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1844
1917
|
function renderEmbeddedFonts(fonts = []) {
|
|
1845
1918
|
if (!fonts.length) return "";
|
|
1846
1919
|
const css = fonts.map(font => {
|
|
1847
|
-
|
|
1920
|
+
assertEmbeddableFont(font);
|
|
1848
1921
|
return `@font-face{font-family:"${font.family}";font-weight:${font.weight};font-style:${font.italic ? "italic" : "normal"};src:url("${font.dataUrl}")}`;
|
|
1849
1922
|
}).join("\n");
|
|
1850
1923
|
const licenses = [...new Set(fonts.map(font=>font.license).filter(Boolean))];
|
|
@@ -1921,16 +1994,29 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1921
1994
|
? (linked ? bound.design.colors.hyperlink : config.fill)
|
|
1922
1995
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1923
1996
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1997
|
+
// RR-59 (#175): measured fragments of a line core did not place (a list entry) keep their measured origin; a mixed-script one
|
|
1998
|
+
// positions its runs (scriptLine runPlacement) as a placed line does.
|
|
1999
|
+
const measuredLine=!asFlow&&!naturalFlow&&fragment.kind!=='tab'&&fragment.width>0;
|
|
2000
|
+
// A right-to-left fragment draws without its trailing white space, after that white space's measured advance (rtlTrailingSpace).
|
|
2001
|
+
const trail=rtl&&measuredLine&&fragment.kind!=='marker'?rtlTrailingSpace(fragment.text):'';
|
|
2002
|
+
let gap=0;
|
|
2003
|
+
if(trail){
|
|
2004
|
+
const measure=textWidthMeasurer(fragment.style,config.options.textMeasurement),full=measure(fragment.text,fragment.fontSize);
|
|
2005
|
+
gap=full>0?Math.min(fragment.width,Math.max(0,fragment.width*(1-measure(fragment.text.slice(0,-trail.length),fragment.fontSize)/full))):0;
|
|
2006
|
+
if(!asFlow)position.x=stableNumber(originX+fragmentX+gap);
|
|
2007
|
+
}
|
|
2008
|
+
const drawnText=trail?fragment.text.slice(0,-trail.length):fragment.text,drawnWidth=fragment.width-gap;
|
|
1924
2009
|
// FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
|
|
1925
2010
|
const runLang=fragment.kind!=='tab'&&fragment.style.lang?(bound.scriptFontsFor?.(fragment.style.lang)?.profile.bcp47??fragment.style.lang):undefined;
|
|
1926
|
-
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(
|
|
1927
|
-
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:
|
|
2011
|
+
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(drawnText,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
|
|
2012
|
+
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,
|
|
2013
|
+
runPlacement:measuredLine&&!fixedAdvance?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1928
2014
|
if(scripted.positioned)fixedAdvance=false;
|
|
1929
2015
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1930
2016
|
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1931
2017
|
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1932
2018
|
const marker=fragment.kind==='marker';
|
|
1933
|
-
const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(
|
|
2019
|
+
const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end-trail.length,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(drawnWidth):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,run.code===true?'monospace':bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1934
2020
|
if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1935
2021
|
return rendered;
|
|
1936
2022
|
};
|