mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-29 17:50:39 +00:00
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:
1 parent
1bfae87022
commit
aec0a51b1f
1 file changed
+184
-10
+184
-10
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user