@openpresentation/opf-render 0.22.2 → 0.24.1
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 +10 -0
- package/dist/charts.js +18 -11
- package/dist/svg.js +54 -28
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -606,6 +606,16 @@ Classic chart previews keep finite axis coordinates for subnormal values and val
|
|
|
606
606
|
|
|
607
607
|
Category-axis labels follow PowerPoint's automatic labelling instead of wrapping inside a band (column, bar, line and area charts, including stacked and 100%, and the chartex histogram, pareto, waterfall, funnel and box-and-whisker previews). A label is never broken inside a word: it stays on one line, or splits at its spaces onto two lines when a band is too narrow, so `Category 1` cannot become `Ca` over `t 1`. When the labels do not fit horizontally they turn to -45 degrees, then -90 degrees (the plot area shrinks to make room, by at most 40% of the chart height, and a slanted first label must stay inside the chart), and when even that collides the axis draws every n-th label (Office's automatic `tickLblSkip`): n is the smallest interval that leaves no two drawn labels overlapping, the first label is always drawn, and ties go to the less rotated arrangement. In horizontal bar charts and funnels, where the category axis is vertical, rows shorter than a line of text are skipped the same way, and the label gutter grows with the longest label up to 30% of the chart width. Only a label wider than that gutter (or wider than the whole chart on a horizontal axis) is ellipsized, and then the renderer reports one `chart-label-truncated` diagnostic per chart with the affected label paths in `labels`; nothing is shortened silently, and no arrangement raises `text-overflow` for an axis label. Radar spoke labels use the same one-line, two-line or ellipsis rule and leave out a spoke label that would collide with one already drawn. Rotated labels are wrapped in a `<g transform="rotate(...)">` around their usual text group, so the traced `data-opf-box-*` attributes describe the unrotated box. PowerPoint applies its own automatic rotation and skip to the exported chart: opf-pptx writes an automatic axis text body (`<a:bodyPr/>`, no `rot`) and no `tickLblSkip` for these axes, so nothing forces a wrap, and the preview and the export make the same kind of choice, not identical ones (the export uses 9 pt labels, the preview the readability floor).
|
|
608
608
|
|
|
609
|
+
### Chart previews and opf-pptx 0.22: same colors, axis and text (since 0.22.2)
|
|
610
|
+
|
|
611
|
+
Since 0.22.2 the classic chart preview draws what `@openpresentation/opf-pptx` 0.22.0 exports, so a chart looks alike in the preview, PowerPoint and Keynote in these three respects (the paragraph above lists the core functions involved):
|
|
612
|
+
|
|
613
|
+
- **Series colors.** The preview draws core's palette of the deck's color scheme (`accent1` to `accent6`, then tints) with the chart's `chart.colors` over it, adjusted for contrast against the chart panel; `chart.highlight` still wins. opf-pptx writes the same colors, as theme colors (`a:schemeClr`) where the deck theme holds them, so re-theming a deck recolors both. Before 0.22.2 the preview drew a fixed twelve-color palette and ignored `design.colorScheme` and `chart.colors`.
|
|
614
|
+
- **Value axis.** Column, bar, line and area charts draw the axis core composes for the chart's box (`min`, `max`, `majorUnit`), and opf-pptx writes the same three numbers (`c:min`, `c:max`, `c:majorUnit`), so the gridlines agree. On a short or narrow plot core's estimate of the plot (from the box, without measuring text) can take one larger step than the old fit to the laid-out plot did. Other chart types keep their own scale in the preview, and opf-pptx leaves their axis to the application.
|
|
615
|
+
- **Chart text.** Axis, legend, category and data-label text is 20 px (15 pt) in a roomy box, shrunk only for a small one and never below the minimum font size; before 0.22.2 it was 14 px floored at 16 px (12 pt). The axis core composes is estimated for this size.
|
|
616
|
+
|
|
617
|
+
Chart previews are still not pixel-identical to PowerPoint or Keynote.
|
|
618
|
+
|
|
609
619
|
## Long-lived workers: measured memory and latency (opf-render#171)
|
|
610
620
|
|
|
611
621
|
A render or export service keeps one fonts handle per worker process and reuses it ("load fonts once per worker"). `npm run bench:soak` (`scripts/soak.mjs`; manual or scheduled, never part of the default CI gate) measures that pattern. Each worker process holds one `loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: 'auto' })` handle for its whole life, blocks the network (a guard counts and refuses any attempt) and repeats jobs: `fonts.ensure(deck)`, `toSvg`, `toPng` of those SVGs and `toPptx` (opf-pptx 0.18.0, which is not a dependency of this package: `npm install --no-save @openpresentation/opf-pptx@0.18.0` and run with `--link-renderer`, since opf-pptx imports the renderer as an optional peer; without opf-pptx the PPTX leg is skipped). The jobs take the 127 core example decks in rotation (5 jobs in 6) and a 30-slide script deck built from the script corpora (every sixth job; one slide per script from Latin to Tibetan, with CJK, Arabic and Indic text, so `ensure` loads the script faces), 500 jobs per worker at concurrency 1 and 4. Memory is sampled after every job following a forced garbage collection (`heapUsed`, the retained heap, and `rss`).
|
package/dist/charts.js
CHANGED
|
@@ -189,6 +189,12 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
189
189
|
// RR-90: core's chart text size (20 px, shrunk only for a small box, never below minFontSize), the size core's value-axis density assumes.
|
|
190
190
|
const requested = chartTextSize({ width: box.width, height: box.height, scale: u, minFontSize: composition.minFontSize }), fontPx = requested * u;
|
|
191
191
|
const surface = bound.design.colors.surface;
|
|
192
|
+
// RR-96: every neutral comes from the slide's variant (core `roles.neutrals`: tx1 mixed into bg1, the colours the PPTX export writes as
|
|
193
|
+
// scheme colours): chart text, gridlines, axis lines and the outline between marks (bg1).
|
|
194
|
+
const neutrals = bound.design.roles.neutrals;
|
|
195
|
+
// The quiet colour of axis, legend and category text; `labelColor` stays the full text colour (highlight mutes toward it and labels inside
|
|
196
|
+
// marks contrast against it, as the PPTX export draws them).
|
|
197
|
+
const chartTextColor = textColorForFill(surface, neutrals.chartText.hex);
|
|
192
198
|
const labelColor = textColorForFill(surface, bound.design.colors.text);
|
|
193
199
|
const style = resolveTextStyle({ fontFamily: bound.design.fonts.body, fontWeight: 400, italic: false, path: item.path }, options.textMeasurement);
|
|
194
200
|
const measure = textWidthMeasurer(style, options.textMeasurement);
|
|
@@ -202,7 +208,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
202
208
|
return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
|
|
203
209
|
};
|
|
204
210
|
const c = {
|
|
205
|
-
item, box, bound, options, svg, rows, columns, formats, combo, children, u, fontPx, surface, labelColor,
|
|
211
|
+
item, box, bound, options, svg, rows, columns, formats, combo, children, u, fontPx, surface, labelColor, chartTextColor,
|
|
206
212
|
path: item.path,
|
|
207
213
|
pt: u * 4 / 3,
|
|
208
214
|
pad: 10 * u,
|
|
@@ -211,11 +217,12 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
211
217
|
// clear space kept between neighbouring category labels.
|
|
212
218
|
textHeight: fontPx * 1.22,
|
|
213
219
|
labelGap: fontPx * 0.1,
|
|
214
|
-
colors: chartSeriesPalette(surface, bound.design.colorScheme, item.value?.colors, { roles: bound.design.colors, variables: bound.design.variables }),
|
|
220
|
+
colors: chartSeriesPalette(surface, bound.design.colorScheme, item.value?.colors, { accents: bound.design.roles.map.accents, roles: bound.design.colors, variables: bound.design.variables }),
|
|
215
221
|
// RR-92: the value axis core composed for a column, bar, line or area chart ({ min, max, majorUnit }); absent for the other types.
|
|
216
222
|
valueAxis: item.chart?.valueAxis,
|
|
217
|
-
gridColor:
|
|
218
|
-
axisColor:
|
|
223
|
+
gridColor: neutrals.gridline.hex,
|
|
224
|
+
axisColor: neutrals.axisLine.hex,
|
|
225
|
+
outlineColor: neutrals.outline.hex,
|
|
219
226
|
number: chartNumber,
|
|
220
227
|
label: (value) => value === null || value === undefined ? "" : String(value),
|
|
221
228
|
width: (value) => measure(String(value), fontPx),
|
|
@@ -225,7 +232,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
225
232
|
for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
|
|
226
233
|
children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, tracePath(path)) : {}) }));
|
|
227
234
|
},
|
|
228
|
-
textElement(value, rect, path, align = "center", fill =
|
|
235
|
+
textElement(value, rect, path, align = "center", fill = chartTextColor) {
|
|
229
236
|
if (!(rect.width > 0 && rect.height > 0)) return "";
|
|
230
237
|
return svg.renderTextBox(String(value), rect, bound, {
|
|
231
238
|
path: tracePath(path), fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
|
|
@@ -406,7 +413,7 @@ function seriesLegend(c, series, key, marker = false) {
|
|
|
406
413
|
}
|
|
407
414
|
|
|
408
415
|
function gridline(c, x1, y1, x2, y2) {
|
|
409
|
-
c.mark("line", { x1, y1, x2, y2, stroke: c.gridColor, "stroke-
|
|
416
|
+
c.mark("line", { x1, y1, x2, y2, stroke: c.gridColor, "stroke-width": c.pt });
|
|
410
417
|
}
|
|
411
418
|
|
|
412
419
|
function axisLine(c, x1, y1, x2, y2) {
|
|
@@ -933,7 +940,7 @@ function renderCircularChart(c, spec) {
|
|
|
933
940
|
c.text("No positive chart values", area, c.path);
|
|
934
941
|
return;
|
|
935
942
|
}
|
|
936
|
-
const border = { stroke:
|
|
943
|
+
const border = { stroke: c.outlineColor, "stroke-width": 0.75 * c.pt };
|
|
937
944
|
let angle = -Math.PI / 2;
|
|
938
945
|
values.forEach((value, i) => {
|
|
939
946
|
const delta = value / total * Math.PI * 2, end = angle + delta;
|
|
@@ -1059,7 +1066,7 @@ function renderRadarChart(c, spec) {
|
|
|
1059
1066
|
for (const tick of scale.ticks) {
|
|
1060
1067
|
if (tick === scale.min) continue;
|
|
1061
1068
|
const ring = categories.map((_, i) => point(i, tick).map(n).join(" "));
|
|
1062
|
-
c.mark("path", { d: `M ${ring.join(" L ")} Z`, fill: "none", stroke: c.gridColor, "stroke-
|
|
1069
|
+
c.mark("path", { d: `M ${ring.join(" L ")} Z`, fill: "none", stroke: c.gridColor, "stroke-width": c.pt });
|
|
1063
1070
|
}
|
|
1064
1071
|
// A spoke label that would collide with one already drawn is left out; the first is always kept.
|
|
1065
1072
|
const drawn = [], truncated = [];
|
|
@@ -1385,7 +1392,7 @@ function renderTreemapChart(c) {
|
|
|
1385
1392
|
const rects = squarify(items, area.x, area.y, area.width, area.height);
|
|
1386
1393
|
rects.forEach((rect, i) => {
|
|
1387
1394
|
if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;
|
|
1388
|
-
c.mark("rect", { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fill: c.colors[i % c.colors.length], stroke:
|
|
1395
|
+
c.mark("rect", { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fill: c.colors[i % c.colors.length], stroke: c.outlineColor, "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
|
|
1389
1396
|
// RR-35: the treemap labels its tiles with category names by default; dataLabels picks the content, false removes them. PowerPoint
|
|
1390
1397
|
// draws the label at the bottom left of its tile whatever the position attribute says (native check 2026-10-01), so the preview does.
|
|
1391
1398
|
if (!c.dataLabelsOff && rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
|
|
@@ -1486,12 +1493,12 @@ function renderRegionMapChart(c) {
|
|
|
1486
1493
|
const tileWidth = area.width / columns, tileHeight = area.height / rowsCount;
|
|
1487
1494
|
const finite = values.filter((value) => value !== null);
|
|
1488
1495
|
const min = finite.length ? Math.min(...finite) : 0, max = finite.length ? Math.max(...finite) : 0;
|
|
1489
|
-
const light = mixHex(/^#[0-9A-Fa-f]{6}$/.test(c.surface) ? c.surface :
|
|
1496
|
+
const light = mixHex(/^#[0-9A-Fa-f]{6}$/.test(c.surface) ? c.surface : c.outlineColor, c.colors[0], 0.15);
|
|
1490
1497
|
categories.forEach((name, i) => {
|
|
1491
1498
|
const value = values[i];
|
|
1492
1499
|
const t = value === null ? 0 : max > min ? axisFraction(value, {min, max}) : 1;
|
|
1493
1500
|
const x = area.x + (i % columns) * tileWidth, y = area.y + Math.floor(i / columns) * tileHeight;
|
|
1494
|
-
c.mark("rect", { x, y, width: tileWidth, height: tileHeight, fill: value === null ? c.surface : mixHex(light, c.colors[0], t), stroke:
|
|
1501
|
+
c.mark("rect", { x, y, width: tileWidth, height: tileHeight, fill: value === null ? c.surface : mixHex(light, c.colors[0], t), stroke: c.outlineColor, "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
|
|
1495
1502
|
const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
|
|
1496
1503
|
if (tileHeight >= c.lineHeight * 2) {
|
|
1497
1504
|
c.text(name, { ...inset, y: y + tileHeight / 2 - c.lineHeight, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
|
package/dist/svg.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import {
|
|
4
4
|
layoutTable, fitList, fitRichText, composeSlide, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill,
|
|
5
5
|
resolveColorRef as resolveCoreColorRef, resolveScriptFonts, paragraphDirection, physicalAlignment, patternRuns,
|
|
6
|
-
metricTrendMark as coreMetricTrendMark, tokenizeCode, codeLineRuns,
|
|
7
|
-
|
|
6
|
+
metricTrendMark as coreMetricTrendMark, tokenizeCode, codeLineRuns, codeSyntaxPaletteForRoles, readableSchemeColor, resolveChartData,
|
|
7
|
+
timelineMarkerShapes, timelineTextColor, layoutWatermark,
|
|
8
8
|
codeHighlightLines, codeLineNumbers, codeHighlightBands, codeHighlightColors, fitImage, intrinsicImageAspect, intrinsicImageSize,
|
|
9
9
|
ENGINE_DEFAULT_THEME, ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_CHART_TYPES
|
|
10
10
|
} from "@openpresentation/opf/composition";
|
|
@@ -299,18 +299,18 @@ const IMAGE_BACKGROUND_SOURCE = /^(asset:|https:\/\/|data:|\.\/|\.\.\/)/;
|
|
|
299
299
|
|
|
300
300
|
// The slide's single background color, or null when there is none (a gradient, or no color the engine can read).
|
|
301
301
|
// With no definition, or a picture, the scheme's default slide background (core defaultSlideBackground) is the canvas.
|
|
302
|
-
function resolveBackground(background, colorScheme, design) {
|
|
303
|
-
const canvas = defaultSlideBackground(colorScheme);
|
|
302
|
+
function resolveBackground(background, colorScheme, design, canvas) {
|
|
304
303
|
if (!background) return canvas;
|
|
305
304
|
if (typeof background === "string") return IMAGE_BACKGROUND_SOURCE.test(background) ? canvas : colorFromScheme(colorScheme, background, canvas);
|
|
306
305
|
if (background.type === "theme") return colorFromScheme(colorScheme, background.slot, canvas);
|
|
307
|
-
if (background.type === "solid") return resolveBackgroundColor(background.color, design,
|
|
306
|
+
if (background.type === "solid") return resolveBackgroundColor(background.color, design, canvas);
|
|
308
307
|
if (background.type === "gradient") return null;
|
|
309
|
-
if (background.type === "pattern") return resolveBackgroundColor(background.pattern?.backgroundColor, design,
|
|
308
|
+
if (background.type === "pattern") return resolveBackgroundColor(background.pattern?.backgroundColor, design, canvas);
|
|
310
309
|
return canvas;
|
|
311
310
|
}
|
|
312
311
|
|
|
313
312
|
|
|
313
|
+
// An unparseable colour measures as white: this is a luminance for the contrast tests below, never a colour that is drawn.
|
|
314
314
|
function colorLuminance(color) {
|
|
315
315
|
const hex = normalizeColor(color, "#FFFFFF").replace("#", "");
|
|
316
316
|
const channels = [0, 2, 4].map(offset => {
|
|
@@ -342,6 +342,13 @@ const LINK_URL = /^(https?:|mailto:|tel:)/i;
|
|
|
342
342
|
// sorted keys, so the output does not depend on the order a catalog lists a field in. A reference that resolves nowhere never
|
|
343
343
|
// throws (unless `strictReferences`): the context falls back to core's engine defaults (ENGINE_DEFAULT_THEME, _COLOR_SCHEME,
|
|
344
344
|
// _FONT_SCHEME; a layout to automatic composition) and reports `unresolved-reference`, which is forwarded as a render diagnostic.
|
|
345
|
+
function drawnBackground(levels) {
|
|
346
|
+
for (const level of levels) {
|
|
347
|
+
if (level.variant !== undefined) return level.background;
|
|
348
|
+
if (level.background !== undefined && level.background !== null && level.background !== false) return level.background;
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
345
352
|
function resolveDesign(presentation, slide, slideContext) {
|
|
346
353
|
const deckDesign = presentation.design ?? {};
|
|
347
354
|
const slideDesign = slide.design ?? {};
|
|
@@ -350,14 +357,13 @@ function resolveDesign(presentation, slide, slideContext) {
|
|
|
350
357
|
const colorScheme = cloneWithSortedKeys(resolved.colorScheme);
|
|
351
358
|
const fontScheme = cloneWithSortedKeys(resolved.fontScheme);
|
|
352
359
|
const dimensions = { width: composeOptions.width, height: composeOptions.height };
|
|
353
|
-
|
|
354
|
-
|
|
360
|
+
// RR-96: core resolves the slide's roles (its variant's map over the scheme, the accents in order, the scheme-derived neutrals) once, in
|
|
361
|
+
// the slide context every engine shares. A variant hides the background of a less specific level (the slide is then the variant's bg1).
|
|
362
|
+
const roles = resolved.roles;
|
|
363
|
+
const backgroundDefinition = drawnBackground([slideDesign, deckDesign, theme]);
|
|
355
364
|
const variables = presentation.variables ?? {};
|
|
356
|
-
//
|
|
357
|
-
const backgroundColor = resolveBackground(backgroundDefinition, colorScheme, { colorScheme, colors: { primary, secondary, accent }, variables });
|
|
358
|
-
// One resolution for every role (core resolveColorRoles), shared with the PPTX export and the validator. Whether the background is dark
|
|
359
|
-
// (relative luminance under 0.179) is core's decision in the slide context, the one composition uses for logos and bullets.
|
|
360
|
-
const roles = resolveColorRoles(colorScheme, { background: backgroundColor });
|
|
365
|
+
// A background reference sees only the scheme and the three accent roles; a picture or no background is the variant's bg1.
|
|
366
|
+
const backgroundColor = resolveBackground(backgroundDefinition, colorScheme, { colorScheme, colors: { primary: roles.primary, secondary: roles.secondary, accent: roles.accent }, variables }, roles.background);
|
|
361
367
|
const darkBackground = composeOptions.darkBackground === true;
|
|
362
368
|
|
|
363
369
|
return {
|
|
@@ -371,17 +377,20 @@ function resolveDesign(presentation, slide, slideContext) {
|
|
|
371
377
|
background: backgroundDefinition,
|
|
372
378
|
backgroundColor,
|
|
373
379
|
colors: {
|
|
374
|
-
background
|
|
380
|
+
// The slide's drawn single-colour background, else the variant's bg1 (a picture, gradient or none).
|
|
381
|
+
background: backgroundColor ?? roles.background,
|
|
375
382
|
surface: roles.surface,
|
|
376
383
|
text: roles.text,
|
|
377
384
|
mutedText: roles.textSecondary,
|
|
378
|
-
primary,
|
|
379
|
-
secondary,
|
|
380
|
-
accent,
|
|
385
|
+
primary: roles.primary,
|
|
386
|
+
secondary: roles.secondary,
|
|
387
|
+
accent: roles.accent,
|
|
381
388
|
hyperlink: roles.hyperlink,
|
|
382
389
|
followedHyperlink: roles.followedHyperlink,
|
|
383
390
|
border: roles.border
|
|
384
391
|
},
|
|
392
|
+
// RR-96: the variant's map, accent order and scheme-derived neutrals (gridline, axis line, chart text, outline, trend, code panel).
|
|
393
|
+
roles,
|
|
385
394
|
fonts: { ...composeOptions.fontFamilies },
|
|
386
395
|
darkBackground,
|
|
387
396
|
diagnostics: diagnostics.map((entry) => ({ ...entry }))
|
|
@@ -766,7 +775,7 @@ function renderBackground(bound, width, height, options) {
|
|
|
766
775
|
const runs=patternRuns(preset);
|
|
767
776
|
const mark=runs?tag('path',{d:runs.map(run=>`M${run.x} ${run.y}h${run.width}v1h-${run.width}z`).join(''),fill:color,'shape-rendering':'crispEdges'}):'';
|
|
768
777
|
if(!mark)reportDiagnostic({code:'unsupported-pattern',path:`${bound.path}.design.background.pattern.preset`,message:`Pattern ${preset} is not implemented by the SVG preview.`},options);
|
|
769
|
-
return tag('g',{...HIDDEN,opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??
|
|
778
|
+
return tag('g',{...HIDDEN,opacity:background.opacity??1},tag('rect',{width,height,fill:bound.design.backgroundColor??bound.design.colors.background})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
|
|
770
779
|
}
|
|
771
780
|
return tag("rect", {
|
|
772
781
|
...HIDDEN,
|
|
@@ -797,7 +806,17 @@ function renderBackgroundImage(background, bound, width, height, options) {
|
|
|
797
806
|
const id = `opf-s${bound.index + 1}-background`, matrix = imageRecolorMatrix(background.recolor, bound);
|
|
798
807
|
const defs = matrix ? tag("defs", {}, tag("filter", { id: `${id}-recolor`, "color-interpolation-filters": "sRGB" }, tag("feColorMatrix", { type: "matrix", values: matrix }))) : "";
|
|
799
808
|
const pixels = matrix || background.opacity !== undefined ? tag("g", { filter: matrix ? `url(#${id}-recolor)` : undefined, opacity: background.opacity === undefined ? undefined : preciseNumber(background.opacity) }, picture) : picture;
|
|
800
|
-
return [canvas, defs, pixels, background.overlay ? renderImageOverlay(background.overlay, bound, options) :
|
|
809
|
+
return [canvas, defs, pixels, background.overlay ? renderImageOverlay(background.overlay, bound, options) : toneOverlay(bound, width, height, options)].filter(Boolean).join("\n");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// RR-96: a picture that names its tone (`dark` or `light`) is a picture the text must read on: with no overlay of its own, the slide
|
|
813
|
+
// background colour (bg1 of the tone's variant, so dark text colours get a dark scrim and light ones a light one) covers it at 40%.
|
|
814
|
+
// Core resolves the variant from the tone but exposes no overlay for it, so this scrim is the renderer's own (reported in RR-96).
|
|
815
|
+
const TONE_OVERLAY_OPACITY = 0.4;
|
|
816
|
+
function toneOverlay(bound, width, height, options) {
|
|
817
|
+
const tone = isPlainObject(bound.design.background) ? bound.design.background.tone : undefined;
|
|
818
|
+
if (tone !== "dark" && tone !== "light") return "";
|
|
819
|
+
return tag("rect", { ...HIDDEN, x: 0, y: 0, width, height, fill: bound.design.colors.background, opacity: TONE_OVERLAY_OPACITY, ...(options.trace ? { "data-opf-tone-overlay": tone } : {}) });
|
|
801
820
|
}
|
|
802
821
|
|
|
803
822
|
// tile: a pattern cell of the picture's intrinsic size, anchored at the canvas origin.
|
|
@@ -868,7 +887,7 @@ function imageRecolorMatrix(recolor, bound) {
|
|
|
868
887
|
if (!recolor) return undefined;
|
|
869
888
|
const weights = [0.299, 0.587, 0.114];
|
|
870
889
|
const channels = recolor.type === 'duotone'
|
|
871
|
-
? [imagePaint(recolor.dark, bound,
|
|
890
|
+
? [imagePaint(recolor.dark, bound, bound.design.colorScheme.dark1 ?? ENGINE_DEFAULT_COLOR_SCHEME.dark1), imagePaint(recolor.light, bound, bound.design.colorScheme.light1 ?? ENGINE_DEFAULT_COLOR_SCHEME.light1)].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
|
|
872
891
|
: [[0, 0, 0], [1, 1, 1]];
|
|
873
892
|
const [dark, light] = channels;
|
|
874
893
|
const rows = [0, 1, 2].map(channel => [...weights.map(weight => preciseNumber((light[channel] - dark[channel]) * weight)), 0, preciseNumber(dark[channel])].join(' '));
|
|
@@ -1274,7 +1293,8 @@ function renderMedia(item, box, bound, options) {
|
|
|
1274
1293
|
tag("path", {
|
|
1275
1294
|
...HIDDEN,
|
|
1276
1295
|
d: trianglePath(iconBox.x + 28, iconBox.y + 22, 28, 28),
|
|
1277
|
-
|
|
1296
|
+
// The play triangle sits on the primary circle: bg1 or tx1, whichever reads better on it (core readableSchemeColor).
|
|
1297
|
+
fill: readableSchemeColor(bound.design.colors.primary, bound.design.roles).hex,
|
|
1278
1298
|
...traceAttrs(options, item.path)
|
|
1279
1299
|
}),
|
|
1280
1300
|
renderTextBox(asset.title || asset.src || "Media", { ...box, y: iconBox.y + iconBox.height + 20, height: 50 }, bound, {
|
|
@@ -1308,8 +1328,8 @@ function renderCode(item, box, bound, options) {
|
|
|
1308
1328
|
y: box.y,
|
|
1309
1329
|
width: box.width,
|
|
1310
1330
|
height: box.height,
|
|
1311
|
-
fill:
|
|
1312
|
-
stroke:
|
|
1331
|
+
fill: bound.design.roles.neutrals.codePanel.hex,
|
|
1332
|
+
stroke: bound.design.colors.border,
|
|
1313
1333
|
"stroke-width": 1,
|
|
1314
1334
|
...traceAttrs(options, item.path)
|
|
1315
1335
|
})
|
|
@@ -1335,7 +1355,7 @@ function renderCode(item, box, bound, options) {
|
|
|
1335
1355
|
x:stableNumber(part.box.x),y:stableNumber(part.box.y+part.fit.fontSize+index*part.fit.lineHeight),
|
|
1336
1356
|
'text-anchor':'start','font-family':fontStack(part.style.fontFamily,'monospace'),
|
|
1337
1357
|
'font-size':stableNumber(part.fit.fontSize),'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
|
|
1338
|
-
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?(lineColors?.plain??
|
|
1358
|
+
'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?(lineColors?.plain??syntax?.palette.plain??bound.design.roles.neutrals.codeText.hex):codeLabelColor(bound),
|
|
1339
1359
|
...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
|
|
1340
1360
|
'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}:{}),
|
|
1341
1361
|
},line.segments.map(segment=>segmentSpan({
|
|
@@ -1402,7 +1422,7 @@ function renderMetric(item, box, bound, options) {
|
|
|
1402
1422
|
|
|
1403
1423
|
// The trend arrow and colour for a laid-out metric (core metricTrendMark); undefined without a trend.
|
|
1404
1424
|
function metricTrendMark(layout,bound,options) {
|
|
1405
|
-
return coreMetricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background});
|
|
1425
|
+
return coreMetricTrendMark(layout,{background:bound.design.backgroundColor??bound.design.colors.background,neutral:bound.design.roles.neutrals.trendNeutral.hex});
|
|
1406
1426
|
}
|
|
1407
1427
|
|
|
1408
1428
|
function renderQuote(item, box, bound, options) {
|
|
@@ -1471,7 +1491,7 @@ function renderTable(item, box, bound, options) {
|
|
|
1471
1491
|
const fill = style.fill == null
|
|
1472
1492
|
? defaultFill
|
|
1473
1493
|
: resolveColorRef(style.fill, bound, defaultFill);
|
|
1474
|
-
const defaultText =
|
|
1494
|
+
const defaultText = cell.header ? readableSchemeColor(fill, bound.design.roles).hex : textColorForFill(fill, bound.design.colors.text);
|
|
1475
1495
|
const textFill = style.color == null
|
|
1476
1496
|
? defaultText
|
|
1477
1497
|
: resolveColorRef(style.color, bound, defaultText);
|
|
@@ -1855,7 +1875,13 @@ function codeHighlight(item, layout, bound) {
|
|
|
1855
1875
|
if (!body?.fit) return undefined;
|
|
1856
1876
|
const lines = codeHighlightLines(highlight, body.text).lines;
|
|
1857
1877
|
if (!lines.length) return undefined;
|
|
1858
|
-
|
|
1878
|
+
const palette = codeSyntaxPaletteForRoles(bound.design.roles);
|
|
1879
|
+
return { marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(bound.design.colorScheme, palette, bound.design.roles.neutrals.codePanel.hex) };
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// The code language / filename label: the first accent of the variant, legible on the code panel (RR-96).
|
|
1883
|
+
function codeLabelColor(bound) {
|
|
1884
|
+
return codeSyntaxPaletteForRoles(bound.design.roles).keyword;
|
|
1859
1885
|
}
|
|
1860
1886
|
|
|
1861
1887
|
// Token ranges of the code body and the palette to paint them with; undefined for plain code (an unknown language,
|
|
@@ -1866,7 +1892,7 @@ function codeSyntax(item, layout, bound, options) {
|
|
|
1866
1892
|
if (!body || !language) return undefined;
|
|
1867
1893
|
const tokens = tokenizeCode(body.text, language);
|
|
1868
1894
|
if (!tokens.length) return undefined;
|
|
1869
|
-
return { tokens, palette:
|
|
1895
|
+
return { tokens, palette: codeSyntaxPaletteForRoles(bound.design.roles) };
|
|
1870
1896
|
}
|
|
1871
1897
|
|
|
1872
1898
|
// RR-61: a face is embedded only when the slide's own markup draws it: the family in a font-family list, at a font-weight and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"test:forced-colors-browser": "node test/forced-colors-browser.mjs artifacts/forced-colors"
|
|
143
143
|
},
|
|
144
144
|
"dependencies": {
|
|
145
|
-
"@openpresentation/opf": "^0.
|
|
145
|
+
"@openpresentation/opf": "^0.24.0",
|
|
146
146
|
"bidi-js": "1.1.0",
|
|
147
147
|
"fontkit": "^2.0.4",
|
|
148
148
|
"harfbuzzjs": "1.6.3",
|
|
@@ -341,7 +341,7 @@
|
|
|
341
341
|
}
|
|
342
342
|
},
|
|
343
343
|
"devDependencies": {
|
|
344
|
-
"@openpresentation/gallery": "^
|
|
344
|
+
"@openpresentation/gallery": "^4.0.0",
|
|
345
345
|
"@expo-google-fonts/arimo": "0.4.3",
|
|
346
346
|
"@expo-google-fonts/caladea": "0.4.2",
|
|
347
347
|
"@expo-google-fonts/cousine": "0.4.3",
|