From aec0a51b1f44b5dfd7f8ea125cadc44d98c97cf2 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 30 May 2026 23:35:31 -0400
Subject: [PATCH] PPTX chart import Phase 4b: data labels
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
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.
---
src/pptx-importer.js | 194 ++++++++++++++++++++++++++++++++++++++++---
1 file changed, 184 insertions(+), 10 deletions(-)
diff --git a/src/pptx-importer.js b/src/pptx-importer.js
index be275ae..0b19231 100644
--- a/src/pptx-importer.js
+++ b/src/pptx-importer.js
@@ -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
.
+ 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, '
'),
+ // 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, '
'),
+ // 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, '
'),
+ 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, '
'),
+ // 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,