@openpresentation/opf-render 0.9.0 → 0.10.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 +62 -12
- package/dist/font-compatibility.js +32 -2
- package/dist/font-manifest.js +877 -0
- package/dist/font-policy.js +2463 -0
- package/dist/fonts-browser.d.ts +10 -0
- package/dist/fonts-browser.js +38 -16
- package/dist/fonts-node.d.ts +23 -9
- package/dist/fonts-node.js +47 -9
- package/dist/fonts.d.ts +71 -4
- package/dist/fonts.js +95 -21
- package/dist/index.d.ts +20 -2
- package/dist/script-font-pack.js +40 -0
- package/dist/script-fonts.js +426 -0
- package/dist/svg.d.ts +18 -2
- package/dist/svg.js +351 -38
- package/package.json +165 -5
package/dist/svg.js
CHANGED
|
@@ -4,6 +4,10 @@ import {
|
|
|
4
4
|
resolveColorRef as resolveCoreColorRef,
|
|
5
5
|
validatePresentation
|
|
6
6
|
} from "@openpresentation/opf";
|
|
7
|
+
// Optional core exports are read from the namespace so an older published core
|
|
8
|
+
// still loads; resolveScriptFonts ships with core FF-18.
|
|
9
|
+
import * as opfCore from "@openpresentation/opf";
|
|
10
|
+
import { createScriptFonts } from "./script-fonts.js";
|
|
7
11
|
|
|
8
12
|
export const packageName = "@openpresentation/opf-render";
|
|
9
13
|
|
|
@@ -51,6 +55,12 @@ export const engineDefaults = Object.freeze({
|
|
|
51
55
|
])
|
|
52
56
|
});
|
|
53
57
|
|
|
58
|
+
// Last-resort font scheme shared by every engine (core pagination, opf-editor and
|
|
59
|
+
// opf-pptx): DEFAULT_FONT_SCHEME in @openpresentation/opf. It applies only when the
|
|
60
|
+
// slide, deck and resolved theme name no font scheme. fontScheme.google is kept for a
|
|
61
|
+
// future Google Slides target and is not used by this renderer.
|
|
62
|
+
const DEFAULT_FONT_SCHEME = engineDefaults.fontScheme.pptx.latin;
|
|
63
|
+
|
|
54
64
|
export class OPFRenderError extends Error {
|
|
55
65
|
constructor(code, message, details = {}) {
|
|
56
66
|
super(message);
|
|
@@ -227,12 +237,47 @@ function sourceRecordsFor(kind, source, options) {
|
|
|
227
237
|
}
|
|
228
238
|
|
|
229
239
|
function engineDefaultId(kind) {
|
|
230
|
-
if (kind === "fontSchemes") return
|
|
240
|
+
if (kind === "fontSchemes") return DEFAULT_FONT_SCHEME;
|
|
231
241
|
if (kind === "chartTypes") return engineDefaults.chartTypes[0];
|
|
232
242
|
const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
|
|
233
243
|
return engineDefaults[key];
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
function findCatalogRecord(kind, id, context) {
|
|
247
|
+
if (!id) return null;
|
|
248
|
+
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
249
|
+
return findById(normalizeSourceRecords(documentCatalog), id) ??
|
|
250
|
+
findById(sourceRecordsFor(kind, documentCatalog?.source, context.options), id) ??
|
|
251
|
+
findById(normalizeSourceRecords(context.options.catalogs?.[kind]), id) ??
|
|
252
|
+
findById(sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options), id) ??
|
|
253
|
+
findById(defaultCatalogFor(kind), id);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Font schemes follow the shared core rule (resolveFontSchemeReference in
|
|
257
|
+
// @openpresentation/opf): an id that matches no record reports one
|
|
258
|
+
// `unresolved-font-scheme` diagnostic and uses the DEFAULT_FONT_SCHEME record as the
|
|
259
|
+
// base, with sibling overrides on top, so preview and PPTX export use the same fonts.
|
|
260
|
+
function resolveFontSchemeRecord(reference, context, path) {
|
|
261
|
+
const id = referenceId(reference);
|
|
262
|
+
const found = findCatalogRecord("fontSchemes", id, context);
|
|
263
|
+
const base = found ?? findCatalogRecord("fontSchemes", DEFAULT_FONT_SCHEME, context) ?? {};
|
|
264
|
+
const scheme = cloneWithSortedKeys(isPlainObject(reference) ? { ...base, ...reference } : base);
|
|
265
|
+
if (!id || found) return { scheme };
|
|
266
|
+
return { scheme, diagnostic: { code: "unresolved-font-scheme", path, id, fallback: DEFAULT_FONT_SCHEME, message: `Font scheme '${id}' is not in the inline or bundled catalogs; using the default font scheme '${DEFAULT_FONT_SCHEME}'.` } };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Generated socials furniture formats handles in core. Core applies inline
|
|
270
|
+
// document records first; the host supplies the rest in resolution order.
|
|
271
|
+
function socialPlatformRecords(context) {
|
|
272
|
+
const kind = "socialPlatforms";
|
|
273
|
+
return [
|
|
274
|
+
...sourceRecordsFor(kind, context.presentation.catalogs?.[kind]?.source, context.options),
|
|
275
|
+
...normalizeSourceRecords(context.options.catalogs?.[kind]),
|
|
276
|
+
...sourceRecordsFor(kind, engineDefaults.catalogs[kind]?.source, context.options),
|
|
277
|
+
...defaultCatalogFor(kind)
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
|
|
236
281
|
function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
|
|
237
282
|
const id = referenceId(reference) ?? fallbackId;
|
|
238
283
|
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
@@ -338,7 +383,7 @@ function colorLuminance(color) {
|
|
|
338
383
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
339
384
|
}
|
|
340
385
|
|
|
341
|
-
function resolveDesign(presentation, slide, context) {
|
|
386
|
+
function resolveDesign(presentation, slide, context, index) {
|
|
342
387
|
const deckDesign = presentation.design ?? {};
|
|
343
388
|
const slideDesign = slide.design ?? {};
|
|
344
389
|
|
|
@@ -354,11 +399,13 @@ function resolveDesign(presentation, slide, context) {
|
|
|
354
399
|
context,
|
|
355
400
|
"design.colorScheme"
|
|
356
401
|
);
|
|
357
|
-
const
|
|
358
|
-
"
|
|
359
|
-
slideDesign.
|
|
402
|
+
const fontPath = slideDesign.fontScheme !== undefined ? `slides.${index}.design.fontScheme`
|
|
403
|
+
: deckDesign.fontScheme !== undefined ? "design.fontScheme"
|
|
404
|
+
: slideDesign.theme !== undefined ? `slides.${index}.design.theme` : "design.theme";
|
|
405
|
+
const { scheme: fontScheme, diagnostic: fontSchemeDiagnostic } = resolveFontSchemeRecord(
|
|
406
|
+
slideDesign.fontScheme ?? deckDesign.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
|
|
360
407
|
context,
|
|
361
|
-
|
|
408
|
+
fontPath
|
|
362
409
|
);
|
|
363
410
|
const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
|
|
364
411
|
const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
|
|
@@ -386,10 +433,58 @@ function resolveDesign(presentation, slide, context) {
|
|
|
386
433
|
accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
|
|
387
434
|
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
388
435
|
},
|
|
389
|
-
fonts: resolveFontFamilies(fontScheme)
|
|
436
|
+
fonts: resolveFontFamilies(fontScheme),
|
|
437
|
+
diagnostics: fontSchemeDiagnostic ? [fontSchemeDiagnostic] : []
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Script font slots, language tags and direction for one slide from core
|
|
443
|
+
* `resolveScriptFonts` (FF-18). The latin slot is always the renderer's design
|
|
444
|
+
* font; a script slot that core fills from the latin family follows it too.
|
|
445
|
+
* Without core support every slot repeats the latin family.
|
|
446
|
+
*/
|
|
447
|
+
function scriptProfile(presentation, index, design, context) {
|
|
448
|
+
let resolved;
|
|
449
|
+
if (typeof opfCore.resolveScriptFonts === "function") {
|
|
450
|
+
try { resolved = opfCore.resolveScriptFonts(presentation, { slideIndex: index }); }
|
|
451
|
+
catch (error) {
|
|
452
|
+
reportLanguageDiagnostic(context, { code: "language-preview-unresolved", path: "language",
|
|
453
|
+
message: "Script fonts could not be resolved (" + (error instanceof Error ? error.message : String(error)) + "), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right." });
|
|
454
|
+
}
|
|
455
|
+
} else if (presentation.language !== undefined) {
|
|
456
|
+
reportLanguageDiagnostic(context, { code: "language-preview-unavailable", path: "language",
|
|
457
|
+
message: "The installed @openpresentation/opf has no resolveScriptFonts (FF-18), so the preview uses the design font for every script, sets no lang and lays out every paragraph left to right. Use a core release with the language model." });
|
|
458
|
+
}
|
|
459
|
+
if (resolved?.rtl === true && typeof opfCore.paragraphDirection !== "function") {
|
|
460
|
+
reportLanguageDiagnostic(context, { code: "paragraph-direction-unavailable", path: "language",
|
|
461
|
+
message: "The installed @openpresentation/opf has no paragraphDirection, so the preview lays out every paragraph of this right-to-left deck left to right, as the PPTX export does. Use a core release that exports it." });
|
|
462
|
+
}
|
|
463
|
+
const slots = role => {
|
|
464
|
+
const latin = design.fonts[role];
|
|
465
|
+
const slot = key => !resolved || resolved.sources?.[key] === "latin" || resolved.sources?.[key] === "schemeFamily" ? latin : resolved[role]?.[key] ?? latin;
|
|
466
|
+
return { latin, eastAsian: slot("eastAsian"), complexScript: slot("complexScript") };
|
|
467
|
+
};
|
|
468
|
+
return {
|
|
469
|
+
heading: slots("heading"),
|
|
470
|
+
body: slots("body"),
|
|
471
|
+
...(resolved?.supplement ? { supplement: resolved.supplement } : {}),
|
|
472
|
+
script: resolved?.script ?? "Zzzz",
|
|
473
|
+
...(resolved?.scriptRole ? { scriptRole: resolved.scriptRole } : {}),
|
|
474
|
+
...(resolved ? { bcp47: resolved.bcp47, lang: resolved.lang, languageSource: resolved.languageSource, direction: resolved.direction } : {}),
|
|
475
|
+
rtl: resolved?.rtl === true,
|
|
476
|
+
serif: design.fontScheme?.type === "serif"
|
|
390
477
|
};
|
|
391
478
|
}
|
|
392
479
|
|
|
480
|
+
/** Report a language diagnostic once per resolvePresentation call. */
|
|
481
|
+
function reportLanguageDiagnostic(context, diagnostic) {
|
|
482
|
+
context.languageDiagnostics ??= new Set();
|
|
483
|
+
if (context.languageDiagnostics.has(diagnostic.code)) return;
|
|
484
|
+
context.languageDiagnostics.add(diagnostic.code);
|
|
485
|
+
context.options.onDiagnostic?.(diagnostic);
|
|
486
|
+
}
|
|
487
|
+
|
|
393
488
|
function inferLayoutId(slide) {
|
|
394
489
|
if (slide.layout) return slide.layout;
|
|
395
490
|
if (slide.blocks?.length) return "blank";
|
|
@@ -511,10 +606,16 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
511
606
|
];
|
|
512
607
|
const blocks = blockContent(slide, slidePath);
|
|
513
608
|
|
|
514
|
-
const design = resolveDesign(presentation, slide, context);
|
|
515
|
-
|
|
516
|
-
|
|
609
|
+
const design = resolveDesign(presentation, slide, context, index);
|
|
610
|
+
// Script fonts (FF-19): each text run is measured and drawn with its script
|
|
611
|
+
// slot's face; the latin slot stays the design font scheme's family.
|
|
612
|
+
const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement);
|
|
613
|
+
const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
|
|
614
|
+
for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
615
|
+
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, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
|
|
517
616
|
return {
|
|
617
|
+
scriptFonts,
|
|
618
|
+
textMeasurement,
|
|
518
619
|
geometry,
|
|
519
620
|
assets: presentation.assets ?? {},
|
|
520
621
|
index,
|
|
@@ -565,13 +666,18 @@ export function renderSvgDeck(input, options = {}) {
|
|
|
565
666
|
}
|
|
566
667
|
|
|
567
668
|
function renderResolvedSlide(resolved, slideIndex, options) {
|
|
568
|
-
options = { ...options, _diagnosticPaths: new Set() };
|
|
569
669
|
const bound = resolved.slides[slideIndex];
|
|
670
|
+
// Paint with the same script-aware measurement composition used.
|
|
671
|
+
options = { ...options, ...(bound.textMeasurement ? { textMeasurement: bound.textMeasurement } : {}), _diagnosticPaths: new Set() };
|
|
672
|
+
const script = bound.scriptFonts?.profile;
|
|
673
|
+
// Only a language the document names sets lang; the renderer default does not.
|
|
674
|
+
const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
|
|
570
675
|
const { width, height } = bound.design.dimensions;
|
|
571
676
|
const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
572
677
|
const children = [
|
|
573
678
|
renderEmbeddedFonts(options.embeddedFonts),
|
|
574
679
|
renderBackground(bound, width, height, options),
|
|
680
|
+
renderSlideImage(bound, options),
|
|
575
681
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
576
682
|
...renderSlideContent(bound, width, height, options),
|
|
577
683
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
@@ -587,6 +693,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
587
693
|
viewBox: `0 0 ${width} ${height}`,
|
|
588
694
|
width,
|
|
589
695
|
height,
|
|
696
|
+
lang,
|
|
697
|
+
"xml:lang": lang,
|
|
590
698
|
...traceAttrs(options, bound.path)
|
|
591
699
|
},
|
|
592
700
|
`\n${children.join("\n")}\n`
|
|
@@ -634,7 +742,10 @@ function renderBackground(bound, width, height, options) {
|
|
|
634
742
|
}
|
|
635
743
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
636
744
|
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=normalizeColor(pattern.foregroundColor,bound.design.colors.text),preset=pattern.preset;
|
|
637
|
-
|
|
745
|
+
// 8px cells approximating DrawingML presets. diagStripe is the engine id that
|
|
746
|
+
// PPTX export writes as wdUpDiag, so both draw the same rising stripe.
|
|
747
|
+
const stroke=(d,width=1)=>tag('path',{d,fill:'none',stroke:color,'stroke-width':width});
|
|
748
|
+
const mark=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'):'';
|
|
638
749
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
639
750
|
return tag('g',{opacity:background.opacity??1},tag('rect',{width,height,fill:normalizeColor(pattern.backgroundColor,'#FFFFFF')})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
640
751
|
}
|
|
@@ -649,11 +760,68 @@ function renderBackground(bound, width, height, options) {
|
|
|
649
760
|
});
|
|
650
761
|
}
|
|
651
762
|
|
|
763
|
+
// design.slideImage: the shared composition frame, beneath branding and content.
|
|
764
|
+
// crop covers the frame and fit centers the whole image, matching native a:srcRect.
|
|
765
|
+
// Treatments mirror the native picture: the preset mask (core outline), recolor
|
|
766
|
+
// and alphaModFix on the pixels only, the centered line, then the overlay shape.
|
|
767
|
+
function renderSlideImage(bound, options) {
|
|
768
|
+
const image = bound.geometry.slideImage;
|
|
769
|
+
if (!image) return '';
|
|
770
|
+
const trace = options.trace ? { 'data-opf-slide-image': image.path, 'data-opf-slide-image-position': image.position } : {};
|
|
771
|
+
const value = image.alt === undefined ? image.value : { ...normalizeAsset(image.value), alt: image.alt };
|
|
772
|
+
const picture = renderImage({ value, path: image.sourcePath }, image.box, bound, { ...options, imageFit: image.fill === 'crop' ? 'cover' : 'contain' });
|
|
773
|
+
// Unresolved sources keep the ordinary placeholder without treatments, like the export.
|
|
774
|
+
if (!picture.startsWith('<image')) return tag('g', trace, picture);
|
|
775
|
+
const id = `opf-s${bound.index + 1}-slide-image`, shape = image.shape, defs = [];
|
|
776
|
+
const masked = shape && shape.preset !== 'rect';
|
|
777
|
+
if (masked) defs.push(tag('clipPath', { id: `${id}-clip` }, tag('path', { d: shape.path })));
|
|
778
|
+
const matrix = slideImageRecolorMatrix(image.recolor, bound);
|
|
779
|
+
if (matrix) defs.push(tag('filter', { id: `${id}-recolor`, 'color-interpolation-filters': 'sRGB' }, tag('feColorMatrix', { type: 'matrix', values: matrix })));
|
|
780
|
+
let pixels = matrix || image.opacity !== undefined ? tag('g', { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: image.opacity === undefined ? undefined : preciseNumber(image.opacity) }, picture) : picture;
|
|
781
|
+
if (masked) pixels = tag('g', { 'clip-path': `url(#${id}-clip)` }, pixels);
|
|
782
|
+
const children = [defs.length ? tag('defs', {}, defs.join('')) : '', pixels];
|
|
783
|
+
if (image.border) {
|
|
784
|
+
const paint = slideImagePaint(image.border.color, bound, bound.design.colors.border);
|
|
785
|
+
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 }));
|
|
786
|
+
}
|
|
787
|
+
if (image.overlay) {
|
|
788
|
+
const paint = slideImagePaint(image.overlay.color, bound, bound.design.colors.text);
|
|
789
|
+
children.push(tag('path', { d: image.overlay.shape?.path ?? rectanglePath(image.overlay.box), fill: paint.color, 'fill-opacity': preciseNumber(paint.alpha * image.overlay.opacity), ...(options.trace ? { 'data-opf-slide-image-overlay': `${image.path}.overlay` } : {}) }));
|
|
790
|
+
}
|
|
791
|
+
return tag('g', trace, children.filter(Boolean).join(''));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Native alpha and color-matrix values keep 1/100000 precision; three decimals would drift.
|
|
795
|
+
const preciseNumber = value => String(Math.round(value * 1e6) / 1e6);
|
|
796
|
+
|
|
797
|
+
function rectanglePath(box) {
|
|
798
|
+
return `M${stableNumber(box.x)} ${stableNumber(box.y)}H${stableNumber(box.x + box.width)}V${stableNumber(box.y + box.height)}H${stableNumber(box.x)}Z`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ColorRef -> opaque #RRGGBB plus the AA byte as alpha, as the native srgbClr + alpha.
|
|
802
|
+
function slideImagePaint(value, bound, fallback) {
|
|
803
|
+
const hex = resolveColorRef(value, bound, fallback) ?? fallback;
|
|
804
|
+
const raw = normalizeColor(hex, fallback).slice(1);
|
|
805
|
+
return { color: `#${raw.slice(0, 6).toUpperCase()}`, alpha: raw.length === 8 ? parseInt(raw.slice(6), 16) / 255 : 1 };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Rec. 601 luminance on sRGB values; duotone maps it linearly from dark to light.
|
|
809
|
+
function slideImageRecolorMatrix(recolor, bound) {
|
|
810
|
+
if (!recolor) return undefined;
|
|
811
|
+
const weights = [0.299, 0.587, 0.114];
|
|
812
|
+
const channels = recolor.type === 'duotone'
|
|
813
|
+
? [slideImagePaint(recolor.dark, bound, '#000000'), slideImagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
|
|
814
|
+
: [[0, 0, 0], [1, 1, 1]];
|
|
815
|
+
const [dark, light] = channels;
|
|
816
|
+
const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
|
|
817
|
+
return [...rows, '0 0 0 1 0'].join(' ');
|
|
818
|
+
}
|
|
819
|
+
|
|
652
820
|
function renderSlideContent(bound, width, height, options) {
|
|
653
|
-
for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
|
|
821
|
+
for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
|
|
654
822
|
return bound.geometry.items.map(item => {
|
|
655
823
|
const frame=item.frameBox;
|
|
656
|
-
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}) : '';
|
|
824
|
+
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)}) : '';
|
|
657
825
|
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
|
|
658
826
|
});
|
|
659
827
|
}
|
|
@@ -695,7 +863,10 @@ function renderPayload(item, box, bound, options) {
|
|
|
695
863
|
function renderTextPayload(item, box, bound, options) {
|
|
696
864
|
return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
|
|
697
865
|
path: item.path,
|
|
698
|
-
|
|
866
|
+
// Core resolves one alignment per composed item for every engine. The
|
|
867
|
+
// fallback keeps cores published before item.alignment working: titles
|
|
868
|
+
// follow design.titleAlignment only (unset is left, as in core composition).
|
|
869
|
+
align: item.alignment ?? (item.field === "title" ? bound.design.titleAlignment ?? "left" : bound.design.contentAlignment),
|
|
699
870
|
fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
|
|
700
871
|
fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
|
|
701
872
|
fontWeight: item.field === "title" ? 700 : 400,
|
|
@@ -853,12 +1024,13 @@ function renderCode(item, box, bound, options) {
|
|
|
853
1024
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
|
|
854
1025
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
855
1026
|
'data-opf-text-start':line.start,'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
856
|
-
},line.segments.map(segment=>
|
|
1027
|
+
},line.segments.map(segment=>segmentSpan({
|
|
857
1028
|
x:stableNumber(part.box.x+segment.x),
|
|
858
1029
|
// SVG's CSS tab-size does not place literal tabs at their measured stops.
|
|
859
1030
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
860
1031
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
861
|
-
|
|
1032
|
+
// Code stays left to right; script runs still take their slot fonts.
|
|
1033
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,'monospace',{rtl:false})).join('')));
|
|
862
1034
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
863
1035
|
'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}:{}),
|
|
864
1036
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
@@ -875,18 +1047,32 @@ function renderMetric(item, box, bound, options) {
|
|
|
875
1047
|
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});
|
|
876
1048
|
if (!part.visible) continue;
|
|
877
1049
|
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFRenderError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
|
|
1050
|
+
const partRtl=paragraphRtl(bound,part.text);
|
|
1051
|
+
// Anchor untabbed lines at the accepted alignment edge, as PPTX export does,
|
|
1052
|
+
// so a shaper whose advance differs from the accepted width keeps the edge.
|
|
1053
|
+
// Tabbed lines keep their accepted segment origins.
|
|
1054
|
+
const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
|
|
878
1055
|
const lines=part.fit.sourceLines.map((line,index)=>{
|
|
879
1056
|
const origin=part.linePositions[index];
|
|
1057
|
+
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',
|
|
1058
|
+
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
1059
|
+
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1060
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
|
|
1061
|
+
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
1062
|
+
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
1063
|
+
},line.segments.map(segment=>segmentSpan({
|
|
1064
|
+
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
1065
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
880
1066
|
return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
|
|
881
1067
|
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
882
1068
|
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
883
1069
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
|
|
884
1070
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
885
1071
|
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
886
|
-
},line.segments.map(segment=>
|
|
1072
|
+
},line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
|
|
887
1073
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
888
1074
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
889
|
-
},
|
|
1075
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
890
1076
|
});
|
|
891
1077
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
|
|
892
1078
|
'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}:{}),
|
|
@@ -1037,10 +1223,23 @@ function renderImportedChart(item, box, bound, options) {
|
|
|
1037
1223
|
const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
|
|
1038
1224
|
const plot={x:box.x+(horizontal?100:64),y:box.y+legendHeight+14,width:Math.max(1,box.width-(horizontal?124:84)),height:Math.max(1,box.height-legendHeight-62)};
|
|
1039
1225
|
const values=series.flatMap(s=>s.values).filter(v=>v!==null),min=Math.min(0,...values),rawMax=Math.max(0,...values),max=rawMax===min?min+1:rawMax,range=max-min;
|
|
1040
|
-
const
|
|
1226
|
+
const fraction=(v,reverse=false)=>{
|
|
1227
|
+
const distance=reverse?max-v:v-min;
|
|
1228
|
+
if(Number.isFinite(range)&&Number.isFinite(distance))return distance/range;
|
|
1229
|
+
// Finite endpoints can overflow during subtraction. Keep ordinary
|
|
1230
|
+
// arithmetic unchanged and normalize only that exceptional range.
|
|
1231
|
+
const unit=Math.max(Math.abs(min),Math.abs(max)),low=min/unit,high=max/unit,position=v/unit;
|
|
1232
|
+
return (reverse?high-position:position-low)/(high-low);
|
|
1233
|
+
};
|
|
1234
|
+
const xValue=v=>plot.x+fraction(v)*plot.width,yValue=v=>plot.y+fraction(v,true)*plot.height;
|
|
1041
1235
|
const zero=horizontal?xValue(0):yValue(0);
|
|
1042
1236
|
for(let tick=0;tick<=4;tick++){
|
|
1043
|
-
const
|
|
1237
|
+
const direct=min+range*tick/4;
|
|
1238
|
+
const value=Number.isFinite(direct)?direct:min*(1-tick/4)+max*(tick/4);
|
|
1239
|
+
const at=horizontal?xValue(value):yValue(value),rounded=Number(value.toPrecision(4));
|
|
1240
|
+
// Rounding MAX_VALUE to four digits can overflow although the tick is
|
|
1241
|
+
// finite. Its exact shortest decimal remains truthful and parseable.
|
|
1242
|
+
const label=Number.isFinite(rounded)?rounded:String(value);
|
|
1044
1243
|
children.push(tag('line',{x1:horizontal?at:plot.x,x2:horizontal?at:plot.x+plot.width,y1:horizontal?plot.y:at,y2:horizontal?plot.y+plot.height:at,stroke:bound.design.colors.border,'stroke-width':1}));
|
|
1045
1244
|
children.push(text(label,horizontal?{x:at-32,y:plot.y+plot.height+6,width:64,height:26}:{x:box.x+4,y:at-12,width:52,height:24},item.path,13,horizontal?'center':'right'));
|
|
1046
1245
|
}
|
|
@@ -1202,7 +1401,75 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1202
1401
|
|
|
1203
1402
|
function fontStack(family, type) {
|
|
1204
1403
|
const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
|
|
1205
|
-
return `${family}, ${fallback}`;
|
|
1404
|
+
return `${[family].flat().join(", ")}, ${fallback}`;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Unicode directional isolates (FF-19): each paragraph (text between hard line
|
|
1408
|
+
// breaks) of a right-to-left-language deck takes one base direction from
|
|
1409
|
+
// paragraphDirection(text, deckDirection), the rule the PPTX export uses for
|
|
1410
|
+
// a:pPr rtl. Every wrapped line of a right-to-left paragraph is laid out as a
|
|
1411
|
+
// right-to-left isolate, so its lines never differ in direction. Absolute
|
|
1412
|
+
// alignment and measured advances are unchanged.
|
|
1413
|
+
const RIGHT_TO_LEFT_ISOLATE = "\u2067", POP_DIRECTIONAL_ISOLATE = "\u2069";
|
|
1414
|
+
// Core owns the rule (paragraphDirection, core #134), so preview and export agree.
|
|
1415
|
+
// Without it (published core 0.11.0) every paragraph is left to right, as in export.
|
|
1416
|
+
const coreParagraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
|
|
1417
|
+
/** Maps a source offset of the text to whether its paragraph is right to left. */
|
|
1418
|
+
function paragraphRtl(bound, text) {
|
|
1419
|
+
if (bound.scriptFonts?.rtl !== true || !coreParagraphDirection) return () => false;
|
|
1420
|
+
const source = String(text ?? ""), spans = [];
|
|
1421
|
+
let start = 0;
|
|
1422
|
+
for (const match of source.matchAll(/\r\n|\r|\n/g)) { spans.push([start, match.index]); start = match.index + match[0].length; }
|
|
1423
|
+
spans.push([start, source.length]);
|
|
1424
|
+
const rtl = spans.map(([from, to]) => coreParagraphDirection(source.slice(from, to), "rtl") === "rtl");
|
|
1425
|
+
return offset => { for (let index = spans.length - 1; index >= 0; index--) if (offset >= spans[index][0]) return rtl[index]; return rtl[0]; };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* SVG content for `text` drawn in `style` (FF-19). Text in the style's own
|
|
1430
|
+
* family is emitted unchanged. A single run in another family returns that
|
|
1431
|
+
* family for the enclosing element. Several runs become tspans naming their
|
|
1432
|
+
* script slot's family. With `placement` ({x, width, fontSize}) the runs are
|
|
1433
|
+
* positioned at their measured advances, each with its own textLength, because
|
|
1434
|
+
* a textLength spanning differently fonted tspans does not rasterize reliably;
|
|
1435
|
+
* the caller then drops its own textLength and anchors at the start.
|
|
1436
|
+
*/
|
|
1437
|
+
function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
|
|
1438
|
+
const value = String(text ?? "");
|
|
1439
|
+
const scripts = bound.scriptFonts;
|
|
1440
|
+
rtl = rtl && value !== "";
|
|
1441
|
+
const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
|
|
1442
|
+
const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
|
|
1443
|
+
if (runs.length === 1) {
|
|
1444
|
+
const [run] = runs;
|
|
1445
|
+
return { content: isolate(escapeText(value)), family: run.own ? undefined : fontStack(run.stack ?? run.family, type) };
|
|
1446
|
+
}
|
|
1447
|
+
const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
|
|
1448
|
+
if (!widths) {
|
|
1449
|
+
return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
|
|
1450
|
+
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type) }, escapeText(run.text))).join("")) };
|
|
1451
|
+
}
|
|
1452
|
+
const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
|
|
1453
|
+
let advance = 0, offset = 0;
|
|
1454
|
+
const content = runs.map((run, index) => {
|
|
1455
|
+
const width = widths[index] * factor, left = rtl ? placement.width - advance - width : advance;
|
|
1456
|
+
advance += width;
|
|
1457
|
+
const start = offset;
|
|
1458
|
+
offset += run.text.length;
|
|
1459
|
+
return tag("tspan", {
|
|
1460
|
+
x: stableNumber(placement.x + left),
|
|
1461
|
+
textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
|
|
1462
|
+
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1463
|
+
...(trace ? trace(start, offset) : {})
|
|
1464
|
+
}, isolate(escapeText(run.text)));
|
|
1465
|
+
}).join("");
|
|
1466
|
+
return { content, positioned: true };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** One positioned code/metric segment tspan; its script runs flow inside it (no textLength). */
|
|
1470
|
+
function segmentSpan(attrs, segment, text, style, bound, type, options) {
|
|
1471
|
+
const scripted = segment.kind === "tab" ? { content: escapeText(text) } : scriptLine(text, style, bound, type, options);
|
|
1472
|
+
return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
|
|
1206
1473
|
}
|
|
1207
1474
|
|
|
1208
1475
|
function renderEmbeddedFonts(fonts = []) {
|
|
@@ -1226,25 +1493,51 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1226
1493
|
const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1227
1494
|
let textOffset=0;
|
|
1228
1495
|
const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
|
|
1496
|
+
const richRtl=paragraphRtl(bound,value.map(run=>typeof run==='string'?run:run.text).join(''));
|
|
1229
1497
|
// With no measurement provider, fragment advances are estimates. Let SVG
|
|
1230
1498
|
// shape adjacent runs naturally inside each estimated line instead of turning
|
|
1231
1499
|
// those estimates into visible gaps. Supplied measurements keep exact origins.
|
|
1232
|
-
const
|
|
1500
|
+
const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
|
|
1501
|
+
const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
|
|
1233
1502
|
const content=fit.richLines.map((line,lineIndex)=>{
|
|
1234
|
-
const
|
|
1235
|
-
const
|
|
1503
|
+
const lineHasTab=line.fragments.some(fragment=>fragment.kind==='tab');
|
|
1504
|
+
const flow=naturalFlow&&!lineHasTab;
|
|
1505
|
+
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1236
1506
|
const placed=fit.placement?.lines[lineIndex];
|
|
1507
|
+
const originX=placed?.x??box.x+offset,baseline=placed?.baseline??box.y+line.baseline;
|
|
1508
|
+
const firstFragment=line.fragments[0],rtl=richRtl(firstFragment?runOffsets[firstFragment.runIndex]+firstFragment.start:0);
|
|
1509
|
+
const renderFragment=(fragment,asFlow,edges={})=>{
|
|
1510
|
+
const run=fragment.run;
|
|
1237
1511
|
// Accepted outline placement owns the horizontal advance. Geometric precision
|
|
1238
1512
|
// avoids hinted browser advances; textLength also removes fractional-size
|
|
1239
1513
|
// quantization drift. Height and baseline retain the selected font size.
|
|
1240
|
-
|
|
1514
|
+
// A right-to-left line places its fragments from the right edge (FF-19).
|
|
1515
|
+
// Estimated (natural-flow) lines keep logical order; the browser reorders them.
|
|
1516
|
+
const fragmentX=rtl&&!naturalFlow?line.width-fragment.x-fragment.width:fragment.x;
|
|
1517
|
+
const position=asFlow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber(originX+fragmentX),y:stableNumber(baseline+fragment.baselineShift)};
|
|
1241
1518
|
const runFill = run.color == null
|
|
1242
1519
|
? config.fill
|
|
1243
1520
|
: resolveColorRef(run.color, bound, config.fill);
|
|
1244
|
-
|
|
1521
|
+
let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
|
|
1522
|
+
const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
|
|
1523
|
+
placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined});
|
|
1524
|
+
if(scripted.positioned)fixedAdvance=false;
|
|
1525
|
+
const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
|
|
1526
|
+
const rendered=tag(asFlow?'tspan':'text',{...(config.options.trace?{'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(fragment.fontSize),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
|
|
1245
1527
|
if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1246
1528
|
return rendered;
|
|
1247
|
-
}
|
|
1529
|
+
};
|
|
1530
|
+
if(naturalFlow&&lineHasTab) {
|
|
1531
|
+
const chunks=[];let textChunk=[];
|
|
1532
|
+
const flush=()=>{if(!textChunk.length)return;const first=textChunk[0];chunks.push(tag('text',{x:stableNumber(originX+first.x),y:stableNumber(baseline),'xml:space':'preserve'},textChunk.map((fragment,index)=>renderFragment(fragment,true,{first:rtl&&!index,last:rtl&&index===textChunk.length-1})).join('')));textChunk=[];};
|
|
1533
|
+
for(const fragment of line.fragments) {
|
|
1534
|
+
if(fragment.kind==='tab'){flush();chunks.push(renderFragment(fragment,false));}
|
|
1535
|
+
else textChunk.push(fragment);
|
|
1536
|
+
}
|
|
1537
|
+
flush();return chunks.join('\n');
|
|
1538
|
+
}
|
|
1539
|
+
// A flowing right-to-left line is one isolate across its fragments.
|
|
1540
|
+
const fragments=line.fragments.map((fragment,index)=>renderFragment(fragment,flow,flow?{first:rtl&&!index,last:rtl&&index===line.fragments.length-1}:{}));
|
|
1248
1541
|
if(!flow)return fragments.join('\n');
|
|
1249
1542
|
const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
|
|
1250
1543
|
const first=line.fragments[0];
|
|
@@ -1257,9 +1550,11 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1257
1550
|
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1258
1551
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1259
1552
|
const placed=fit.placement?.lines[index];
|
|
1260
|
-
return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height
|
|
1553
|
+
return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height,
|
|
1554
|
+
...(naturalFlow&&line.fragments.some(fragment=>fragment.kind==='tab')?{spacing:'natural-chunks-estimated-tabs'}:{})};
|
|
1261
1555
|
}):undefined;
|
|
1262
|
-
|
|
1556
|
+
const spacing=naturalFlow?(hasTabs?'mixed-estimated-tabs':'natural'):'measured';
|
|
1557
|
+
return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace),'data-opf-rich-spacing':spacing}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
|
|
1263
1558
|
}
|
|
1264
1559
|
|
|
1265
1560
|
function renderTextBox(text, box, bound, config) {
|
|
@@ -1282,21 +1577,39 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1282
1577
|
const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1283
1578
|
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1284
1579
|
const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
|
|
1580
|
+
const type = config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type;
|
|
1581
|
+
const source = String(text ?? ""), boxRtl = paragraphRtl(bound, source);
|
|
1582
|
+
let cursor = 0;
|
|
1285
1583
|
const lines = fit.lines.map((line, index) => {
|
|
1286
1584
|
const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1585
|
+
// The line's paragraph decides its direction: source offsets when layout has them, else the next match.
|
|
1586
|
+
const found=sourceLine?sourceLine.start:source.indexOf(line,cursor),lineStart=found>=0?found:cursor;cursor=lineStart+line.length;
|
|
1587
|
+
const rtl=boxRtl(lineStart);
|
|
1287
1588
|
const origin=placed?.x??x-(sourceLine?.width??0)*factor;
|
|
1288
1589
|
const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1590
|
+
let content,family,positioned=false;
|
|
1591
|
+
if(tabs) content=sourceLine.segments.map(segment=>{
|
|
1592
|
+
const segmentText=line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start),fixed=segment.kind==='tab'||placed;
|
|
1593
|
+
const traced=(start,end)=>config.options.trace?{'data-opf-source-start':start,'data-opf-source-end':end,'data-opf-segment':segment.kind}:{};
|
|
1594
|
+
const scripted=segment.kind==='tab'?{content:escapeText(segmentText)}:scriptLine(segmentText,style,bound,type,{rtl,
|
|
1595
|
+
placement:placed?{x:origin+segment.x,width:segment.width,fontSize:size}:undefined,
|
|
1596
|
+
trace:config.options.trace?(start,end)=>traced(segment.start+start,segment.start+end):undefined});
|
|
1597
|
+
if(scripted.positioned)return scripted.content;
|
|
1598
|
+
return tag('tspan',{
|
|
1599
|
+
x:stableNumber(origin+segment.x),textLength:fixed?stableNumber(segment.width):undefined,
|
|
1600
|
+
lengthAdjust:fixed?'spacingAndGlyphs':undefined,'font-family':scripted.family,
|
|
1601
|
+
...traced(segment.start,segment.end),
|
|
1602
|
+
},scripted.content);
|
|
1603
|
+
}).join('');
|
|
1604
|
+
else ({content,family,positioned=false}=scriptLine(line,style,bound,type,{rtl,placement:placed?.width>0?{x:placed.x,width:placed.width,fontSize:size}:undefined}));
|
|
1605
|
+
// Positioned script runs carry their own x and textLength (FF-19).
|
|
1606
|
+
const start=tabs||positioned;
|
|
1294
1607
|
return tag("text", {
|
|
1295
|
-
x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
|
|
1296
|
-
"text-anchor":
|
|
1608
|
+
x: stableNumber(tabs?origin:positioned?placed.x:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
|
|
1609
|
+
"text-anchor": start?'start':anchor, "font-family": family ?? fontStack(style.fontFamily, type),
|
|
1297
1610
|
"font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1298
1611
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
|
|
1299
|
-
textLength:!
|
|
1612
|
+
textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1300
1613
|
...traceAttrs(config.options, config.path),
|
|
1301
1614
|
...(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}:{}),
|
|
1302
1615
|
}, content);
|