@openpresentation/opf-render 0.8.1 → 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 +64 -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 +21 -2
- package/dist/script-font-pack.js +40 -0
- package/dist/script-fonts.js +426 -0
- package/dist/svg.d.ts +20 -2
- package/dist/svg.js +401 -44
- package/package.json +165 -5
package/dist/svg.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
2
2
|
import {
|
|
3
3
|
catalogs as bundledCatalogs,
|
|
4
|
+
resolveColorRef as resolveCoreColorRef,
|
|
4
5
|
validatePresentation
|
|
5
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";
|
|
6
11
|
|
|
7
12
|
export const packageName = "@openpresentation/opf-render";
|
|
8
13
|
|
|
@@ -50,6 +55,12 @@ export const engineDefaults = Object.freeze({
|
|
|
50
55
|
])
|
|
51
56
|
});
|
|
52
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
|
+
|
|
53
64
|
export class OPFRenderError extends Error {
|
|
54
65
|
constructor(code, message, details = {}) {
|
|
55
66
|
super(message);
|
|
@@ -226,12 +237,47 @@ function sourceRecordsFor(kind, source, options) {
|
|
|
226
237
|
}
|
|
227
238
|
|
|
228
239
|
function engineDefaultId(kind) {
|
|
229
|
-
if (kind === "fontSchemes") return
|
|
240
|
+
if (kind === "fontSchemes") return DEFAULT_FONT_SCHEME;
|
|
230
241
|
if (kind === "chartTypes") return engineDefaults.chartTypes[0];
|
|
231
242
|
const key = kind.endsWith("s") ? kind.slice(0, -1) : kind;
|
|
232
243
|
return engineDefaults[key];
|
|
233
244
|
}
|
|
234
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
|
+
|
|
235
281
|
function resolveCatalogRecord(kind, reference, context, path, fallbackId = engineDefaultId(kind)) {
|
|
236
282
|
const id = referenceId(reference) ?? fallbackId;
|
|
237
283
|
const documentCatalog = context.presentation.catalogs?.[kind];
|
|
@@ -291,6 +337,32 @@ function normalizeColor(value, fallback) {
|
|
|
291
337
|
return fallback;
|
|
292
338
|
}
|
|
293
339
|
|
|
340
|
+
/** Resolve content ColorRef via core; keep authored #RRGGBB / #RRGGBBAA casing. */
|
|
341
|
+
function resolveColorRef(value, bound, fallback) {
|
|
342
|
+
if (value == null || value === "") return fallback;
|
|
343
|
+
if (typeof value !== "string") return fallback;
|
|
344
|
+
const trimmed = value.trim();
|
|
345
|
+
// Core normalizeHexColor uppercases literals and strips #RRGGBBAA alpha.
|
|
346
|
+
// Packed-browser editor checks keep toolbar hex like #2563eb as authored.
|
|
347
|
+
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(trimmed)) return trimmed;
|
|
348
|
+
if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) return normalizeColor(trimmed, fallback);
|
|
349
|
+
const colors = bound.design.colors ?? {};
|
|
350
|
+
return resolveCoreColorRef(trimmed, {
|
|
351
|
+
colorScheme: bound.design.colorScheme ?? {},
|
|
352
|
+
roles: {
|
|
353
|
+
primary: colors.primary,
|
|
354
|
+
secondary: colors.secondary,
|
|
355
|
+
accent: colors.accent,
|
|
356
|
+
background: colors.background,
|
|
357
|
+
surface: colors.surface,
|
|
358
|
+
text: colors.text,
|
|
359
|
+
textSecondary: colors.mutedText
|
|
360
|
+
},
|
|
361
|
+
variables: bound.design.variables,
|
|
362
|
+
fallback
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
294
366
|
function resolveBackground(background, colorScheme) {
|
|
295
367
|
if (!background) return colorFromScheme(colorScheme, "light1", "#FFFFFF");
|
|
296
368
|
if (typeof background === "string") return colorFromScheme(colorScheme, background, "#FFFFFF");
|
|
@@ -311,7 +383,7 @@ function colorLuminance(color) {
|
|
|
311
383
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
312
384
|
}
|
|
313
385
|
|
|
314
|
-
function resolveDesign(presentation, slide, context) {
|
|
386
|
+
function resolveDesign(presentation, slide, context, index) {
|
|
315
387
|
const deckDesign = presentation.design ?? {};
|
|
316
388
|
const slideDesign = slide.design ?? {};
|
|
317
389
|
|
|
@@ -327,11 +399,13 @@ function resolveDesign(presentation, slide, context) {
|
|
|
327
399
|
context,
|
|
328
400
|
"design.colorScheme"
|
|
329
401
|
);
|
|
330
|
-
const
|
|
331
|
-
"
|
|
332
|
-
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,
|
|
333
407
|
context,
|
|
334
|
-
|
|
408
|
+
fontPath
|
|
335
409
|
);
|
|
336
410
|
const dimensions = resolveDimensions(slideDesign.dimensions ?? deckDesign.dimensions ?? theme.dimensions);
|
|
337
411
|
const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
|
|
@@ -346,6 +420,7 @@ function resolveDesign(presentation, slide, context) {
|
|
|
346
420
|
colorScheme,
|
|
347
421
|
fontScheme,
|
|
348
422
|
dimensions,
|
|
423
|
+
variables: presentation.variables ?? {},
|
|
349
424
|
background: backgroundDefinition,
|
|
350
425
|
backgroundColor,
|
|
351
426
|
colors: {
|
|
@@ -358,10 +433,58 @@ function resolveDesign(presentation, slide, context) {
|
|
|
358
433
|
accent: normalizeColor(colorScheme.accent, null) ?? colorFromScheme(colorScheme, "accent3", "#F59E0B"),
|
|
359
434
|
border: colorFromScheme(colorScheme, "accent5", "#CBD5E1")
|
|
360
435
|
},
|
|
361
|
-
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"
|
|
362
477
|
};
|
|
363
478
|
}
|
|
364
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
|
+
|
|
365
488
|
function inferLayoutId(slide) {
|
|
366
489
|
if (slide.layout) return slide.layout;
|
|
367
490
|
if (slide.blocks?.length) return "blank";
|
|
@@ -483,10 +606,16 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
483
606
|
];
|
|
484
607
|
const blocks = blockContent(slide, slidePath);
|
|
485
608
|
|
|
486
|
-
const design = resolveDesign(presentation, slide, context);
|
|
487
|
-
|
|
488
|
-
|
|
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) });
|
|
489
616
|
return {
|
|
617
|
+
scriptFonts,
|
|
618
|
+
textMeasurement,
|
|
490
619
|
geometry,
|
|
491
620
|
assets: presentation.assets ?? {},
|
|
492
621
|
index,
|
|
@@ -502,7 +631,9 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
502
631
|
|
|
503
632
|
export function resolvePresentation(input, options = {}) {
|
|
504
633
|
const presentation = parseInput(input);
|
|
505
|
-
|
|
634
|
+
if (options.validate !== false) {
|
|
635
|
+
assertValidBoundary(presentation);
|
|
636
|
+
}
|
|
506
637
|
|
|
507
638
|
const context = { presentation, options };
|
|
508
639
|
const slides = presentation.slides.map((slide, index) => {
|
|
@@ -535,13 +666,18 @@ export function renderSvgDeck(input, options = {}) {
|
|
|
535
666
|
}
|
|
536
667
|
|
|
537
668
|
function renderResolvedSlide(resolved, slideIndex, options) {
|
|
538
|
-
options = { ...options, _diagnosticPaths: new Set() };
|
|
539
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;
|
|
540
675
|
const { width, height } = bound.design.dimensions;
|
|
541
676
|
const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
542
677
|
const children = [
|
|
543
678
|
renderEmbeddedFonts(options.embeddedFonts),
|
|
544
679
|
renderBackground(bound, width, height, options),
|
|
680
|
+
renderSlideImage(bound, options),
|
|
545
681
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
546
682
|
...renderSlideContent(bound, width, height, options),
|
|
547
683
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
@@ -557,6 +693,8 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
557
693
|
viewBox: `0 0 ${width} ${height}`,
|
|
558
694
|
width,
|
|
559
695
|
height,
|
|
696
|
+
lang,
|
|
697
|
+
"xml:lang": lang,
|
|
560
698
|
...traceAttrs(options, bound.path)
|
|
561
699
|
},
|
|
562
700
|
`\n${children.join("\n")}\n`
|
|
@@ -604,7 +742,10 @@ function renderBackground(bound, width, height, options) {
|
|
|
604
742
|
}
|
|
605
743
|
if(isPlainObject(background) && background.type==='pattern'){
|
|
606
744
|
const pattern=background.pattern??{},id=`opf-s${bound.index+1}-pattern`,color=normalizeColor(pattern.foregroundColor,bound.design.colors.text),preset=pattern.preset;
|
|
607
|
-
|
|
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'):'';
|
|
608
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);
|
|
609
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})`}));
|
|
610
751
|
}
|
|
@@ -619,11 +760,68 @@ function renderBackground(bound, width, height, options) {
|
|
|
619
760
|
});
|
|
620
761
|
}
|
|
621
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
|
+
|
|
622
820
|
function renderSlideContent(bound, width, height, options) {
|
|
623
|
-
for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
|
|
821
|
+
for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
|
|
624
822
|
return bound.geometry.items.map(item => {
|
|
625
823
|
const frame=item.frameBox;
|
|
626
|
-
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)}) : '';
|
|
627
825
|
return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
|
|
628
826
|
});
|
|
629
827
|
}
|
|
@@ -665,7 +863,10 @@ function renderPayload(item, box, bound, options) {
|
|
|
665
863
|
function renderTextPayload(item, box, bound, options) {
|
|
666
864
|
return (Array.isArray(item.value) ? renderRichTextBox : renderTextBox)(Array.isArray(item.value) ? item.value : flattenText(item.value), box, bound, {
|
|
667
865
|
path: item.path,
|
|
668
|
-
|
|
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),
|
|
669
870
|
fontSize: item.field === "title" ? 54 : item.field === "tag" ? 16 : 25,
|
|
670
871
|
fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
|
|
671
872
|
fontWeight: item.field === "title" ? 700 : 400,
|
|
@@ -823,12 +1024,13 @@ function renderCode(item, box, bound, options) {
|
|
|
823
1024
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
|
|
824
1025
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
825
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}:{}),
|
|
826
|
-
},line.segments.map(segment=>
|
|
1027
|
+
},line.segments.map(segment=>segmentSpan({
|
|
827
1028
|
x:stableNumber(part.box.x+segment.x),
|
|
828
1029
|
// SVG's CSS tab-size does not place literal tabs at their measured stops.
|
|
829
1030
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
830
1031
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
831
|
-
|
|
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('')));
|
|
832
1034
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
833
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}:{}),
|
|
834
1036
|
...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
|
|
@@ -845,18 +1047,32 @@ function renderMetric(item, box, bound, options) {
|
|
|
845
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});
|
|
846
1048
|
if (!part.visible) continue;
|
|
847
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;
|
|
848
1055
|
const lines=part.fit.sourceLines.map((line,index)=>{
|
|
849
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(''));
|
|
850
1066
|
return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
|
|
851
1067
|
'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
|
|
852
1068
|
'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
853
1069
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
|
|
854
1070
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
|
|
855
1071
|
'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
|
|
856
|
-
},line.segments.map(segment=>
|
|
1072
|
+
},line.segments.map(segment=>segmentSpan({x:stableNumber(origin.x+segment.x),
|
|
857
1073
|
...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
|
|
858
1074
|
...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
|
|
859
|
-
},
|
|
1075
|
+
},segment,part.text.slice(segment.start,segment.end),part.style,bound,bound.design.fontScheme.type,{rtl:partRtl(line.start)})).join(''));
|
|
860
1076
|
});
|
|
861
1077
|
children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
|
|
862
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}:{}),
|
|
@@ -903,7 +1119,14 @@ function renderTable(item, box, bound, options) {
|
|
|
903
1119
|
const defaultEdges = [], explicitEdges = [];
|
|
904
1120
|
for (const row of layout.rows) for (const cell of row.cells) {
|
|
905
1121
|
const style = cell.style ?? {};
|
|
906
|
-
const
|
|
1122
|
+
const defaultFill = cell.header ? bound.design.colors.primary : bound.design.colors.surface;
|
|
1123
|
+
const fill = style.fill == null
|
|
1124
|
+
? defaultFill
|
|
1125
|
+
: resolveColorRef(style.fill, bound, defaultFill);
|
|
1126
|
+
const defaultText = textColorForFill(fill, cell.header ? "#FFFFFF" : bound.design.colors.text);
|
|
1127
|
+
const textFill = style.color == null
|
|
1128
|
+
? defaultText
|
|
1129
|
+
: resolveColorRef(style.color, bound, defaultText);
|
|
907
1130
|
children.push(tag("rect", {
|
|
908
1131
|
x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
|
|
909
1132
|
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
@@ -923,17 +1146,18 @@ function renderTable(item, box, bound, options) {
|
|
|
923
1146
|
children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
|
|
924
1147
|
path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
|
|
925
1148
|
fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
|
|
926
|
-
fill:
|
|
1149
|
+
fill:textFill, align:style.align, options
|
|
927
1150
|
}));
|
|
928
1151
|
}
|
|
929
1152
|
|
|
930
|
-
return tag("g", traceAttrs(options, item.path), [...children, ...renderTableBorders(defaultEdges, explicitEdges, scale, bound
|
|
1153
|
+
return tag("g", traceAttrs(options, item.path), [...children, ...renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)].join("\n"));
|
|
931
1154
|
}
|
|
932
1155
|
|
|
933
1156
|
// Explicit edges own their shared segment, including invisible/zero-width edges.
|
|
934
1157
|
// Split implicit neighbors at merge boundaries so they cannot fill dashed gaps,
|
|
935
1158
|
// cover alpha strokes, or reintroduce a border the author removed.
|
|
936
|
-
function renderTableBorders(defaultEdges, explicitEdges, scale,
|
|
1159
|
+
function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options) {
|
|
1160
|
+
const defaultColor = bound.design.colors.border;
|
|
937
1161
|
const epsilon = 1e-7;
|
|
938
1162
|
const segment = ({coordinates:[x1,y1,x2,y2]}) => y1 === y2
|
|
939
1163
|
? {horizontal:true, fixed:y1, start:x1, end:x2}
|
|
@@ -955,9 +1179,12 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, defaultColor, op
|
|
|
955
1179
|
return [...defaults,...explicitEdges].flatMap(({coordinates:[x1,y1,x2,y2],border,path}) => {
|
|
956
1180
|
const width = border ? border.width * scale : 1;
|
|
957
1181
|
if (width === 0) return [];
|
|
1182
|
+
const stroke = border?.color == null
|
|
1183
|
+
? defaultColor
|
|
1184
|
+
: resolveColorRef(border.color, bound, defaultColor);
|
|
958
1185
|
return [tag('line', {
|
|
959
1186
|
x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
|
|
960
|
-
stroke
|
|
1187
|
+
stroke, 'stroke-width':stableNumber(width),
|
|
961
1188
|
'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
|
|
962
1189
|
...traceAttrs(options,path)
|
|
963
1190
|
})];
|
|
@@ -996,10 +1223,23 @@ function renderImportedChart(item, box, bound, options) {
|
|
|
996
1223
|
const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
|
|
997
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)};
|
|
998
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;
|
|
999
|
-
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;
|
|
1000
1235
|
const zero=horizontal?xValue(0):yValue(0);
|
|
1001
1236
|
for(let tick=0;tick<=4;tick++){
|
|
1002
|
-
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);
|
|
1003
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}));
|
|
1004
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'));
|
|
1005
1245
|
}
|
|
@@ -1161,7 +1401,75 @@ function renderBranding(bound,presentation,width,height,options) {
|
|
|
1161
1401
|
|
|
1162
1402
|
function fontStack(family, type) {
|
|
1163
1403
|
const fallback = type === "serif" ? "serif" : type === "monospace" ? "monospace" : "sans-serif";
|
|
1164
|
-
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);
|
|
1165
1473
|
}
|
|
1166
1474
|
|
|
1167
1475
|
function renderEmbeddedFonts(fonts = []) {
|
|
@@ -1185,22 +1493,51 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1185
1493
|
const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1186
1494
|
let textOffset=0;
|
|
1187
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(''));
|
|
1188
1497
|
// With no measurement provider, fragment advances are estimates. Let SVG
|
|
1189
1498
|
// shape adjacent runs naturally inside each estimated line instead of turning
|
|
1190
1499
|
// those estimates into visible gaps. Supplied measurements keep exact origins.
|
|
1191
|
-
const
|
|
1500
|
+
const naturalFlow=!config.options.textMeasurement?.measure&&!fit.placement;
|
|
1501
|
+
const hasTabs=fit.richLines.some(line=>line.fragments.some(fragment=>fragment.kind==='tab'));
|
|
1192
1502
|
const content=fit.richLines.map((line,lineIndex)=>{
|
|
1193
|
-
const
|
|
1194
|
-
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;
|
|
1195
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;
|
|
1196
1511
|
// Accepted outline placement owns the horizontal advance. Geometric precision
|
|
1197
1512
|
// avoids hinted browser advances; textLength also removes fractional-size
|
|
1198
1513
|
// quantization drift. Height and baseline retain the selected font size.
|
|
1199
|
-
|
|
1200
|
-
|
|
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)};
|
|
1518
|
+
const runFill = run.color == null
|
|
1519
|
+
? config.fill
|
|
1520
|
+
: resolveColorRef(run.color, bound, config.fill);
|
|
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);
|
|
1201
1527
|
if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
|
|
1202
1528
|
return rendered;
|
|
1203
|
-
}
|
|
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}:{}));
|
|
1204
1541
|
if(!flow)return fragments.join('\n');
|
|
1205
1542
|
const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
|
|
1206
1543
|
const first=line.fragments[0];
|
|
@@ -1213,9 +1550,11 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1213
1550
|
const start=cursor;cursor+=(fit.lines[index]??'').length;
|
|
1214
1551
|
const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
|
|
1215
1552
|
const placed=fit.placement?.lines[index];
|
|
1216
|
-
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'}:{})};
|
|
1217
1555
|
}):undefined;
|
|
1218
|
-
|
|
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'));
|
|
1219
1558
|
}
|
|
1220
1559
|
|
|
1221
1560
|
function renderTextBox(text, box, bound, config) {
|
|
@@ -1238,21 +1577,39 @@ function renderTextBox(text, box, bound, config) {
|
|
|
1238
1577
|
const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
|
|
1239
1578
|
const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
|
|
1240
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;
|
|
1241
1583
|
const lines = fit.lines.map((line, index) => {
|
|
1242
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);
|
|
1243
1588
|
const origin=placed?.x??x-(sourceLine?.width??0)*factor;
|
|
1244
1589
|
const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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;
|
|
1250
1607
|
return tag("text", {
|
|
1251
|
-
x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
|
|
1252
|
-
"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),
|
|
1253
1610
|
"font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
|
|
1254
1611
|
'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
|
|
1255
|
-
textLength:!
|
|
1612
|
+
textLength:!start&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!start&&placed?.width>0?'spacingAndGlyphs':undefined,
|
|
1256
1613
|
...traceAttrs(config.options, config.path),
|
|
1257
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}:{}),
|
|
1258
1615
|
}, content);
|