PPTX chart import Phase 4b: data labels

Native rendering of c:dLbls — showVal / showCatName / showPercent /
showSerName flags. Series-level dLbls overrides chart-type-level
when both are present (matches PPTX inheritance rules).

Shared helpers:

- _readDataLabelOpts(dLblsNode): parse c:showVal / c:showCatName /
  c:showPercent / c:showSerName / c:dLblPos into a plain object.
  Returns null when the node is missing.
- _resolveDataLabelOpts(serNode, chartTypeNode): merge series-level
  over chart-type-level; returns null when no show* flag is set.
- _formatDataLabelText(opts, value, cat, total, name): join the
  enabled fields with \n (becomes <br> in the emitted HTML).
  showPercent uses Math.round(value/total*100).

Per-chart-type emission:

- Bar / column: text layer above each column ("outEnd"-like
  default position), or to the right of each horizontal bar.
  Reuses the bar-coord math; clamps to plot area so labels above
  the topmost bar don't escape upward.
- Pie / doughnut: text layer at the slice's angular midpoint,
  positioned 65 % of the way from centre to rim. Single-series
  c:ser so the merged opts apply to every slice.
- Line: text layer above each data point. Emitted in the marker
  loop so it's positioned even when markers themselves are
  disabled.

User-visible: gallery_template_layout_set.pptx slide 10's chart
has three c:dLbls blocks (one chart-level, two series-level) all
with showVal=0 by default — labels stay hidden unless a series
explicitly enables a flag. Decks that ship showVal=1 (the more
common configuration) now get readable numeric labels above each
bar / pie slice / line point.

Regression: polyline path test still passes.
This commit is contained in:
thePR0M3TH3AN committed 2026-05-30 23:35:31 -04:00
1 parent 1bfae87022
commit aec0a51b1f
1 file changed
+184 -10
+184 -10
View File
@@ -991,6 +991,55 @@ window.ArtstrPptxImporter = (function () {
return CHART_DEFAULT_PALETTE[fallbackIdx % CHART_DEFAULT_PALETTE.length];
}
// ---- Data-label helpers (Phase 4b) ----------------------------------
// Read c:dLbls flags into a plain options object. Returns null when
// the node is missing entirely.
function _readDataLabelOpts(dLblsNode) {
if (!dLblsNode) return null;
const opts = { showVal: false, showCatName: false, showPercent: false, showSerName: false, position: null };
for (let i = 0; i < dLblsNode.children.length; i++) {
const ch = dLblsNode.children[i];
const v = ch.getAttribute('val');
if (ch.localName === 'showVal' && v === '1') opts.showVal = true;
else if (ch.localName === 'showCatName' && v === '1') opts.showCatName = true;
else if (ch.localName === 'showPercent' && v === '1') opts.showPercent = true;
else if (ch.localName === 'showSerName' && v === '1') opts.showSerName = true;
else if (ch.localName === 'dLblPos') opts.position = ch.getAttribute('val');
}
return opts;
}
// Series-level dLbls overrides chart-type-level for each flag.
// Returns null when neither layer turned any show* flag on.
function _resolveDataLabelOpts(serNode, chartTypeNode) {
const s = _readDataLabelOpts(_directChild(serNode, 'dLbls')) || {};
const c = _readDataLabelOpts(_directChild(chartTypeNode, 'dLbls')) || {};
const merged = {
showVal: s.showVal || c.showVal || false,
showCatName: s.showCatName || c.showCatName || false,
showPercent: s.showPercent || c.showPercent || false,
showSerName: s.showSerName || c.showSerName || false,
position: s.position || c.position || null,
};
if (!merged.showVal && !merged.showCatName && !merged.showPercent && !merged.showSerName) {
return null;
}
return merged;
}
// Format a single data-point's label text per the show* flags.
// Multiple flags get joined with a newline → rendered as <br>.
function _formatDataLabelText(opts, value, categoryLabel, totalValue, seriesName) {
const parts = [];
if (opts.showCatName && categoryLabel) parts.push(String(categoryLabel));
if (opts.showSerName && seriesName) parts.push(String(seriesName));
if (opts.showVal) parts.push(String(value));
if (opts.showPercent && totalValue > 0) {
parts.push(`${Math.round((value / totalValue) * 100)}%`);
}
return parts.join('\n');
}
// ---- Chart polish helpers (Phase 4) ---------------------------------
// Read the chart title text from c:chart/c:title/c:tx/c:rich, walking
// a:p paragraphs and a:r runs. Returns '' when no title or when
@@ -1218,14 +1267,19 @@ window.ArtstrPptxImporter = (function () {
}
if (maxV <= 0) maxV = 1;
// Per-series data-label options (Phase 4b). Series overrides
// chart-type-level c:dLbls.
const dlOptsBySeries = serNodes.map((sn) => _resolveDataLabelOpts(sn, barChartNode));
const layers = [];
// ---- Bars ----
// ---- Bars + per-bar data labels ----
if (direction === 'col') {
// Vertical columns: categories along X, value extends up.
const catW = pa.w / N;
const gap = catW * 0.15; // ~15 % gap between category groups
const barW = (catW - gap) / S;
const labelH = pa.h * 0.05;
for (let c = 0; c < N; c++) {
for (let si = 0; si < S; si++) {
const v = series[si].values[c] || 0;
@@ -1244,6 +1298,30 @@ window.ArtstrPptxImporter = (function () {
fill: { type: 'solid', color: series[si].color },
stroke: { type: 'none', color: '#000000', width: 1, dash: 'solid' },
});
const dl = dlOptsBySeries[si];
if (dl) {
const text = _formatDataLabelText(dl, v, categories[c], 0, series[si].name);
if (text) {
layers.push({
id: _makePptxLayerId(),
type: 'text',
name: `${chartLabel} / Data label [${categories[c] || c + 1}, ${series[si].name || `Series ${si + 1}`}]`,
target: 'canvas',
html: _escapeHtml(text).replace(/\n/g, '<br>'),
// Default position: above the bar ("outEnd" in PPTX).
x: xBar - barW * 0.1,
y: Math.max(pa.y, yBar - labelH - 0.02),
w: barW * 1.2,
h: labelH,
rotate: 0, opacity: 1, z: ctx.nextZ++,
fontFamily: 'inherit',
fontSize: 10,
color: '#222',
align: 'center',
bold: false, italic: false,
});
}
}
}
}
} else {
@@ -1269,6 +1347,31 @@ window.ArtstrPptxImporter = (function () {
fill: { type: 'solid', color: series[si].color },
stroke: { type: 'none', color: '#000000', width: 1, dash: 'solid' },
});
const dl = dlOptsBySeries[si];
if (dl) {
const text = _formatDataLabelText(dl, v, categories[c], 0, series[si].name);
if (text) {
const labelW = pa.w * 0.10;
layers.push({
id: _makePptxLayerId(),
type: 'text',
name: `${chartLabel} / Data label [${categories[c] || c + 1}, ${series[si].name || `Series ${si + 1}`}]`,
target: 'canvas',
html: _escapeHtml(text).replace(/\n/g, '<br>'),
// Default position: to the right of the bar's end.
x: Math.min(pa.x + pa.w - labelW, xBar + wBar + 0.02),
y: yBar,
w: labelW,
h: barH,
rotate: 0, opacity: 1, z: ctx.nextZ++,
fontFamily: 'inherit',
fontSize: 10,
color: '#222',
align: 'left',
bold: false, italic: false,
});
}
}
}
}
}
@@ -1449,18 +1552,27 @@ window.ArtstrPptxImporter = (function () {
const innerR = isDoughnut ? outerR * (holePct / 100) : 0;
let angle = -Math.PI / 2 + (firstAngleDeg * Math.PI / 180);
// Data-label options (Phase 4b). Pie charts have a single c:ser,
// so series + chart-type-level dLbls collapse to one resolved set.
const dlOpts = _resolveDataLabelOpts(serNode, pieChartNode);
const layers = [];
const legendSeries = []; // for the legend builder — one entry per slice
const labelInfos = []; // collected for emission after slice paths
for (let i = 0; i < values.length; i++) {
const v = Math.max(0, Number(values[i]) || 0);
if (v <= 0) continue;
const sweep = (Math.PI * 2) * (v / total);
const d = _sliceArcPath(cx, cy, outerR, angle, angle + sweep, innerR);
const midAngle = angle + sweep / 2;
angle += sweep;
if (!d) continue;
const color = _readPieSliceColor(serNode, i, varyColors, ctx.theme);
const catLabel = categories[i] || `Slice ${i + 1}`;
legendSeries.push({ name: catLabel, color });
if (dlOpts) {
labelInfos.push({ value: v, category: catLabel, midAngle });
}
layers.push({
id: _makePptxLayerId(),
type: 'shape',
@@ -1483,6 +1595,37 @@ window.ArtstrPptxImporter = (function () {
});
}
// Phase 4b: data labels at slice bisectors. Skip if no flags set.
// Positioned 65% of the way out from the centre to the rim along
// the slice's angular midpoint — readable for most slice sizes.
if (dlOpts && labelInfos.length) {
const labelSizeFrac = 0.18; // fraction of side per label box
const labelW = sliceBounds.w * labelSizeFrac;
const labelH = sliceBounds.h * 0.06;
for (const info of labelInfos) {
const lx = sliceBounds.x + sliceBounds.w / 2
+ (sliceBounds.w * 0.5) * 0.65 * Math.cos(info.midAngle) - labelW / 2;
const ly = sliceBounds.y + sliceBounds.h / 2
+ (sliceBounds.h * 0.5) * 0.65 * Math.sin(info.midAngle) - labelH / 2;
const text = _formatDataLabelText(dlOpts, info.value, info.category, total, '');
if (!text) continue;
layers.push({
id: _makePptxLayerId(),
type: 'text',
name: `${chartLabel} / Data label [${info.category}]`,
target: 'canvas',
html: _escapeHtml(text).replace(/\n/g, '<br>'),
x: lx, y: ly, w: labelW, h: labelH,
rotate: 0, opacity: 1, z: ctx.nextZ++,
fontFamily: 'inherit',
fontSize: 10,
color: '#111',
align: 'center',
bold: false, italic: false,
});
}
}
// Phase 4 polish: title + legend. Pie legend lists slice
// categories (each in its own slice colour) rather than series
// names — there's only one series anyway.
@@ -1578,7 +1721,10 @@ window.ArtstrPptxImporter = (function () {
}
}
series.push({ name, color, values, lineWidth: lineWidthIn, markerEnabled, markerSizeIn, markerColor });
// Data-label opts per series (Phase 4b).
const dlOpts = _resolveDataLabelOpts(s, lineChartNode);
series.push({ name, color, values, lineWidth: lineWidthIn, markerEnabled, markerSizeIn, markerColor, dlOpts });
}
// Plot area: shared insets-from-chart-polish helper (Phase 4).
@@ -1633,14 +1779,14 @@ window.ArtstrPptxImporter = (function () {
},
});
// ---- Markers ----
if (s.markerEnabled) {
for (let c = 0; c < N; c++) {
const v = Number(s.values[c]) || 0;
const cxFrac = N > 1 ? (c / (N - 1)) : 0.5;
const cxIn = pa.x + cxFrac * pa.w;
const cyIn = pa.y + pa.h - (v / maxV) * pa.h;
// Centred at the data point, square layer of marker size.
// ---- Markers + per-point data labels ----
const dl = s.dlOpts;
for (let c = 0; c < N; c++) {
const v = Number(s.values[c]) || 0;
const cxFrac = N > 1 ? (c / (N - 1)) : 0.5;
const cxIn = pa.x + cxFrac * pa.w;
const cyIn = pa.y + pa.h - (v / maxV) * pa.h;
if (s.markerEnabled) {
layers.push({
id: _makePptxLayerId(),
type: 'shape',
@@ -1656,6 +1802,31 @@ window.ArtstrPptxImporter = (function () {
stroke: { type: 'none', color: '#000000', width: 1, dash: 'solid' },
});
}
if (dl) {
const text = _formatDataLabelText(dl, v, categories[c], 0, s.name);
if (text) {
const labelW = pa.w * 0.10;
const labelH = pa.h * 0.05;
layers.push({
id: _makePptxLayerId(),
type: 'text',
name: `${chartLabel} / Data label [${categories[c] || c + 1}, ${s.name || `Series ${si + 1}`}]`,
target: 'canvas',
html: _escapeHtml(text).replace(/\n/g, '<br>'),
// Default: above the marker (PPTX dLblPos 't').
x: cxIn - labelW / 2,
y: Math.max(pa.y, cyIn - labelH - 0.02),
w: labelW,
h: labelH,
rotate: 0, opacity: 1, z: ctx.nextZ++,
fontFamily: 'inherit',
fontSize: 10,
color: '#222',
align: 'center',
bold: false, italic: false,
});
}
}
}
}
@@ -2331,6 +2502,9 @@ window.ArtstrPptxImporter = (function () {
_readChartTitle,
_readChartLegend,
_computePlotInsets,
_readDataLabelOpts,
_resolveDataLabelOpts,
_formatDataLabelText,
convertChart,
walkSpTree,
readSlideRels,