How charts/native.py turns a validated ChartSpec into a native OOXML chart —
a real, editable PowerPoint chart with an embedded worksheet, built via
python-pptx's chart API. This doc is about the renderer's internals: the
per-type option sets, the palette, and how to add a type.
To write a chart in a deck spec, read docs/authoring.md
instead — it owns the wire format (kind:, the row-oriented data: list,
value: vs values:, highlight:, the full table of all 29 kinds) and every
error message the chart block can produce. Nothing about the spec's shape is
duplicated here.
For AI assistants: pptx-deck-building.md covers the render/QA loop
end to end; this doc is the full reference for the chart renderer
specifically.
- What reaches the renderer
- The native renderer
- Chart build animation
- Room for the labels a bar chart sets on its left
- Negative values, and why the render cannot check them
- Turning one series' labels off
- The unit reaches the axis too
- A bar starts at zero
- The categorical palette
- Adding a new chart type
ChartSpec.from_body parses the spec's chart: block and hands the renderer a
frozen ChartSpec: a type, categories, one or more Series, an optional
highlight index, decimals, and y_min/y_max.
The spec's wire format is row-oriented — one data: row per datapoint, each
carrying its own category (or x/y/size), its own numbers, and its own
highlight flag. from_body derives the categories/series tuples the
renderer consumes, so nothing downstream of the parse sees rows. Series names and
their order come from the first row's values mapping.
Two consequences worth knowing when working on the renderer:
highlightarrives as an index, resolved from whichever row sethighlight: true.charts/fills.pyturns it into per-point fills.Series.unitcomes from the block, not the row.unit:is a key of thechart:block, threaded through_parse_category_rowsonto everySeries, and_number_formatturns it into the code both the labels and the value axis print in, so the scale and the values it frames agree. Only the point-shaped parser takes nounitargument, so it staysNoneon the xy-scatter and bubble kinds:unit:is accepted there and does nothing.
charts/native.py builds a real chart part via python-pptx's chart API —
add_chart, an embedded CategoryChartData/XyChartData/BubbleChartData
worksheet (shape-dependent), and per-series or per-point fill styled from the
theme. It draws all 29 creatable ChartSpec types — every type
python-pptx can build: the 22 category-shaped types (bar, column,
column-stacked, column-stacked-100, bar-stacked, bar-stacked-100,
line, line-markers, line-stacked, line-stacked-100,
line-markers-stacked, line-markers-stacked-100, area, area-stacked,
area-stacked-100, radar, radar-filled, radar-markers, pie,
doughnut, pie-exploded, doughnut-exploded), the 5 xy-scatter variants,
and the 2 bubble variants — and reads every colour, face and size from the
theme; it never hardcodes a hex value or a point size.
Every piece of chart text reads the slide's live pair, not the template.
_style_data_labels and _style_axes set their own ctx.fg()/ctx.dim(), and
_style_text sets the chart-wide default so the two pieces of text nobody asks
for — the legend, and the title PowerPoint auto-generates for a single-series
chart — cannot fall through to the presentation theme's dark ink and disappear on
an inverse slide. The legend also takes the theme's face and the caption rung
rather than python-pptx's 18pt default.
Per-type option frozensets drive the divergence, so a new type is added
by extending a set, never by branching on spec.type. _SIDE_LABEL_CHART_TYPES
has its own section below:
_AXIS_CHART_TYPES— everything exceptpie/doughnut/pie-exploded/doughnut-exploded, which have no category or value axis (chart.category_axisraisesValueErroron one). The xy-scatter and bubble types all have both axes, so they join this set too._GAP_WIDTH_CHART_TYPES— only the bar/column family (including their stacked variants); no other plot class exposesgap_widthin python-pptx — not even the xy-scatter and bubble plots._PIE_FAMILY_CHART_TYPES—pie,doughnut,pie-explodedanddoughnut-exploded: one series, coloured per point from the categorical palette, named byshow_category_namerather than a legend or axis._STRUCTURAL_GRIDLINE_CHART_TYPES—radar,radar-filledandradar-markers: their major gridlines are the rings and spokes the data is plotted against, so_style_axesleaves them at python-pptx's own default instead of driving them fromtheme.chart.gridlike every other axis-bearing type._NO_DATA_LABEL_CHART_TYPES— the 5xy-scatter*variants. python-pptx'sCT_ScatterChartlistsc:dLblsin its tag sequence but never wires up the descriptor, soplot.has_data_labelsraisesAttributeErrorfor every scatter variant;add_native_chartskips the data-labels call for these types entirely.CT_BubbleChartdoes define it, sobubble/bubble-3dkeep their labels like any other type._MARKER_CHART_TYPES—line-markers(+ its two stacked variants),radar-markers, andxy-scatter/xy-scatter-lines/xy-scatter-smooth: the types python-pptx itself renders with an auto/default marker, themed fromtheme.chart.marker_size/marker_styleinstead. Plainline,line-stacked(-100), plainradar, and the two*-no-markersscatter variants explicitly render with no marker (python-pptx writes<c:symbol val="none"/>at creation) — forcing one on would fight the type's own declared identity.radar-filled'sradarStyle="filled"suppresses a marker regardless of what's set (confirmed by forcing one and rendering it — nothing appears). Bubble series inherit.markertoo, butCT_BubbleSer's real schema has no marker child; a bubble's own size-driven circle is already its marker._STROKE_CHART_TYPES— everyline/radar/xy-scattervariant, marker or not (14 types). A point on these has no fillable shape of its own — only a stroke and, on some types, a marker — so_fill_pointforces a solid fill here regardless oftheme.chart.gradient: a gradient stored on a shape nothing draws is confirmed-invisible (rendered and inspected pixel by pixel) and costs two gradient-stop elements for nothing.bubble/bubble-3dare excluded — a bubble's point is a visible filled circle, so its gradient stays meaningful._PERCENT_AXIS_CHART_TYPES— the 5*-stacked-100types._style_axesforces a0%tick format on the value axis, and_number_formatdrops the series'unit— an axis already reading in percent has nothing left for a suffix to add. The0%wins over the unit format: the share is what the renderer computed._SERIES_FILL_CHART_TYPES—area,area-stacked,area-stacked-100andradar-filled: one continuous band per series rather than a mark per point, so gradient and shadow are applied to the series through_fill_series, not point by point._HORIZONTAL_BAR_CHART_TYPES—bar,bar-stackedandbar-stacked-100. Their bars run the other way, sotheme.chart.gradient_angleis rotated a quarter turn and the gradient still runs along the bar rather than across it._CONNECTED_CHART_TYPES— every type butxy-scatter, which is points and nothing else. The rest take a themed stroke; giving one to a pure scatter would draw a line through data chosen to have none.
Marker fill always takes the series' own colour from the categorical palette
(_style_marker, called once per series) rather than a fixed colour, so a
themed marker matches its line instead of introducing a new one.
Editable in PowerPoint (Edit Data), animatable by category or by series,
vector at any zoom, and its labels are real text — visible to a PDF text
extractor and qa's overflow check, unlike a screenshotted image would be.
A native chart's own build — bars arriving one category at a time on click,
rather than the whole chart appearing at once — is the slide-level animate:
field, same field as any other component's click build:
title: Adoption climbs every quarter
animate: by_category # or by_series
place:
- at: {cols: full}
chart:
kind: column
data:
- {category: Q1, value: 12}
- {category: Q4, value: 91}by_category and by_series map to add_chart_build's by="category" /
by="series", which the chart component calls directly on the chart
graphicFrame — this is a different OOXML mechanism from the <p:bldP>
visibility toggle every other component's animate: uses
(one_at_a_time/together), because a chart is a graphicFrame build
(<p:bldGraphic>/<a:bldChart>), not a shape-visibility one. Asking for either
value on a non-chart component is rejected rather than silently downgraded. See
docs/pptx-deck-building.md's Animations section for the OOXML and the
verification caveat.
Keynote does not play a chart build. The same file that builds one category per
click in PowerPoint shows the chart whole in Keynote — <p:bldGraphic>/<a:bldChart>
is the one construct here with a known consumer gap. For a Keynote audience use
animate: together, or split the categories across slides.
Not every kind can build by category. _BUILDABLE_BY_CATEGORY in
charts/model.py names the ones that can, and by_category on anything else
raises. A category build reveals one category's marks per click, so it needs the
category to be a mark: a bar, a wedge, a point on a line. On a radar the
categories are vertices of one closed outline — filled, they are a single
polygon — so the build emits a click per category and nothing moves. Observed in
Keynote on radar-filled: six clicks, no change. This is the same refusal
_HIGHLIGHTABLE_KINDS makes, for the same reason.
by_series is not restricted: every kind has series, and a chart with one series
builds in a single click, which is honest rather than dead.
A bar chart's category labels run down the left of the plot, and nothing in the file says how much room they get — so each renderer decides. LibreOffice shrinks the plot area to fit them; Keynote does not, and a long label runs off the slide.
_reserve_label_column writes an explicit c:manualLayout on the plot area,
sized from the longest label at the caption rung, floored so the bars keep at
least _MIN_PLOT_FRACTION of the frame. Every renderer honours a manual layout,
so the reservation is stated once, in the file.
Only the bar family (_SIDE_LABEL_CHART_TYPES) gets one. A column chart's labels
sit under the plot, in width they already have, and pinning its plot area would
take room away for nothing.
A bar or column chart carrying negative values writes correct OOXML and renders wrongly
through the path render and qa both use. The bar is drawn on the positive side at
its absolute length, and its data label loses the minus sign — while the value axis
correctly scales to include the negatives.
What was measured, so nobody re-investigates it:
The chart's own numCache |
holds -146.0 |
| Its embedded workbook | holds -146 |
c:crosses |
autoZero, and no min/max is pinned |
Stripping c:dPt, or setting invertIfNegative="0" |
changes nothing |
| The same chart built with bare python-pptx | renders identically wrong |
| A line series with the same values | renders correctly, sign and all |
So the file is right and deckwright is not implicated: LibreOffice 26.2.5.2 plots and labels
the absolute value of a barChart datapoint. PowerPoint is expected to draw it correctly;
the contact sheet you check it on will not, and neither will qa. The documented workflow
for every other chart — build it, render it, look at it — is the one thing that cannot
settle this one, so qa raises a chart-negative warning saying exactly that.
So for a diverging comparison, reach for
diverge instead. It
draws the same shape as geometry rather than as a chart, which renders identically
everywhere, at the cost of not being an editable chart object in PowerPoint.
A chart whose values are all positive is unaffected.
A legend goes under the plot on every kind but the bar family, which reserves a column beside its category labels instead (below). A mid-right legend costs a quarter of the frame's width and leaves the plot crowded against it; under the plot it costs one line of height.
On the line and scatter kinds a data label sits above its point. The theme's
default label_position is outside_end, which is a bar position — a line has no bar
end, so the label lands on the point it names and collides with its own marker. A theme
that names a position the kind can honour keeps it.
A position is written only where the chart group offers one. inside_end reaches the
bar family; area, doughnut and radar take no position at all, and writing one into those
parts is what makes PowerPoint ask to repair the file.
A pie's label position is pinned to ctr, not the theme's label_position. Left
unset — what an ordinary bar's outside_end amounts to — a renderer's own best fit can
push a wedge too small for its label past the rim and onto the slide behind it, in an ink
chosen for the wedge, not the page: a real, silent way to lose a label. ctr keeps every
wedge's label inside it regardless of size, so a pie never reaches _LABEL_POSITIONS at
all. A doughnut's ring never narrows to a point the way a pie's does, but it still takes
no position, for the same repair-prompt reason as area and radar.
A label drawn on a fill is inked for that fill. On a pie or doughnut wedge, an area
band, a stacked bar, or a bar whose theme sets inside_end, the label reads against the
shape's colour rather than the slide. Each such series gets its own c:dLbls holding a
copy of the plot's label settings in the ink Palette.ink_for picks for that fill, and a
point filled differently (each pie wedge, a highlighted bar) gets its own c:dLbl. For a
gradient, the ink is the one whose weakest stop reads best. A label set beside its shape
(outside_end on a clustered bar, a line, radar, bubble) keeps the slide's ink. The code
is charts/labels.py.
An area chart's first and last labels are moved in off the plot edge. An area's points
sit on its category ticks, so the first and last are on the plot's left and right edges,
and a label centred there lands on the value axis. Area takes no label position, so each
of those two labels gets its own c:dLbl with a manual offset of half its measured width
plus half an em, inward. Leader lines are turned off for the series, since LibreOffice
otherwise draws one from a moved label back to its point.
A legend sits to the right of the plot, so on the bar family — where the plot area is pinned by hand so the left-hand category labels fit — the same manual layout measures a column for the longest series name. Both columns come out of one budget, and the plot never drops below 45% of the frame however long the words are.
The legend is also taken out of the plot's layout (c:overlay off). Left at
PowerPoint's default it is drawn over the plot, which a manual layout cannot then
account for — that is what put a legend on top of its own bars.
The places are read off the data: the most any plotted value carries, so a series of
11.2, 9.6, 5.1, 4.8 labels to one place and a series of whole numbers labels to none.
Values past four places are taken as arithmetic noise and capped.
decimals: overrides that in either direction — 0 rounds a noisy series back to
whole numbers, 2 pads a mixed series to a fixed width.
The *-stacked-100 kinds are no exception. Their axis shows the share the renderer
computed, but each data label still prints that series' own authored value, so a
stack of 11.2 and 88.8 labels those two numbers and needs its decimal place like
any other chart. What those kinds do drop is unit: — the axis already reads in per
cent, so a % on the label would be a second sign.
The places have to be written into the format code because unit: is a quoted
literal (0.0"%") rather than Excel's % code — a quoted suffix cannot ride on a
general format, so a format with a unit and no places stated is a format that rounds.
plot.has_data_labels and plot.data_labels are the whole plot, so the theme's label
settings arrive all-or-nothing. labels: names the series that opt out, and
_hide_series_labels writes a c:dLbls carrying showVal="0" on that c:ser: a series'
own element overrides the plot's, and it is the only place the dissent can be stated.
The suppressed series keeps its stroke, its palette colour and its legend entry — only the numbers go, which is what a benchmark line wants.
python-pptx exposes series.data_labels on the five category series classes and on neither
XySeries nor BubbleSeries. The block's refusal covers that: those kinds, like the
value: shorthand, fold their rows into a single unnamed series, and labels: addresses
series by name.
A unit: says what the numbers are, and that is as true of the scale as of the values on
it. _number_format computes one code and _style_axes puts it on the value axis, so a
line whose points read 11.2% sits against ticks reading 0.0% rather than a bare 0-12.
The axis takes the format only when the chart carries a unit. Places and digit grouping
are label-scoped by design, and pinning a code with no unit would print 12.0 for a tick
the renderer would otherwise draw as 12. With no unit the ticks stay the renderer's to
choose.
A bar or column encodes its value as a length from the baseline, so the bar family's
value axis starts at 0 whenever every plotted value is zero or more. Left to automatic
scaling, 284, 297, 290 draws on an axis from 275 and the first bar looks a third the
height of the second. A family holding a negative value keeps automatic scaling, which
already spans zero.
y_min / y_max reach the value axis directly and win over that default. Line, area,
radar, scatter and bubble keep automatic scaling unless you set them: a line encodes
change, and a floor at zero flattens it.
Multiple series (or wedges on a pie/doughnut/pie-exploded/doughnut-exploded)
cycle the palette's accent ramp — accent-1…accent-N, counting only the
accents a theme genuinely binds — so each is visually distinct without a legend
doing all the work. A single series stays accent-1; the ramp only engages once
colour has to carry a distinction between two or more series or wedges. Keep an
example inside the theme's accent count: past it the cycle repeats, and two series
share a colour. qa reports it as
series-colour, naming the two series and the fill they share.
Every fill is resolved in charts/fills.py before python-pptx sees it, as hex.
A pie's wedges are a ring. _ring cycles the accents' distinct colours, so two roles
bound to one hex count once, and a last wedge that would match the first takes the next
colour that matches neither neighbour. When no accent is left for it (one distinct accent,
or two under an odd number of wedges), _apart fades an accent in _RECEDE's steps, toward
the ground first and then white and black, and takes the first fade an ink reads on that
stands_off both neighbours: the ΔE-or-contrast rule of qa's fill-ground check. A single
accent pairs with such a fade to go round at all.
highlight is emphasis by isolation: the marked point keeps its colour and every other
mark recedes, so no other mark on the plot shares its fill. One series mutes the rest to
muted, fading it further if muted alone reads no label. Several fade each series'
colour toward the ground the frame sits on, the paper ctx.text_ink reports, so every
series keeps its hue and a stacked column keeps its segments apart. A fade starts halfway
and goes on in steps until an ink reads on it — native.py checks this against the
gradient's second, paler stop too, when the theme gradients the mark, so a fade legible
flat cannot still lighten past its label's ink — since halfway between a pale accent and a
dark page is a mid-tone no ink reads on; _RECEDE holds the steps, and none of them settle
for one that stays a mid-tone. A pie marks its wedge in accent-1 and alternates two fades
of that same accent toward the ground, starting from the wedge after the marked one so the
run never puts two alike side by side. Both stop short of _RECEDE's own steps: _quiet
searches a finer ladder from the start, for the fullest fade that still stands off the
ground — utils.color.stands_off — since accent-1 can sit far enough from the ground in
lightness that _RECEDE's halfway point already reads as the ground itself, on a page the
marked wedge alone would otherwise still clear. Neither the ring nor the isolation reads
past the first accent, or the theme's muted role at all.
- Add it to
ChartSpec's_TYPESincharts/_kinds.py— a type outside this set never reaches the renderer. If its data shape isn't one value per category, add it to_XY_CHART_TYPESor_BUBBLE_CHART_TYPEStoo, so_shape()and thepoints-vs-valuesvalidation pick it up. - Map it to an
XL_CHART_TYPEincharts/_native_types.py's_CHART_TYPES— 29 ofXL_CHART_TYPE's 73 members are creatable through python-pptx; the other 44 raiseNotImplementedError. - Measure the new type against each of the option frozensets in
The native renderer before assuming it matches its
family —
gap_widthandhas_data_labelsare both silent no-ops or raiseAttributeErroron the wrong plot class, so a wrong guess here fails quietly rather than loudly. Build a real chart of the type and probe it directly rather than reasoning from the plot class's name.