@openpresentation/opf-render 0.22.1 → 0.23.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 +12 -0
- package/dist/charts.js +43 -117
- package/dist/svg.js +54 -28
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -596,6 +596,8 @@ Combo charts (FA-15): `type: "combo"` draws clustered columns with line series (
|
|
|
596
596
|
|
|
597
597
|
Chart options (RR-35): a chart's optional `axisTitles`, `legend` and `dataLabels` (core `docs/chart-options.md`) are drawn. A named legend position is carved from the chart box before the plot is laid out, axis titles sit beside the axes (the value title rotated 270 degrees), and data labels follow the content (category, value, percent), position and separator the chart type supports. An option a type cannot show is dropped with a `chart-option-adapted` diagnostic; a chart without the fields draws as before.
|
|
598
598
|
|
|
599
|
+
Chart colours, text and value axis (RR-89, RR-90, RR-92; need core 0.22.0: `chartSeriesPalette`, `chartTextSize` and the composed `chart.valueAxis`): series are drawn in core's `chartSeriesPalette` of the deck's resolved colour scheme (`accent1` to `accent6`, then the same accents tinted toward white), adjusted per chart panel by `chartPaletteForFill`, so `design.colorScheme` recolours charts; chart axis, legend, category and data-label text is core's `chartTextSize` (20 px, shrunk only for a small chart box, never below `minFontSize`); and a column, bar, line or area chart draws the value axis (`min`, `max`, `majorUnit`) core composes for its box, the axis the PPTX export writes. Other chart types keep their own scale.
|
|
600
|
+
|
|
599
601
|
Chart emphasis (FA-14): `chart.highlight` names the series (data columns) and categories (row labels) that carry the message. Highlighted marks are filled with the deck primary and every other mark with a muted neutral derived from the chart panel and text colors (core `chartHighlightColors`; core `chartHighlightMarks` decides which marks are named: a mark is highlighted when its series or its category is). It colors columns, bars, line series and (for a category highlight) line points, areas, scatter and radar series, and pie and doughnut slices, with legend keys and data label contrast following; the chartex constructs draw as before and the `chart-option-adapted` diagnostic names the dropped part. Series keep their order and a chart without `highlight` draws the same SVG as before.
|
|
600
602
|
|
|
601
603
|
Chart and table data (RR-54, needs the core release with `resolveChartData`; core `docs/chart-table-data.md`): every chart path plots the data core resolves, so `DataColumn` headers, `{ dataset, fields }` references to the top-level `datasets` map and `chart.mapping` (category, scatter X and series by column name) all draw, and a series is labelled with its column `name`. Chart numbers are core's strict `chartNumber` (`"12%"`, `"(5)"` and `"Q1"` are gaps, `"1e6"` is 1000000). A column's number `format` shapes its data labels and the value-axis tick labels (the first plotted series' format; the scatter X axis follows the X column; percent axes and pie percent labels stay percent; a 100% stacked chart's data labels show the values in their own format, as PowerPoint does), and table cells draw the formatted text. A formatted horizontal axis (horizontal bars, the scatter X axis) pulls its plot in until the first and last tick labels stay inside the chart; a General axis keeps its placement. A dataset chart or table draws exactly its inline equivalent and traces every part to its authored `slides.N.chart` or `slides.N.table` path; a chart with `mapping` traces each part to the authored column. The legacy sketch (a chart type outside the catalog) plots a gap as nothing. A document without the new fields draws byte for byte as before; a data source by file or asset (the removed `ChartDataSource`) is an `invalid-opf` error.
|
|
@@ -604,6 +606,16 @@ Classic chart previews keep finite axis coordinates for subnormal values and val
|
|
|
604
606
|
|
|
605
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).
|
|
606
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
|
+
|
|
607
619
|
## Long-lived workers: measured memory and latency (opf-render#171)
|
|
608
620
|
|
|
609
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
|
@@ -4,19 +4,14 @@
|
|
|
4
4
|
// The table below is carried here so the preview stays synchronous and self-contained; test/chart-catalog.mjs checks it
|
|
5
5
|
// against core's CHART_TYPES. Each type renders the construct PowerPoint shows for the exported chart; any other value
|
|
6
6
|
// returns null so the caller keeps its legacy single-series preview.
|
|
7
|
-
import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, formatDataNumber, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
|
|
7
|
+
import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, chartSeriesPalette as coreChartSeriesPalette, chartTextSize, formatDataNumber, maxIntervalsFor, niceScale, stackCategoryValues, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
|
|
8
8
|
import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
// The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge).
|
|
18
|
-
export function chartSeriesPalette(surface) {
|
|
19
|
-
return chartPaletteForFill(surface, CHART_SERIES_COLORS);
|
|
10
|
+
// The series colours for a chart panel: core's `chartSeriesPalette` (the deck colour scheme's accents, RR-89, with the chart's own
|
|
11
|
+
// `colors` entries replacing their positions) adjusted by core's `chartPaletteForFill` (contrast against the panel without letting
|
|
12
|
+
// two series merge). The PPTX export and the validator use the same pair, so the preview draws the colours they write.
|
|
13
|
+
export function chartSeriesPalette(surface, colorScheme, colors, context) {
|
|
14
|
+
return chartPaletteForFill(surface, coreChartSeriesPalette(colorScheme, colors, context));
|
|
20
15
|
}
|
|
21
16
|
|
|
22
17
|
// FA-14: chart.highlight. Core resolves which series and categories are named (chartHighlightMarks) and the two colours
|
|
@@ -191,8 +186,15 @@ function tracePaths(item, bound, resolved) {
|
|
|
191
186
|
function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace, combo }) {
|
|
192
187
|
const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
193
188
|
const composition = bound.composition ?? bound.geometry.composition ?? {};
|
|
194
|
-
|
|
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
|
+
const requested = chartTextSize({ width: box.width, height: box.height, scale: u, minFontSize: composition.minFontSize }), fontPx = requested * u;
|
|
195
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);
|
|
196
198
|
const labelColor = textColorForFill(surface, bound.design.colors.text);
|
|
197
199
|
const style = resolveTextStyle({ fontFamily: bound.design.fonts.body, fontWeight: 400, italic: false, path: item.path }, options.textMeasurement);
|
|
198
200
|
const measure = textWidthMeasurer(style, options.textMeasurement);
|
|
@@ -206,7 +208,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
206
208
|
return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
|
|
207
209
|
};
|
|
208
210
|
const c = {
|
|
209
|
-
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,
|
|
210
212
|
path: item.path,
|
|
211
213
|
pt: u * 4 / 3,
|
|
212
214
|
pad: 10 * u,
|
|
@@ -215,9 +217,12 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
215
217
|
// clear space kept between neighbouring category labels.
|
|
216
218
|
textHeight: fontPx * 1.22,
|
|
217
219
|
labelGap: fontPx * 0.1,
|
|
218
|
-
colors: chartSeriesPalette(surface),
|
|
219
|
-
|
|
220
|
-
|
|
220
|
+
colors: chartSeriesPalette(surface, bound.design.colorScheme, item.value?.colors, { accents: bound.design.roles.map.accents, roles: bound.design.colors, variables: bound.design.variables }),
|
|
221
|
+
// RR-92: the value axis core composed for a column, bar, line or area chart ({ min, max, majorUnit }); absent for the other types.
|
|
222
|
+
valueAxis: item.chart?.valueAxis,
|
|
223
|
+
gridColor: neutrals.gridline.hex,
|
|
224
|
+
axisColor: neutrals.axisLine.hex,
|
|
225
|
+
outlineColor: neutrals.outline.hex,
|
|
221
226
|
number: chartNumber,
|
|
222
227
|
label: (value) => value === null || value === undefined ? "" : String(value),
|
|
223
228
|
width: (value) => measure(String(value), fontPx),
|
|
@@ -227,7 +232,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
227
232
|
for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
|
|
228
233
|
children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, tracePath(path)) : {}) }));
|
|
229
234
|
},
|
|
230
|
-
textElement(value, rect, path, align = "center", fill =
|
|
235
|
+
textElement(value, rect, path, align = "center", fill = chartTextColor) {
|
|
231
236
|
if (!(rect.width > 0 && rect.height > 0)) return "";
|
|
232
237
|
return svg.renderTextBox(String(value), rect, bound, {
|
|
233
238
|
path: tracePath(path), fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
|
|
@@ -262,60 +267,6 @@ export function formatChartValue(value, format) {
|
|
|
262
267
|
// ---------------------------------------------------------------------------
|
|
263
268
|
// Shared axis and legend helpers
|
|
264
269
|
|
|
265
|
-
/**
|
|
266
|
-
* Office-like automatic value axis: include zero unless every value sits in
|
|
267
|
-
* the top sixth of the range, add 5% headroom, then choose the smallest
|
|
268
|
-
* 1/2/5 x 10^k major unit that fits `maxIntervals`.
|
|
269
|
-
*/
|
|
270
|
-
export function niceScale(dataMin, dataMax, maxIntervals = 10, { percent = false } = {}) {
|
|
271
|
-
const limit = Number.isFinite(maxIntervals) ? Math.max(1, Math.floor(maxIntervals)) : 10;
|
|
272
|
-
if (percent) {
|
|
273
|
-
const min = dataMin < -1e-9 ? -1 : 0, max = dataMax > 1e-9 || min === 0 ? 1 : 0;
|
|
274
|
-
return scaleFrom(min, max, pickStep(min, max, limit, [0.1, 0.2, 0.25, 0.5, 1]));
|
|
275
|
-
}
|
|
276
|
-
if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) { dataMin = 0; dataMax = 1; }
|
|
277
|
-
let lo = Math.min(dataMin, 0), hi = Math.max(dataMax, 0);
|
|
278
|
-
if (dataMin > 0 && dataMax - dataMin < dataMax / 6) lo = dataMin;
|
|
279
|
-
if (dataMax < 0 && dataMax - dataMin < -dataMin / 6) hi = dataMax;
|
|
280
|
-
if (hi === lo) {
|
|
281
|
-
if (hi === 0) hi = 1;
|
|
282
|
-
else if (hi > 0) lo = 0;
|
|
283
|
-
else hi = 0;
|
|
284
|
-
}
|
|
285
|
-
const span = hi - lo;
|
|
286
|
-
const top = hi > 0 ? hi + span / 20 : hi;
|
|
287
|
-
const bottom = lo < 0 ? lo - span / 20 : lo > 0 ? Math.max(0, lo - span / 20) : lo;
|
|
288
|
-
const raw = (top - bottom) / limit;
|
|
289
|
-
if (!(raw > 0) || !Number.isFinite(raw)) return normalizedScale(dataMin, dataMax, limit);
|
|
290
|
-
let exponent = Math.floor(Math.log10(raw)) - 1;
|
|
291
|
-
for (; exponent <= 308; exponent++) {
|
|
292
|
-
for (const mantissa of [1, 2, 5]) {
|
|
293
|
-
const step = mantissa * 10 ** exponent;
|
|
294
|
-
if (!(step > 0) || !Number.isFinite(step)) continue;
|
|
295
|
-
const min = Math.floor(bottom / step + 1e-9) * step, max = Math.ceil(top / step - 1e-9) * step;
|
|
296
|
-
if (Number.isFinite(min) && Number.isFinite(max) && Math.round((max - min) / step) <= limit) return scaleFrom(clean(min), clean(max), step);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
return normalizedScale(dataMin, dataMax, limit);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Work in representable units when headroom or a tick step exceeds Number's
|
|
303
|
-
// range. Only axis bounds/ticks saturate at the representable endpoints; data
|
|
304
|
-
// values keep their actual positions. Subnormal rounding may merge ticks.
|
|
305
|
-
function normalizedScale(dataMin, dataMax, limit) {
|
|
306
|
-
const magnitude = Math.max(Math.abs(dataMin), Math.abs(dataMax));
|
|
307
|
-
const unit = 10 ** Math.floor(Math.log10(magnitude)) || Number.MIN_VALUE;
|
|
308
|
-
const normalized = niceScale(dataMin / unit, dataMax / unit, limit);
|
|
309
|
-
const restore = (value) => {
|
|
310
|
-
const scaled = value * unit;
|
|
311
|
-
return Number.isFinite(scaled) ? scaled : Math.sign(value) * Number.MAX_VALUE;
|
|
312
|
-
};
|
|
313
|
-
const min = Math.min(restore(normalized.min), dataMin);
|
|
314
|
-
const max = Math.max(restore(normalized.max), dataMax);
|
|
315
|
-
const ticks = [...new Set([min, ...normalized.ticks.map(restore), max])].sort((a, b) => a - b);
|
|
316
|
-
return { min, max, step: restore(normalized.step) || Number.MIN_VALUE, ticks };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
270
|
// Keep ordinary arithmetic unchanged. A range spanning both finite endpoints
|
|
320
271
|
// can overflow even though every value/bound is finite; divide before subtracting.
|
|
321
272
|
function axisFraction(value, scale, reverse = false) {
|
|
@@ -327,10 +278,6 @@ function axisFraction(value, scale, reverse = false) {
|
|
|
327
278
|
return (reverse ? max - position : position - min) / (max - min);
|
|
328
279
|
}
|
|
329
280
|
|
|
330
|
-
function pickStep(min, max, limit, steps) {
|
|
331
|
-
return steps.find((step) => Math.round((max - min) / step) <= limit) ?? max - min;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
281
|
function scaleFrom(min, max, step) {
|
|
335
282
|
const ticks = [];
|
|
336
283
|
const count = Math.round((max - min) / step);
|
|
@@ -466,17 +413,13 @@ function seriesLegend(c, series, key, marker = false) {
|
|
|
466
413
|
}
|
|
467
414
|
|
|
468
415
|
function gridline(c, x1, y1, x2, y2) {
|
|
469
|
-
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 });
|
|
470
417
|
}
|
|
471
418
|
|
|
472
419
|
function axisLine(c, x1, y1, x2, y2) {
|
|
473
420
|
c.mark("line", { x1, y1, x2, y2, stroke: c.axisColor, "stroke-width": c.pt });
|
|
474
421
|
}
|
|
475
422
|
|
|
476
|
-
function maxIntervalsFor(length, labelExtent) {
|
|
477
|
-
return Math.max(1, Math.min(10, Math.floor(length / Math.max(1, labelExtent))));
|
|
478
|
-
}
|
|
479
|
-
|
|
480
423
|
// ---------------------------------------------------------------------------
|
|
481
424
|
// Category-axis labels. PowerPoint labels a category axis automatically: a label
|
|
482
425
|
// stays on one line unless it has spaces and fits two lines in its category
|
|
@@ -676,40 +619,23 @@ function drawRowLabels(c, plan, entries, band, gutter, x, rowTop) {
|
|
|
676
619
|
// ---------------------------------------------------------------------------
|
|
677
620
|
// Category charts: column, bar, line and area (standard, stacked, 100%).
|
|
678
621
|
|
|
679
|
-
/**
|
|
680
|
-
* Plotted extents per series point. Bars stack positive and negative values
|
|
681
|
-
* separately; lines and areas accumulate the signed sum. 100% groupings divide
|
|
682
|
-
* by the category's sum of absolute values first.
|
|
683
|
-
*/
|
|
684
|
-
export function stackCategoryValues(series, count, kind, grouping, path = "chart") {
|
|
685
|
-
const percent = grouping === "percentStacked";
|
|
686
|
-
const stacked = grouping === "stacked" || percent;
|
|
687
|
-
const totals = Array.from({ length: count }, (_, i) => series.reduce((sum, s) => sum + Math.abs(s.values[i] ?? 0), 0));
|
|
688
|
-
if (percent && totals.some((total) => !Number.isFinite(total))) throw chartAggregateError(path, "percentage total");
|
|
689
|
-
const positive = new Array(count).fill(0), negative = new Array(count).fill(0), running = new Array(count).fill(0);
|
|
690
|
-
return series.map((s) => s.values.map((raw, i) => {
|
|
691
|
-
if (raw === null && kind !== "area") return null;
|
|
692
|
-
let value = raw ?? 0;
|
|
693
|
-
if (percent) value = totals[i] ? value / totals[i] : 0;
|
|
694
|
-
if (!stacked) return { value, from: 0, to: value };
|
|
695
|
-
if (kind === "bar") {
|
|
696
|
-
const base = value >= 0 ? positive : negative;
|
|
697
|
-
const from = base[i];
|
|
698
|
-
base[i] += value;
|
|
699
|
-
if (!Number.isFinite(base[i])) throw chartAggregateError(path, "stacked total");
|
|
700
|
-
return { value, from, to: base[i] };
|
|
701
|
-
}
|
|
702
|
-
const from = running[i];
|
|
703
|
-
running[i] += value;
|
|
704
|
-
if (!Number.isFinite(running[i])) throw chartAggregateError(path, "stacked total");
|
|
705
|
-
return { value, from, to: running[i] };
|
|
706
|
-
}));
|
|
707
|
-
}
|
|
708
|
-
|
|
709
622
|
function chartAggregateError(path, aggregate) {
|
|
710
623
|
return new RangeError(`Cannot render ${path}: ${aggregate} exceeds the finite numeric range. Rescale the chart values before rendering.`);
|
|
711
624
|
}
|
|
712
625
|
|
|
626
|
+
// RR-92: a column, bar, line or area chart draws the value axis core composed for its box (c.valueAxis), the one the PPTX export
|
|
627
|
+
// writes. Without it (a chart the composition has no axis for) the scale is fitted to the plot's length as before.
|
|
628
|
+
// A range so wide that min + n x majorUnit leaves Number's range (finite data up to Number.MAX_VALUE) has no ticks to rebuild from the
|
|
629
|
+
// three numbers; it takes the saturating scale `niceScale` computes.
|
|
630
|
+
function categoryScale(c, dataMin, dataMax, length, labelExtent, percent) {
|
|
631
|
+
const axis = c.valueAxis;
|
|
632
|
+
if (axis && Number.isFinite(axis.max - axis.min) && (axis.max - axis.min) / axis.majorUnit <= 1000) {
|
|
633
|
+
const scale = scaleFrom(axis.min, axis.max, axis.majorUnit);
|
|
634
|
+
if (scale.ticks.every(Number.isFinite)) return scale;
|
|
635
|
+
}
|
|
636
|
+
return niceScale(dataMin, dataMax, maxIntervalsFor(length, labelExtent), { percent });
|
|
637
|
+
}
|
|
638
|
+
|
|
713
639
|
function renderCategoryChart(c, spec) {
|
|
714
640
|
const { box, pad, fontPx } = c;
|
|
715
641
|
const horizontal = spec.kind === "bar" && spec.dir === "bar";
|
|
@@ -738,7 +664,7 @@ function renderCategoryChart(c, spec) {
|
|
|
738
664
|
const bottom = box.y + box.height - pad - c.lineHeight;
|
|
739
665
|
let width = Math.max(1, right - x - fontPx);
|
|
740
666
|
const tickWidth = Math.max(c.width(formatTick(dataMax, percent, valueFormat)), c.width(formatTick(dataMin, percent, valueFormat)), c.width("100%")) + fontPx;
|
|
741
|
-
const scaleFor = (w) =>
|
|
667
|
+
const scaleFor = (w) => categoryScale(c, dataMin, dataMax, w, tickWidth, percent);
|
|
742
668
|
scale = scaleFor(width);
|
|
743
669
|
// RR-54: a formatted value axis keeps its end labels inside the chart.
|
|
744
670
|
if (valueFormat !== undefined && !percent) ({ x, width, scale } = insetForEndLabels(c, x, width, valueFormat, scaleFor));
|
|
@@ -752,7 +678,7 @@ function renderCategoryChart(c, spec) {
|
|
|
752
678
|
if (rtl) entries.reverse();
|
|
753
679
|
const axis = planCategoryAxis(c, entries, (reserve) => {
|
|
754
680
|
const height = Math.max(1, box.y + box.height - pad - reserve - top);
|
|
755
|
-
const fitted =
|
|
681
|
+
const fitted = categoryScale(c, dataMin, dataMax, height, c.lineHeight * 1.2, percent);
|
|
756
682
|
const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent, valueFormat)))) + fontPx * 0.5;
|
|
757
683
|
if (rtl) {
|
|
758
684
|
const x = box.x + pad + fontPx / 2;
|
|
@@ -1014,7 +940,7 @@ function renderCircularChart(c, spec) {
|
|
|
1014
940
|
c.text("No positive chart values", area, c.path);
|
|
1015
941
|
return;
|
|
1016
942
|
}
|
|
1017
|
-
const border = { stroke:
|
|
943
|
+
const border = { stroke: c.outlineColor, "stroke-width": 0.75 * c.pt };
|
|
1018
944
|
let angle = -Math.PI / 2;
|
|
1019
945
|
values.forEach((value, i) => {
|
|
1020
946
|
const delta = value / total * Math.PI * 2, end = angle + delta;
|
|
@@ -1140,7 +1066,7 @@ function renderRadarChart(c, spec) {
|
|
|
1140
1066
|
for (const tick of scale.ticks) {
|
|
1141
1067
|
if (tick === scale.min) continue;
|
|
1142
1068
|
const ring = categories.map((_, i) => point(i, tick).map(n).join(" "));
|
|
1143
|
-
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 });
|
|
1144
1070
|
}
|
|
1145
1071
|
// A spoke label that would collide with one already drawn is left out; the first is always kept.
|
|
1146
1072
|
const drawn = [], truncated = [];
|
|
@@ -1466,7 +1392,7 @@ function renderTreemapChart(c) {
|
|
|
1466
1392
|
const rects = squarify(items, area.x, area.y, area.width, area.height);
|
|
1467
1393
|
rects.forEach((rect, i) => {
|
|
1468
1394
|
if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;
|
|
1469
|
-
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}`);
|
|
1470
1396
|
// RR-35: the treemap labels its tiles with category names by default; dataLabels picks the content, false removes them. PowerPoint
|
|
1471
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.
|
|
1472
1398
|
if (!c.dataLabelsOff && rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
|
|
@@ -1567,12 +1493,12 @@ function renderRegionMapChart(c) {
|
|
|
1567
1493
|
const tileWidth = area.width / columns, tileHeight = area.height / rowsCount;
|
|
1568
1494
|
const finite = values.filter((value) => value !== null);
|
|
1569
1495
|
const min = finite.length ? Math.min(...finite) : 0, max = finite.length ? Math.max(...finite) : 0;
|
|
1570
|
-
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);
|
|
1571
1497
|
categories.forEach((name, i) => {
|
|
1572
1498
|
const value = values[i];
|
|
1573
1499
|
const t = value === null ? 0 : max > min ? axisFraction(value, {min, max}) : 1;
|
|
1574
1500
|
const x = area.x + (i % columns) * tileWidth, y = area.y + Math.floor(i / columns) * tileHeight;
|
|
1575
|
-
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}`);
|
|
1576
1502
|
const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
|
|
1577
1503
|
if (tileHeight >= c.lineHeight * 2) {
|
|
1578
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.23.0",
|
|
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.23.0",
|
|
146
146
|
"bidi-js": "1.1.0",
|
|
147
147
|
"fontkit": "^2.0.4",
|
|
148
148
|
"harfbuzzjs": "1.6.3",
|