@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 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: bound.design.colors.border,
218
- axisColor: "#888888",
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 = labelColor) {
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-opacity": 0.7, "stroke-width": c.pt });
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: "#F9F9F9", "stroke-width": 0.75 * c.pt };
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-opacity": 0.7, "stroke-width": c.pt });
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: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
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 : "#FFFFFF", c.colors[0], 0.15);
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: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
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, codeSyntaxPaletteForScheme, resolveChartData,
7
- resolveColorRoles, defaultSlideBackground, timelineMarkerShapes, timelineTextColor, layoutWatermark,
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, "#FFFFFF");
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, "#FFFFFF");
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
- const backgroundDefinition = slideDesign.background ?? deckDesign.background ?? theme.background;
354
- const { primary, secondary, accent } = resolveColorRoles(colorScheme);
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
- // background/surface/text roles depend on the background itself, so a background reference sees only the scheme and the three accent roles.
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: roles.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??'#FFFFFF'})+tag('defs',{},tag('pattern',{id,width:8,height:8,patternUnits:'userSpaceOnUse'},mark))+tag('rect',{width,height,fill:`url(#${id})`}));
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) : ""].filter(Boolean).join("\n");
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, '#000000'), imagePaint(recolor.light, bound, '#FFFFFF')].map(paint => [1, 3, 5].map(at => parseInt(paint.color.slice(at, at + 2), 16) / 255))
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
- fill: "#FFFFFF",
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: "#111827",
1312
- stroke: "#334155",
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??'#E5E7EB'):'#93C5FD',
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 = textColorForFill(fill, cell.header ? "#FFFFFF" : bound.design.colors.text);
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
- return { marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(bound.design.colorScheme) };
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: codeSyntaxPaletteForScheme(bound.design.colorScheme) };
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.22.2",
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.22.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": "^3.0.0",
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",