@openpresentation/opf-render 0.11.9 → 0.12.2
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 +142 -12
- package/dist/annotations.js +45 -0
- package/dist/chart-options.js +177 -0
- package/dist/charts.js +194 -36
- package/dist/color-fonts.js +32 -0
- package/dist/deck-runtime.js +343 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +5 -0
- package/dist/element.d.ts +86 -0
- package/dist/element.js +521 -0
- package/dist/export-browser.d.ts +26 -0
- package/dist/export-browser.js +228 -0
- package/dist/font-manifest.js +158 -0
- package/dist/font-policy.js +232 -16
- package/dist/fonts.d.ts +30 -0
- package/dist/fonts.js +48 -22
- package/dist/index.d.ts +100 -2
- package/dist/pdf-fonts.js +333 -0
- package/dist/pdf-style.js +429 -0
- package/dist/pdf-subset.js +176 -0
- package/dist/pdf-text.js +338 -0
- package/dist/pdf-vector.js +1249 -0
- package/dist/pdf-writer.js +154 -0
- package/dist/pdf-xml.js +78 -0
- package/dist/player.d.ts +79 -0
- package/dist/player.js +706 -0
- package/dist/preview-fonts-cli.js +15 -0
- package/dist/preview-fonts-node.d.ts +14 -0
- package/dist/preview-fonts-node.js +86 -0
- package/dist/preview-fonts.d.ts +23 -0
- package/dist/preview-fonts.js +78 -0
- package/dist/raster-images.js +19 -6
- package/dist/raster-text.js +468 -0
- package/dist/raster.js +81 -12
- package/dist/script-font-pack.js +19 -1
- package/dist/script-fonts.js +214 -23
- package/dist/sha256.js +47 -0
- package/dist/svg.d.ts +9 -1
- package/dist/svg.js +360 -53
- package/dist/symbol-encodings.js +7 -0
- package/dist/symbol-fonts.js +96 -0
- package/package.json +90 -8
package/dist/svg.js
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
// Optional core exports are read from the namespace so an older published core
|
|
8
8
|
// still loads; resolveScriptFonts ships with core FF-18.
|
|
9
9
|
import * as opfCore from "@openpresentation/opf";
|
|
10
|
-
import { createScriptFonts } from "./script-fonts.js";
|
|
10
|
+
import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
|
|
11
11
|
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
12
|
+
import { fontPolicyFor } from "./font-policy.js";
|
|
12
13
|
import { renderCatalogChart } from "./charts.js";
|
|
14
|
+
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
13
15
|
|
|
14
16
|
export const packageName = "@openpresentation/opf-render";
|
|
15
17
|
|
|
@@ -181,6 +183,36 @@ function parseInput(input) {
|
|
|
181
183
|
throw new OPFRenderError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
// Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved
|
|
187
|
+
// to a concrete deck before composition, so the preview and the PPTX exporter see the same text, numbers, dates and
|
|
188
|
+
// images. A template previews with each unfilled variable's example; a normal deck with an unfilled required
|
|
189
|
+
// variable is refused. Decks without content variables are returned untouched. Read from the namespace so an older
|
|
190
|
+
// published core still loads.
|
|
191
|
+
function resolveTemplateInput(presentation, options) {
|
|
192
|
+
if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
|
|
193
|
+
const values = options.variables;
|
|
194
|
+
// variables: false draws the document as authored, tokens and var: references included (the template editing view).
|
|
195
|
+
if (values === false) return presentation;
|
|
196
|
+
if (values !== undefined && !isPlainObject(values)) {
|
|
197
|
+
throw new OPFRenderError("invalid-variables", "The variables option must be an object keyed by variable id.", { path: "options.variables" });
|
|
198
|
+
}
|
|
199
|
+
const template = opfCore.isTemplate(presentation);
|
|
200
|
+
if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
201
|
+
const result = opfCore.resolveVariables(presentation, values ?? {}, { examples: template });
|
|
202
|
+
const errors = result.diagnostics.filter((entry) => entry.severity === "error");
|
|
203
|
+
if (errors.length) {
|
|
204
|
+
const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
|
|
205
|
+
throw new OPFRenderError(unfilled ? "unfilled-variables" : "invalid-variables", errors[0].message, {
|
|
206
|
+
issues: errors,
|
|
207
|
+
path: errors[0].path
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
for (const entry of result.diagnostics) {
|
|
211
|
+
if (entry.code === "variable-example-used") options.onDiagnostic?.({ code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id });
|
|
212
|
+
}
|
|
213
|
+
return result.presentation;
|
|
214
|
+
}
|
|
215
|
+
|
|
184
216
|
function assertValidBoundary(presentation) {
|
|
185
217
|
const result = validatePresentation(presentation);
|
|
186
218
|
if (!result.valid) {
|
|
@@ -539,18 +571,26 @@ function reportLanguageDiagnostic(context, diagnostic) {
|
|
|
539
571
|
*/
|
|
540
572
|
function reportGlyphFallback(context, note) {
|
|
541
573
|
context.glyphFallbackDiagnostics ??= new Set();
|
|
542
|
-
const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
|
|
574
|
+
const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.placeholder ?? ""}\u0000${note.path ?? ""}`;
|
|
543
575
|
if (context.glyphFallbackDiagnostics.has(key)) return;
|
|
544
576
|
context.glyphFallbackDiagnostics.add(key);
|
|
577
|
+
// FF-45: a symbol-encoded family's note lists the codes drawn as their Unicode equivalents, or as the placeholder.
|
|
578
|
+
const symbol = Array.isArray(note.codes);
|
|
579
|
+
const codes = symbol ? note.codes.slice(0, 8).map(code => `0x${code}`).join(", ") : "";
|
|
545
580
|
context.options.onDiagnostic?.({
|
|
546
581
|
code: "font-glyph-fallback",
|
|
547
582
|
...(note.path ? { path: note.path } : {}),
|
|
548
583
|
// The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
|
|
549
|
-
message:
|
|
584
|
+
message: symbol
|
|
585
|
+
? (note.placeholder
|
|
586
|
+
? `'${note.fontFamily}' is symbol-encoded; codes such as ${codes} have no Unicode equivalent that a loaded face draws, so the preview draws the placeholder '${note.placeholder}' with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`
|
|
587
|
+
: `'${note.fontFamily}' is symbol-encoded and not bundled; the preview draws codes such as ${codes} as their Unicode equivalents with '${note.fallbackFamily}'. The PPTX keeps the chosen font and the original codes.`)
|
|
588
|
+
: `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
|
|
550
589
|
fontFamily: note.fontFamily,
|
|
551
590
|
fallbackFamily: note.fallbackFamily,
|
|
552
591
|
scripts: [...note.scripts],
|
|
553
|
-
characters: [...note.characters]
|
|
592
|
+
characters: [...note.characters],
|
|
593
|
+
...(symbol ? { codes: [...note.codes], ...(note.placeholder ? { placeholder: note.placeholder } : {}) } : {})
|
|
554
594
|
});
|
|
555
595
|
}
|
|
556
596
|
|
|
@@ -684,7 +724,13 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
684
724
|
});
|
|
685
725
|
const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
|
|
686
726
|
// fontScheme.accent (the tag and quote text) exists only with a core that resolves it; it takes the same look-alike policy.
|
|
687
|
-
|
|
727
|
+
// RR-17 (FF-41): a family that names its weight (Arial Black, Segoe UI Semibold and Light) keeps its own name through composition, so each run
|
|
728
|
+
// resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
|
|
729
|
+
// replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
|
|
730
|
+
for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
|
|
731
|
+
const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
732
|
+
design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
|
|
733
|
+
}
|
|
688
734
|
const geometry = composeSlide(slide, { ...design.dimensions, layout, presentation, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, darkBackground: design.darkBackground, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
|
|
689
735
|
return {
|
|
690
736
|
scriptFonts,
|
|
@@ -703,7 +749,7 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
703
749
|
}
|
|
704
750
|
|
|
705
751
|
export function resolvePresentation(input, options = {}) {
|
|
706
|
-
const presentation = parseInput(input);
|
|
752
|
+
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
707
753
|
if (options.validate !== false) {
|
|
708
754
|
assertValidBoundary(presentation);
|
|
709
755
|
}
|
|
@@ -752,10 +798,18 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
752
798
|
renderSlideImage(bound, options),
|
|
753
799
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
754
800
|
...renderSlideContent(bound, width, height, options),
|
|
801
|
+
// RR-34: the footnote area core reserved above the footer band, after the content and before the furniture.
|
|
802
|
+
renderFootnotes(bound, options, drawHelpers),
|
|
755
803
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
756
804
|
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
757
805
|
].filter(Boolean);
|
|
758
806
|
const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
|
|
807
|
+
// FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
|
|
808
|
+
// 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
|
|
809
|
+
// would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
|
|
810
|
+
// that draw such punctuation carry it, so other output is unchanged.
|
|
811
|
+
const drawn = content.join("");
|
|
812
|
+
const trimsPunctuation = /[ -〿-]/.test(drawn) || (script?.scriptRole === "eastAsian" && /[‘-”]/.test(drawn));
|
|
759
813
|
|
|
760
814
|
return tag(
|
|
761
815
|
"svg",
|
|
@@ -768,6 +822,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
768
822
|
height,
|
|
769
823
|
lang,
|
|
770
824
|
"xml:lang": lang,
|
|
825
|
+
style: trimsPunctuation ? "text-spacing-trim:space-all" : undefined,
|
|
771
826
|
...traceAttrs(options, bound.path)
|
|
772
827
|
},
|
|
773
828
|
`\n${children.join("\n")}\n`
|
|
@@ -815,10 +870,14 @@ function renderBackground(bound, width, height, options) {
|
|
|
815
870
|
}
|
|
816
871
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
817
872
|
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=resolveBackgroundColor(pattern.foregroundColor,bound.design,bound.design.colors.text),preset=pattern.preset;
|
|
818
|
-
//
|
|
819
|
-
// PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
873
|
+
// Without core's pattern table (an older core) five presets keep their hand-drawn approximation. diagStripe is the
|
|
874
|
+
// engine id that PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
820
875
|
const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
|
|
821
|
-
|
|
876
|
+
// RR-07: every DrawingML preset (core PATTERN_PRESETS) is an 8x8 bitmap, one unit per 1/96 inch, anchored at the slide origin.
|
|
877
|
+
const runs=typeof opfCore.patternRuns==='function'?opfCore.patternRuns(preset):undefined;
|
|
878
|
+
const bitmap=runs?tag('path',{d:runs.map(run=>`M${run.x} ${run.y}h${run.width}v1h-${run.width}z`).join(''),fill:color,'shape-rendering':'crispEdges'}):'';
|
|
879
|
+
const legacy=preset==='ltHorz'?tag('path',{d:'M0 4H8',stroke:color,'stroke-width':1}):preset==='diagStripe'||preset==='wdUpDiag'?tag('path',{d:'M-2 2L2 -2M0 8L8 0M6 10L10 6',stroke:color,'stroke-width':2}):preset==='pct5'?tag('circle',{cx:2,cy:2,r:.8,fill:color}):preset==='openDmnd'?stroke('M0 4L4 0L8 4L4 8Z'):preset==='wave'?stroke('M0 4C2 1 2 1 4 4S6 7 8 4'):'';
|
|
880
|
+
const mark=bitmap||legacy;
|
|
822
881
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
823
882
|
return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
824
883
|
}
|
|
@@ -895,9 +954,12 @@ function renderSlideContent(bound, width, height, options) {
|
|
|
895
954
|
return bound.geometry.items.map(item => {
|
|
896
955
|
const frame=item.frameBox;
|
|
897
956
|
const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
|
|
898
|
-
|
|
957
|
+
// RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
|
|
958
|
+
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
|
|
899
959
|
});
|
|
900
960
|
}
|
|
961
|
+
// Draw helpers injected into src/annotations.js (captions and footnote areas).
|
|
962
|
+
const drawHelpers = { tag, renderTextBox, renderRichLines };
|
|
901
963
|
|
|
902
964
|
function reportDiagnostic(diagnostic, options) {
|
|
903
965
|
const key = `${diagnostic.code}:${diagnostic.path}:${diagnostic.reason ?? ''}`;
|
|
@@ -957,13 +1019,13 @@ function renderTextPayload(item, box, bound, options) {
|
|
|
957
1019
|
|
|
958
1020
|
function renderList(item, box, bound, options) {
|
|
959
1021
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
960
|
-
const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement});
|
|
1022
|
+
const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},textMeasurement:options.textMeasurement,...(item.payload?.numbering!==undefined?{numbering:item.payload.numbering}:{})});
|
|
961
1023
|
const children=[];
|
|
962
1024
|
// design.listBullet=image: core attaches the icon logo as item.bulletImage and its box (entry.bulletBox: 0.65 em, as PowerPoint draws a:buBlip). An icon that cannot be drawn keeps the glyph marker.
|
|
963
1025
|
const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
|
|
964
1026
|
for(const entry of fit.listEntries){
|
|
965
1027
|
if(bullet){const box=entry.bulletBox??{x:entry.marker.x,y:entry.marker.y-entry.marker.fontSize*.65,width:entry.marker.fontSize*.65,height:entry.marker.fontSize*.65};children.push(tag('image',{x:stableNumber(box.x),y:stableNumber(box.y),width:stableNumber(box.width),height:stableNumber(box.height),href:bullet,preserveAspectRatio:'xMidYMid meet','aria-hidden':'true',...(options.trace?{'data-opf-generated':'true'}:{})}));}
|
|
966
|
-
else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),fill:bound.design.colors.text,'aria-hidden':'true'},escapeText(entry.marker.text)));
|
|
1028
|
+
else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),...numberMarkerStyle(entry.marker),fill:bound.design.colors.text,'aria-hidden':'true',...(entry.marker.anchor==='end'?{'text-anchor':'end'}:{})},escapeText(entry.marker.text)));
|
|
967
1029
|
const config={path:entry.textPath,align:'left',fill:bound.design.colors.text,rich:Array.isArray(entry.value),options};
|
|
968
1030
|
children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
|
|
969
1031
|
if(entry.description)children.push(renderRichLines(typeof entry.descriptionValue==='string'?[entry.descriptionValue]:entry.descriptionValue,entry.description,entry.descriptionBox,bound,{...config,path:entry.descriptionPath,rich:Array.isArray(entry.descriptionValue),fill:bound.design.colors.mutedText}));
|
|
@@ -971,6 +1033,13 @@ function renderList(item, box, bound, options) {
|
|
|
971
1033
|
return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},children.join('\n'));
|
|
972
1034
|
}
|
|
973
1035
|
|
|
1036
|
+
// A numbered list's marker (numbering) draws the number with the weight and slant core measured it at: PowerPoint draws an
|
|
1037
|
+
// auto-number in the first run's character formatting. Bullet markers carry no such attributes.
|
|
1038
|
+
function numberMarkerStyle(marker) {
|
|
1039
|
+
if (!marker.number) return {};
|
|
1040
|
+
return {'font-weight':marker.style.fontWeight===400?undefined:marker.style.fontWeight,'font-style':marker.style.italic?'italic':undefined};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
974
1043
|
// Follows asset: references and the host imageResolver to the drawable source of an image value.
|
|
975
1044
|
function resolveImageSource(item, bound, options) {
|
|
976
1045
|
let asset = normalizeAsset(item.value);
|
|
@@ -985,8 +1054,146 @@ function resolveImageSource(item, bound, options) {
|
|
|
985
1054
|
asset = {...normalizeAsset(bound.assets[id]),...overrides};
|
|
986
1055
|
}
|
|
987
1056
|
const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
|
|
988
|
-
|
|
989
|
-
|
|
1057
|
+
// An SVG data URI (base64 or text, any encoding) is drawn as an image too, as a base64 URI: an SVG used as an image never
|
|
1058
|
+
// runs scripts or loads anything outside itself, in a browser or in the PNG/PDF rasterizer.
|
|
1059
|
+
const svg = typeof source === "string" ? svgImageSource(source) : null;
|
|
1060
|
+
const drawable = svg !== null || (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source));
|
|
1061
|
+
return { asset, source: svg ?? source, drawable, missingReference };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
1065
|
+
|
|
1066
|
+
// The base64 data URI of a drawable SVG, else null. Drawable: a data URI whose type is image/svg+xml, whose text is
|
|
1067
|
+
// well-formed XML (and safe: no external or markup entity) with an <svg> root in the SVG namespace and an intrinsic size
|
|
1068
|
+
// (width and height, or a viewBox) - the same checks opf-pptx makes before it writes a native SVG picture over a PNG
|
|
1069
|
+
// fallback - so a document the export refuses (malformed, unsafe, no size) is the placeholder in the preview too, and the
|
|
1070
|
+
// two agree. The scripts, foreign objects and external references a valid document may carry are never run or loaded: an
|
|
1071
|
+
// SVG used as an image reaches nothing outside itself.
|
|
1072
|
+
function svgImageSource(uri) {
|
|
1073
|
+
const header = /^data:([^;,]*)((?:;[^;,]*)*),/i.exec(uri);
|
|
1074
|
+
if (!header || !/^image\/svg\+xml$/i.test(header[1])) return null;
|
|
1075
|
+
let bytes;
|
|
1076
|
+
try {
|
|
1077
|
+
const payload = uri.slice(header[0].length);
|
|
1078
|
+
bytes = /;base64/i.test(header[2]) ? Uint8Array.from(atob(payload.replace(/\s+/g, "")), char => char.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(payload));
|
|
1079
|
+
} catch { return null; }
|
|
1080
|
+
if (bytes.length > SVG_IMAGE_MAX_BYTES) return null;
|
|
1081
|
+
let text;
|
|
1082
|
+
try { text = decodeSvgText(bytes); } catch { return null; }
|
|
1083
|
+
const root = svgRootAttributes(text);
|
|
1084
|
+
if (!root || !svgHasIntrinsicSize(root)) return null;
|
|
1085
|
+
let binary = "";
|
|
1086
|
+
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
1087
|
+
return `data:image/svg+xml;base64,${btoa(binary)}`;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const SVG_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
1091
|
+
|
|
1092
|
+
// UTF-8 (with or without a BOM), UTF-16 with a BOM, or the encoding the XML declaration names; throws on bytes the encoding cannot decode.
|
|
1093
|
+
function decodeSvgText(bytes) {
|
|
1094
|
+
let start = 0, label = "utf-8";
|
|
1095
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) start = 3;
|
|
1096
|
+
else if (bytes[0] === 0xff && bytes[1] === 0xfe) { label = "utf-16le"; start = 2; }
|
|
1097
|
+
else if (bytes[0] === 0xfe && bytes[1] === 0xff) { label = "utf-16be"; start = 2; }
|
|
1098
|
+
else {
|
|
1099
|
+
const declared = /^\s*<\?xml\b[^>]*\bencoding\s*=\s*["']([A-Za-z0-9._-]+)["']/.exec(String.fromCharCode(...bytes.subarray(0, 200)))?.[1];
|
|
1100
|
+
if (declared && !/^utf-?8$/i.test(declared)) label = declared;
|
|
1101
|
+
}
|
|
1102
|
+
return new TextDecoder(label, { fatal: true }).decode(bytes.subarray(start));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const SVG_START_TAG = /<([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)((?:\s+[^\s=\/>"'<]+\s*=\s*(?:"[^"<]*"|'[^'<]*'))*)\s*(\/?)>/y;
|
|
1106
|
+
const SVG_ATTRIBUTE = /\s+([^\s=\/>"'<]+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
|
|
1107
|
+
const SVG_END_TAG = /<\/([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>/y;
|
|
1108
|
+
|
|
1109
|
+
// The root element's attributes (references expanded) of a well-formed, safe SVG document in the SVG namespace, else null.
|
|
1110
|
+
// A small XML check, not a parser: tags nest and close, attributes are quoted and unique, the one root is <svg>, text sits
|
|
1111
|
+
// inside it, and every reference is one of the five predefined entities, a numeric reference or a plain-text entity the
|
|
1112
|
+
// DOCTYPE declares. An entity that is external or contains markup, or expansions beyond the size limit, refuse the document.
|
|
1113
|
+
function svgRootAttributes(text) {
|
|
1114
|
+
const stack = [], entities = new Map();
|
|
1115
|
+
let index = 0, rootDone = false, rootAttributes = null, expansion = 0;
|
|
1116
|
+
const references = value => value.replace(/&([^;\s&<]*);?/g, (all, name) => {
|
|
1117
|
+
if (!all.endsWith(";")) throw new Error("reference");
|
|
1118
|
+
if (/^(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6})$/.test(name)) return all;
|
|
1119
|
+
if (!entities.has(name)) throw new Error("undeclared entity");
|
|
1120
|
+
const expanded = entities.get(name);
|
|
1121
|
+
if ((expansion += expanded.length) > SVG_IMAGE_MAX_BYTES) throw new Error("entity expansion");
|
|
1122
|
+
return expanded;
|
|
1123
|
+
});
|
|
1124
|
+
try {
|
|
1125
|
+
while (index < text.length) {
|
|
1126
|
+
if (text[index] !== "<") {
|
|
1127
|
+
const end = text.indexOf("<", index), chunk = text.slice(index, end < 0 ? text.length : end);
|
|
1128
|
+
index += chunk.length;
|
|
1129
|
+
if (!stack.length) { if (chunk.trim()) return null; }
|
|
1130
|
+
else { if (chunk.includes("]]>")) return null; references(chunk); }
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (text.startsWith("<!--", index)) {
|
|
1134
|
+
const end = text.indexOf("-->", index + 4);
|
|
1135
|
+
if (end < 0) return null;
|
|
1136
|
+
index = end + 3;
|
|
1137
|
+
} else if (text.startsWith("<![CDATA[", index)) {
|
|
1138
|
+
const end = text.indexOf("]]>", index + 9);
|
|
1139
|
+
if (end < 0 || !stack.length) return null;
|
|
1140
|
+
index = end + 3;
|
|
1141
|
+
} else if (text.startsWith("<?", index)) {
|
|
1142
|
+
const end = text.indexOf("?>", index + 2);
|
|
1143
|
+
if (end < 0) return null;
|
|
1144
|
+
index = end + 2;
|
|
1145
|
+
} else if (text.startsWith("<!DOCTYPE", index)) {
|
|
1146
|
+
if (rootDone || stack.length || rootAttributes) return null;
|
|
1147
|
+
const match = /^<!DOCTYPE\s+[^\[>]*(?:\[([\s\S]*?)\]\s*)?>/.exec(text.slice(index));
|
|
1148
|
+
if (!match) return null;
|
|
1149
|
+
for (const declaration of (match[1] ?? "").matchAll(/<!ENTITY\b[^>]*>/g)) {
|
|
1150
|
+
const entity = /^<!ENTITY\s+([A-Za-z_][\w.-]*)\s+(?:"([^"]*)"|'([^']*)')\s*>$/.exec(declaration[0]);
|
|
1151
|
+
const value = entity?.[2] ?? entity?.[3];
|
|
1152
|
+
if (!entity || value.includes("&") || value.includes("<")) return null;
|
|
1153
|
+
entities.set(entity[1], value);
|
|
1154
|
+
}
|
|
1155
|
+
index += match[0].length;
|
|
1156
|
+
} else if (text.startsWith("</", index)) {
|
|
1157
|
+
SVG_END_TAG.lastIndex = index;
|
|
1158
|
+
const match = SVG_END_TAG.exec(text);
|
|
1159
|
+
if (!match || stack.pop() !== match[1]) return null;
|
|
1160
|
+
index = SVG_END_TAG.lastIndex;
|
|
1161
|
+
if (!stack.length) rootDone = true;
|
|
1162
|
+
} else {
|
|
1163
|
+
SVG_START_TAG.lastIndex = index;
|
|
1164
|
+
const match = SVG_START_TAG.exec(text);
|
|
1165
|
+
if (!match) return null;
|
|
1166
|
+
index = SVG_START_TAG.lastIndex;
|
|
1167
|
+
const [, qualified, source, selfClosing] = match;
|
|
1168
|
+
const attributes = [], names = new Set();
|
|
1169
|
+
SVG_ATTRIBUTE.lastIndex = 0;
|
|
1170
|
+
for (let attribute; source && (attribute = SVG_ATTRIBUTE.exec(source));) {
|
|
1171
|
+
if (names.has(attribute[1])) return null;
|
|
1172
|
+
names.add(attribute[1]);
|
|
1173
|
+
attributes.push([attribute[1], references(attribute[2] ?? attribute[3])]);
|
|
1174
|
+
}
|
|
1175
|
+
if (!stack.length) {
|
|
1176
|
+
if (rootDone || rootAttributes) return null;
|
|
1177
|
+
const colon = qualified.indexOf(":");
|
|
1178
|
+
if (qualified.slice(colon + 1) !== "svg") return null;
|
|
1179
|
+
const namespace = colon < 0 ? "xmlns" : `xmlns:${qualified.slice(0, colon)}`;
|
|
1180
|
+
if (!attributes.some(([key, value]) => key === namespace && value === SVG_NAMESPACE)) return null;
|
|
1181
|
+
rootAttributes = Object.fromEntries(attributes);
|
|
1182
|
+
}
|
|
1183
|
+
if (selfClosing) { if (!stack.length) rootDone = true; }
|
|
1184
|
+
else stack.push(qualified);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
} catch { return null; }
|
|
1188
|
+
return stack.length || !rootAttributes ? null : rootAttributes;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// The root has an intrinsic size: width and height lengths, or a viewBox (the same rule core and opf-pptx use).
|
|
1192
|
+
function svgHasIntrinsicSize(attributes) {
|
|
1193
|
+
const length = value => { const match = value && /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|mm|cm|in|q)?\s*$/i.exec(value); return match && Number(match[1]) > 0 ? Number(match[1]) : undefined; };
|
|
1194
|
+
const box = (attributes.viewBox ?? "").trim().split(/[\s,]+/).map(Number);
|
|
1195
|
+
const view = box.length === 4 && box.every(Number.isFinite) && box[2] > 0 && box[3] > 0;
|
|
1196
|
+
return Boolean((length(attributes.width) && length(attributes.height)) || view);
|
|
990
1197
|
}
|
|
991
1198
|
|
|
992
1199
|
// The drawable data URI of a picture bullet, or undefined (after reporting unresolved-asset once) so the glyph marker stays.
|
|
@@ -1004,11 +1211,11 @@ function renderImage(item, box, bound, options) {
|
|
|
1004
1211
|
const { asset, source, drawable, missingReference } = resolveImageSource(item, bound, options);
|
|
1005
1212
|
if (drawable) {
|
|
1006
1213
|
return tag("image", { x: box.x, y: box.y, width: box.width, height: box.height,
|
|
1007
|
-
href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
|
|
1214
|
+
href: source, preserveAspectRatio: options.imageAnchor === "left" ? "xMinYMid meet" : options.imageAnchor === "right" ? "xMaxYMid meet" : (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? options.imageLabel ?? "Image",
|
|
1008
1215
|
...traceAttrs(options, item.path), ...generatedAttrs(options) });
|
|
1009
1216
|
}
|
|
1010
1217
|
const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
|
|
1011
|
-
const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster data URI or a host imageResolver.';
|
|
1218
|
+
const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster or SVG data URI, or a host imageResolver.';
|
|
1012
1219
|
if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: item.path,reason });
|
|
1013
1220
|
const fill = bound.design.colors.surface;
|
|
1014
1221
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
@@ -1101,6 +1308,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1101
1308
|
const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1102
1309
|
if (invalid) throw new OPFRenderError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`, {path:part.path});
|
|
1103
1310
|
}
|
|
1311
|
+
const syntax = codeSyntax(item, layout, bound, options);
|
|
1104
1312
|
const children = [
|
|
1105
1313
|
tag("rect", {
|
|
1106
1314
|
x: box.x,
|
|
@@ -1128,7 +1336,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1128
1336
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
1129
1337
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1130
1338
|
// Code stays left to right; script runs still take their slot fonts.
|
|
1131
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
|
|
1339
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false,fontSize:part.fit.fontSize},part.role==='body'?syntax:undefined)).join('')));
|
|
1132
1340
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1133
1341
|
'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
|
|
1134
1342
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
@@ -1140,6 +1348,9 @@ function renderMetric(item, box, bound, options) {
|
|
|
1140
1348
|
const layout=item.metricLayout;
|
|
1141
1349
|
if (!layout) throw new OPFRenderError('missing-metric-layout','Metric rendering requires coordinated core metric geometry.',{path:item.path});
|
|
1142
1350
|
const children=[];
|
|
1351
|
+
// RR-07: a trend draws an arrow beside its word and colours the trend and delta text, from core's accepted geometry.
|
|
1352
|
+
const trendMark=metricTrendMark(layout,bound,options);
|
|
1353
|
+
const partFill=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color:part.role==='value'?bound.design.colors.primary:bound.design.colors.text;
|
|
1143
1354
|
for (const part of layout.parts) {
|
|
1144
1355
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
1145
1356
|
if (invalid) throw new OPFRenderError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`,{path:part.path});
|
|
@@ -1155,30 +1366,37 @@ function renderMetric(item, box, bound, options) {
|
|
|
1155
1366
|
if(factor&&!line.segments.some(segment=>segment.kind==='tab'))return tag('text',{x:stableNumber(origin.x+line.width*factor),y:stableNumber(origin.baseline),'text-anchor':factor===1?'end':'middle',
|
|
1156
1367
|
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
1157
1368
|
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1158
|
-
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part
|
|
1369
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
|
|
1159
1370
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
1160
1371
|
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
1161
1372
|
},line.segments.map(segment=>segmentSpan({
|
|
1162
1373
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1163
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
1374
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
|
|
1164
1375
|
return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
|
|
1165
1376
|
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
1166
1377
|
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1167
|
-
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part
|
|
1378
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:partFill(part),
|
|
1168
1379
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
1169
1380
|
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
1170
1381
|
},line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
|
|
1171
1382
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
1172
1383
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1173
|
-
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
1384
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start),fontSize:part.fit.fontSize})).join(''));
|
|
1174
1385
|
});
|
|
1175
1386
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
|
|
1176
1387
|
'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
|
|
1177
1388
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
1178
1389
|
}
|
|
1390
|
+
if (trendMark) children.push(tag('g',{role:'img','aria-label':trendMark.ariaLabel},tag('polygon',{points:trendMark.points.map(([x,y])=>`${stableNumber(x)},${stableNumber(y)}`).join(' '),fill:trendMark.color})));
|
|
1179
1391
|
return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
|
|
1180
1392
|
}
|
|
1181
1393
|
|
|
1394
|
+
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend or without core support.
|
|
1395
|
+
function metricTrendMark(layout,bound,options) {
|
|
1396
|
+
if (typeof opfCore.metricTrendMark!=='function') return undefined;
|
|
1397
|
+
return opfCore.metricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1182
1400
|
function renderQuote(item, box, bound, options) {
|
|
1183
1401
|
const layout = item.quoteLayout;
|
|
1184
1402
|
if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
|
|
@@ -1211,7 +1429,7 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1211
1429
|
|
|
1212
1430
|
function renderTable(item, box, bound, options) {
|
|
1213
1431
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1214
|
-
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path});
|
|
1432
|
+
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
|
|
1215
1433
|
const children = [];
|
|
1216
1434
|
const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
|
|
1217
1435
|
const defaultEdges = [], explicitEdges = [];
|
|
@@ -1419,13 +1637,18 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1419
1637
|
}
|
|
1420
1638
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
|
|
1421
1639
|
const logo=bound.geometry.logo;
|
|
1422
|
-
if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:'left',imageLabel:'Logo',imageGenerated:true}));
|
|
1640
|
+
if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:'Logo',imageGenerated:true}));
|
|
1423
1641
|
return pieces.join('');
|
|
1424
1642
|
}
|
|
1425
1643
|
|
|
1644
|
+
// A family name is written unquoted only when it is a valid sequence of CSS identifiers; a word that starts with a digit
|
|
1645
|
+
// ("Source Sans 3", "Noto Sans Symbols 2") or any other non-identifier character makes an unquoted name invalid CSS, which
|
|
1646
|
+
// drops the whole font-family in a browser (FF-45). Such names are single-quoted (the attribute is double-quoted).
|
|
1647
|
+
const cssIdentifierSequence = /^(?:-?(?:[A-Za-z_ -]|\\.)(?:[\w --]|\\.)*)(?: (?:-?(?:[A-Za-z_ -]|\\.)(?:[\w --]|\\.)*))*$/;
|
|
1648
|
+
const cssFamily = name => cssIdentifierSequence.test(name) && !/^(?:inherit|initial|unset|default|serif|sans-serif|monospace|cursive|fantasy|system-ui)$/i.test(name) ? name : `'${name.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
1426
1649
|
function fontStack(family, type) {
|
|
1427
1650
|
const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
|
|
1428
|
-
return `${[family].flat().join(", ")}, ${fallback}`;
|
|
1651
|
+
return `${[family].flat().map(cssFamily).join(", ")}, ${fallback}`;
|
|
1429
1652
|
}
|
|
1430
1653
|
|
|
1431
1654
|
// Unicode directional isolates (FF-19): each paragraph (text between hard line
|
|
@@ -1438,6 +1661,10 @@ const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
|
|
|
1438
1661
|
// Core owns the rule (paragraphDirection, core #134), so preview and export agree.
|
|
1439
1662
|
// Without it (published core 0.11.0) every paragraph is left to right, as in export.
|
|
1440
1663
|
const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
|
|
1664
|
+
// RR-05: authored alignment is logical for right-to-left text, so `left` is the start edge (drawn at the right of a
|
|
1665
|
+
// right-to-left line) and `right` the end edge. Core owns the rule; the fallback only serves a core without it, which
|
|
1666
|
+
// also reports no line directions, so it never flips.
|
|
1667
|
+
const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
|
|
1441
1668
|
/** Maps a source offset of the text to whether its paragraph is right to left. */
|
|
1442
1669
|
function paragraphRtl(bound, text) {
|
|
1443
1670
|
if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
|
|
@@ -1465,43 +1692,81 @@ function nestedReset(style, run) {
|
|
|
1465
1692
|
return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
|
|
1466
1693
|
}
|
|
1467
1694
|
|
|
1468
|
-
function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
|
|
1695
|
+
function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
|
|
1469
1696
|
const value = String(text ?? "");
|
|
1470
1697
|
const scripts = bound.scriptFonts;
|
|
1471
1698
|
rtl = rtl && value !== "";
|
|
1472
1699
|
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1473
1700
|
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
1474
|
-
|
|
1701
|
+
// RR-38: PowerPoint's baseline of a line in Arabic Typesetting sits above core's (one em below the line top): the caller moves it up by `baselineShift` em.
|
|
1702
|
+
const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
|
|
1703
|
+
// FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
|
|
1704
|
+
// line is placed, one tspan per glyph at the verified symbol font's advance, so a single mapped glyph is never stretched to it.
|
|
1705
|
+
const symbols = runs.some(run => run.symbol);
|
|
1706
|
+
if (runs.length === 1 && !(symbols && placement && placement.width > 0)) {
|
|
1475
1707
|
const [run] = runs;
|
|
1476
|
-
|
|
1708
|
+
// RR-38: a replacement the policy scales (Arabic Typesetting -> Noto Naskh Arabic, 0.64) is drawn at the scaled size; the caller applies it.
|
|
1709
|
+
return { content: isolate(escapeText(run.own ? value : run.text)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type), ...(run.sizeAdjust ? { sizeAdjust: run.sizeAdjust } : {}), ...shiftOf(runs) };
|
|
1477
1710
|
}
|
|
1711
|
+
const baseSize = placement?.fontSize ?? fontSize;
|
|
1712
|
+
const adjusted = run => run.sizeAdjust && baseSize > 0 ? stableNumber(adjustedFontSize(baseSize, run.sizeAdjust)) : undefined;
|
|
1478
1713
|
const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
|
|
1479
1714
|
if (!widths) {
|
|
1480
1715
|
return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
|
|
1481
|
-
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
|
|
1716
|
+
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), "font-size": adjusted(run), style: nestedReset(style, run) }, escapeText(run.text))).join("")), ...shiftOf(runs) };
|
|
1482
1717
|
}
|
|
1718
|
+
const natural = symbols ? scripts.runWidths(runs, placement.fontSize, style, { natural: true }) : undefined;
|
|
1483
1719
|
const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
|
|
1484
1720
|
let advance = 0, offset = 0;
|
|
1485
1721
|
const content = runs.map((run, index) => {
|
|
1486
1722
|
const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
|
|
1487
1723
|
advance += width;
|
|
1488
1724
|
const start = offset;
|
|
1489
|
-
offset += run.text.length;
|
|
1725
|
+
offset += run.symbol ? run.symbol.source.length : run.text.length;
|
|
1726
|
+
// A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
|
|
1727
|
+
const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
|
|
1490
1728
|
return tag("tspan", {
|
|
1491
1729
|
x: stableNumber(placement.x + left),
|
|
1492
|
-
textLength:
|
|
1730
|
+
textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
|
|
1493
1731
|
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1732
|
+
"font-size": adjusted(run),
|
|
1494
1733
|
style: run.own ? undefined : nestedReset(style, run),
|
|
1495
1734
|
...(trace ? trace(start, offset) : {})
|
|
1496
1735
|
}, isolate(escapeText(run.text)));
|
|
1497
1736
|
}).join("");
|
|
1498
|
-
return { content, positioned: true };
|
|
1737
|
+
return { content, positioned: true, ...shiftOf(runs) };
|
|
1499
1738
|
}
|
|
1500
1739
|
|
|
1501
1740
|
/** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
|
|
1502
|
-
function segmentSpan(attrs, segment, text, style, bound, type, options) {
|
|
1741
|
+
function segmentSpan(attrs, segment, text, style, bound, type, options, syntax) {
|
|
1503
1742
|
const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
|
|
1504
|
-
|
|
1743
|
+
// Syntax highlighting (RR-07): a coloured text segment becomes consecutive sibling tspans, one per run, each a
|
|
1744
|
+
// traced segment of its own source range holding one text node (the editor's caret mapping reads that), the first
|
|
1745
|
+
// keeping the accepted x and the rest flowing after it. Plain code keeps one tspan per accepted segment.
|
|
1746
|
+
const runs = syntax && segment.kind !== "tab" ? opfCore.codeLineRuns(syntax.tokens, segment.start, segment.end) : undefined;
|
|
1747
|
+
if (runs?.some(run => run.kind)) {
|
|
1748
|
+
return runs.map((run, index) => {
|
|
1749
|
+
const sizeOf = line => line.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, line.sizeAdjust)) : undefined;
|
|
1750
|
+
const piece = scriptLine(text.slice(run.start - segment.start, run.end - segment.start), style, bound, type, options);
|
|
1751
|
+
const own = { ...attrs };
|
|
1752
|
+
if (index > 0) delete own.x;
|
|
1753
|
+
if (own["data-opf-text-start"] !== undefined) { own["data-opf-text-start"] = run.start; own["data-opf-text-end"] = run.end; }
|
|
1754
|
+
return tag("tspan", { ...own, fill: run.kind ? syntax.palette[run.kind] : undefined, "font-family": piece.family, "font-size": sizeOf(piece) }, piece.content);
|
|
1755
|
+
}).join("");
|
|
1756
|
+
}
|
|
1757
|
+
return tag("tspan", { ...attrs, "font-family": scripted.family, "font-size": scripted.sizeAdjust && options?.fontSize > 0 ? stableNumber(adjustedFontSize(options.fontSize, scripted.sizeAdjust)) : undefined }, scripted.content);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
|
|
1761
|
+
// no language, or a core without the shared highlighter). The preview and the PPTX export read the same tables.
|
|
1762
|
+
function codeSyntax(item, layout, bound, options) {
|
|
1763
|
+
const body = layout.parts.find(part => part.role === "body");
|
|
1764
|
+
const language = typeof item.value?.language === "string" ? item.value.language : layout.parts.find(part => part.role === "language")?.text;
|
|
1765
|
+
if (!body || !language) return undefined;
|
|
1766
|
+
if (typeof opfCore.tokenizeCode !== "function") return undefined;
|
|
1767
|
+
const tokens = opfCore.tokenizeCode(body.text, language);
|
|
1768
|
+
if (!tokens.length) return undefined;
|
|
1769
|
+
return { tokens, palette: opfCore.codeSyntaxPaletteForScheme(bound.design.colorScheme) };
|
|
1505
1770
|
}
|
|
1506
1771
|
|
|
1507
1772
|
// Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
|
|
@@ -1564,8 +1829,30 @@ function renderRichTextBox(value, box, bound, config) {
|
|
|
1564
1829
|
return renderRichLines(value,fit,box,bound,config);
|
|
1565
1830
|
}
|
|
1566
1831
|
|
|
1832
|
+
// A rich line without its leading and trailing whitespace: fragments keep their order and styles, positions and widths follow the trimmed text.
|
|
1833
|
+
// Returns null when there is nothing to trim or the line holds a tab.
|
|
1834
|
+
function trimRichLineEdges(line,textMeasurement) {
|
|
1835
|
+
const fragments=line.fragments.map(fragment=>({...fragment}));
|
|
1836
|
+
if(!fragments.length||fragments.some(fragment=>fragment.kind==='tab'))return null;
|
|
1837
|
+
const width=(fragment,text)=>text?textWidthMeasurer(fragment.style,textMeasurement)(text,fragment.fontSize):0;
|
|
1838
|
+
let changed=false;
|
|
1839
|
+
while(fragments.length&&!/\S/.test(fragments[0].text)){fragments.shift();changed=true;}
|
|
1840
|
+
while(fragments.length&&!/\S/.test(fragments.at(-1).text)){fragments.pop();changed=true;}
|
|
1841
|
+
if(!fragments.length)return null;
|
|
1842
|
+
const first=fragments[0],last=fragments.at(-1);
|
|
1843
|
+
const lead=/^\s*/.exec(first.text)[0],trail=/\s*$/.exec(last.text)[0];
|
|
1844
|
+
if(lead){first.text=first.text.slice(lead.length);first.start+=lead.length;first.width-=width(first,lead);changed=true;}
|
|
1845
|
+
if(trail){last.text=last.text.slice(0,-trail.length);last.end-=trail.length;last.width-=width(last,trail);changed=true;}
|
|
1846
|
+
if(!changed)return null;
|
|
1847
|
+
let x=0;
|
|
1848
|
+
for(const fragment of fragments){fragment.x=x;x+=fragment.width;}
|
|
1849
|
+
return {...line,fragments,width:x};
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1567
1852
|
function renderRichLines(value,fit,box,bound,config) {
|
|
1568
|
-
const
|
|
1853
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1854
|
+
// Each line takes its paragraph's direction from core (every wrapped line shares it) and its physical edge from that.
|
|
1855
|
+
const lineAlignment=index=>fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
|
|
1569
1856
|
let textOffset=0;
|
|
1570
1857
|
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1571
1858
|
const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
|
|
@@ -1574,13 +1861,21 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1574
1861
|
// those estimates into visible gaps. Supplied measurements keep exact origins.
|
|
1575
1862
|
const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
|
|
1576
1863
|
const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
|
|
1577
|
-
const content=fit.richLines.map((
|
|
1864
|
+
const content=fit.richLines.map((sourceLine,lineIndex)=>{
|
|
1865
|
+
const alignment=lineAlignment(lineIndex);
|
|
1866
|
+
const sourceFirst=sourceLine.fragments[0],rtl=fit.directions?fit.directions[lineIndex]==='rtl':richRtl(sourceFirst?runOffsets[sourceFirst.runIndex]+sourceFirst.start:0);
|
|
1867
|
+
// PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line, so the glyph edge stays on the box edge (RR-05).
|
|
1868
|
+
const edgeTrim=rtl&&alignment==='right'?trimRichLineEdges(sourceLine,config.options.textMeasurement):null;
|
|
1869
|
+
const line=edgeTrim??sourceLine;
|
|
1578
1870
|
const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
|
|
1579
1871
|
const flow=naturalFlow&&!lineHasTab;
|
|
1580
1872
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1581
1873
|
const placed=fit.placement?.lines[lineIndex];
|
|
1582
|
-
|
|
1583
|
-
const
|
|
1874
|
+
// RR-38: a line with runs in a policy replacement (Arabic Typesetting) takes PowerPoint's baseline of the real font, above core's.
|
|
1875
|
+
const lineShift=bound.scriptFonts?baselineShift(...line.fragments.filter(fragment=>fragment.kind!=='tab'&&fragment.text).map(fragment=>bound.scriptFonts.plan(fragment.text,fragment.style))):0;
|
|
1876
|
+
const shiftPx=lineShift*Math.max(fit.fontSize,...line.fragments.map(fragment=>fragment.fontSize));
|
|
1877
|
+
const originX=placed?placed.x+(edgeTrim?sourceLine.width-line.width:0):box.x+offset,baseline=(placed?.baseline??box.y+line.baseline)-shiftPx;
|
|
1878
|
+
const firstFragment=line.fragments[0];
|
|
1584
1879
|
const renderFragment=(fragment,asFlow,edges={})=>{
|
|
1585
1880
|
const run=fragment.run;
|
|
1586
1881
|
// Accepted outline placement owns the horizontal advance. Geometric precision
|
|
@@ -1595,11 +1890,14 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1595
1890
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1596
1891
|
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1597
1892
|
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
|
|
1598
|
-
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
|
|
1893
|
+
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
|
|
1599
1894
|
if(scripted.positioned)fixedAdvance=false;
|
|
1600
1895
|
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1601
|
-
|
|
1602
|
-
|
|
1896
|
+
// RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
|
|
1897
|
+
// segment without text offsets, so editors never read it as part of the run, and it is not linked.
|
|
1898
|
+
const marker=fragment.kind==='marker';
|
|
1899
|
+
const rendered=tag(asFlow?'tspan':'text',{...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(fragment.width):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1900
|
+
if(!marker&&run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1603
1901
|
return rendered;
|
|
1604
1902
|
};
|
|
1605
1903
|
if(naturalFlow&&lineHasTab) {
|
|
@@ -1616,13 +1914,14 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1616
1914
|
if(!flow)return fragments.join('\n');
|
|
1617
1915
|
const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
|
|
1618
1916
|
const first=line.fragments[0];
|
|
1619
|
-
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
|
|
1917
|
+
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline-shiftPx),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
|
|
1620
1918
|
});
|
|
1621
1919
|
let cursor=0;
|
|
1622
1920
|
const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
|
|
1623
1921
|
const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
|
|
1624
1922
|
if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
|
|
1625
1923
|
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1924
|
+
const alignment=lineAlignment(index);
|
|
1626
1925
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1627
1926
|
const placed=fit.placement?.lines[index];
|
|
1628
1927
|
return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
|
|
@@ -1649,42 +1948,50 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1649
1948
|
const totalHeight = fit.lines.length * fit.lineHeight;
|
|
1650
1949
|
const startY = config.verticalAlign === "middle"
|
|
1651
1950
|
? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
|
|
1652
|
-
const
|
|
1653
|
-
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1654
|
-
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1951
|
+
const logicalAlignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1655
1952
|
const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
|
|
1656
1953
|
const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
|
|
1657
1954
|
let cursor = 0;
|
|
1658
1955
|
const lines = fit.lines.map((line, index) => {
|
|
1956
|
+
// Logical alignment (RR-05): a right-to-left line starts at the right edge.
|
|
1957
|
+
const alignment=fit.placement?.lines[index]?.alignment??physicalAlignment(logicalAlignment,fit.directions?.[index]);
|
|
1958
|
+
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1959
|
+
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1659
1960
|
const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1660
1961
|
// The line's paragraph decides its direction: source offsets when layout has them, else the next match.
|
|
1661
1962
|
const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
|
|
1662
|
-
const rtl=boxRtl(lineStart);
|
|
1963
|
+
const rtl=fit.directions?fit.directions[index]==='rtl':boxRtl(lineStart);
|
|
1663
1964
|
const origin=placed?.x??x-(sourceLine?.width??0)*factor;
|
|
1664
1965
|
const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
|
|
1665
|
-
let content,family,positioned=false;
|
|
1966
|
+
let content,family,positioned=false,trimWidth=0,sizeAdjust,shift=0;
|
|
1666
1967
|
if(tabs) content=sourceLine.segments.map(segment=>{
|
|
1667
1968
|
const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
|
|
1668
1969
|
const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
|
|
1669
1970
|
const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
|
|
1670
|
-
placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
|
|
1971
|
+
placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,fontSize:size,
|
|
1671
1972
|
trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
|
|
1672
1973
|
if(scripted.positioned)return scripted.content;
|
|
1673
1974
|
return tag('tspan',{
|
|
1674
1975
|
x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
|
|
1675
|
-
lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
|
|
1976
|
+
lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,'font-size':scripted.sizeAdjust?stableNumber(adjustedFontSize(size,scripted.sizeAdjust)):undefined,
|
|
1676
1977
|
...traced(segment.start,segment.end),
|
|
1677
1978
|
},scripted.content);
|
|
1678
1979
|
}).join('');
|
|
1679
|
-
else
|
|
1980
|
+
else {
|
|
1981
|
+
// PowerPoint ignores whitespace at either edge of a right-aligned right-to-left line (the glyph edge stays on the box edge, native check
|
|
1982
|
+
// 2026-10-02), so a soft-wrapped line that keeps its trailing space, or a leading space, draws without it (RR-05).
|
|
1983
|
+
const edge=rtl&&alignment==='right'?/^(\s*)([\s\S]*?)(\s*)$/.exec(line):null,trimmed=edge&&edge[2]&&(edge[1]||edge[3])?edge:null;
|
|
1984
|
+
if(trimmed){const measureEdge=textWidthMeasurer(style,config.options.textMeasurement);trimWidth=measureEdge(trimmed[1],size)+measureEdge(trimmed[3],size);}
|
|
1985
|
+
({content,family,positioned=false,sizeAdjust,baselineShift:shift=0}=scriptLine(trimmed?trimmed[2]:line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x+trimWidth,width:placed.width-trimWidth,fontSize:size}:undefined,fontSize:size}));
|
|
1986
|
+
}
|
|
1680
1987
|
// Positioned script runs carry their own x and textLength (FF-19).
|
|
1681
1988
|
const start=tabs||positioned;
|
|
1682
1989
|
return tag("text", {
|
|
1683
|
-
x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
|
|
1990
|
+
x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber((placed?.baseline??startY + index * fit.lineHeight) - shift * size),
|
|
1684
1991
|
"text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
|
|
1685
|
-
"font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1992
|
+
"font-size": stableNumber(adjustedFontSize(size,sizeAdjust)), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1686
1993
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
|
|
1687
|
-
textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1994
|
+
textLength:!start&&placed?.width>0?stableNumber(placed.width-trimWidth):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1688
1995
|
...traceAttrs(config.options, config.path),
|
|
1689
1996
|
...(config.options.trace&&sourceLine?{'data-opf-source-start':sourceLine.start,'data-opf-source-end':sourceLine.end,'data-opf-source-next-start':sourceLine.nextStart,'data-opf-line-boundary':sourceLine.boundary}:{}),
|
|
1690
1997
|
}, content);
|
|
@@ -1783,7 +2090,7 @@ const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
|
|
|
1783
2090
|
const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
|
|
1784
2091
|
function withPolicyFeatures(name, attrs) {
|
|
1785
2092
|
if (attrs[NO_POLICY_FEATURES]) return attrs;
|
|
1786
|
-
const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
|
|
2093
|
+
const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim().replace(/^'(.*)'$/, "$1") : undefined;
|
|
1787
2094
|
const features = family && disabledFeaturesStyle(family);
|
|
1788
2095
|
return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
|
|
1789
2096
|
}
|