v3.6.0
Four things ship in this release: a security fix for a real vulnerability, a new chart
form, a new component family, and a rule that finally became a gate.
Security — this release fixes an SVG-rasterizer flaw where an attacker-supplied icon could read
local files, or run JS/SSRF, through the headless-Chrome fallback. If you pass local or third-party
SVGs toicon_png(), upgrade.
Added — OLDSTYLE_FIGURES: a documented rule becomes a deterministic gate
deckkit.lint_layoutnow hard-fails on display numerals set in an OLD-STYLE (text) figure
face — Georgia, Palatino, Baskerville, Book Antiqua, Constantia, Hoefler Text, Calluna, Candara.
Those faces set 0/1/2 at x-height, push 6/8 up and drop 3/4/5/7/9 below the baseline, so a hero
number visibly bobs and misaligns with adjacent CJK/Latin. The check names the face, the offending
run and its size, and points at the lining-figure fix.- Threshold-aware, so it is right rather than merely strict: fires at ≥20pt only. Old-style
figures inside running prose are a legitimate typographic choice and stay quiet; the gate targets
display numerals, where the wobble is a defect. - Why this shipped as code and not more prose: the rule was already written in five places —
SKILL.md,design-principles.md,font-guidance.md,multilingual.mdandcritic.md— and was
still missed on a real deck, twice, across two skills. That is precisely the failure mode SKILL.md's
own enforcement invariant warns about: a MUST that lives only in reference prose is advisory in
practice. Geometry linters cannot see it (it is a font property, not a layout fault) and render
thumbnails are too small to reveal it, so nothing downstream caught it either. - Wired end-to-end: the CRITICAL in
lint_layout, a plain-language row introubleshooting-faq.md§4,
the hard-fail list inSKILL.mdupdated from four items to five, and a four-case regression test
insmoke_deckkit.pycovering both directions (Georgia/Palatino display numerals fail; Georgia body
prose and a lining-face numeral pass) — so the gate itself is verified to fire, not merely present.
Added — composition / target / range components (native, editable)
native_chartstacked & area kinds —column_stacked·column_stacked_100·bar_stacked·
bar_stacked_100·area·area_stacked·area_stacked_100: real editable PowerPoint charts for
composition over time (a total AND its component mix — the most common exec chart the roster
couldn't draw). Series-fill themed,zero_baseextended to stacked column/bar, CJK<a:ea>labels
intact; a printed notice fires on negative segments (a stack's height only reads as a sum for
same-sign parts).deckkit.bullet_graph— Stephen Few's 'actual vs TARGET, in context' KPI bar (poor/ok/good bands +
a target tick), one row per metric. Each row scales to its OWN max, so a mixed-unit dashboard reads
right;higher_better=Falsereverses the bands for churn/latency. The dashboard barscorecard/
meter_barcan't give.deckkit.range_bars— a 'football field' (floating min–max ranges per row on a sharedaxis_scale- optional base-case tick); closes form-selection's recipe-only note.
- Wired end-to-end from a 4-lane self-check (integration · decision-taste · agent-workflow cooperation
· correctness): a Composition + Range row in the Concept→Visualization dictionary (§3), a
'stacked chart that misleads' anti-pattern (100%-hides-collapsing-total · negative segments · spaghetti)
indata-viz.md+ critic hooks inreview-rubrics.md/critic.md, a single-highlight carve-out so a
correct stacked chart isn't flagged, and roster/catalogue entries (form-selection.md,data-viz.md,
design-gallery.md,SKILL.md). Correctness fixes: range_bars includes base-case points in its axis;
bullet_graph sorts bands; smoke test added.
Added — choropleth map (value per country / province)
deckkit.choropleth(slide, x, y, w, h, data, mapname)+scripts/maps.py— the high-payoff
form the skill was missing: shade a real map by a value per region.mapname=europe·world·
china(provinces). Built on public-domain geometry (Natural Earth 110m countries · DataV China
provinces — nothing fabricated), rendered with matplotlib (no geopandas): Albers equal-area conic for a
region, equirectangular for the world; light→accentramp (orscale='div'); neutral no-data fill;
thin borders. Keydataby ISO-3166 alpha-2/alpha-3 or English name (countries), or province name/adcode
(china); the Natural EarthISO_A2 = -99quirk is patched (France/Norway…) and unmatched keys are
reported, not dropped.- The map PNG is language-agnostic; the title + gradient legend are native deckkit text, so units
and titles render in any language (CJK included) instead of matplotlib tofu. Geometry is fetched once and
cached like icons (network only on first use;SLIDE_MAKER_CACHEoverride). - Hardened by a 3-lane self-check (code-alignment · decision-taste · adversarial correctness):
scale='div'
is zero-centred (neutral == 0, distinct poles viaaccent2) and the native legend now renders the
diverging ramp with a 0 tick so legend and map agree; NaN/None values are treated as no-data (no
crash); the unmatched-key notice reports the actual bad keys; China's nine-dash line renders as a
visible dashed element. Decision-taste wired everywhere the model looks: a Geography/"where" row in the
Concept→Visualization dictionary (design-intelligence-addendum.md§3 +form-selection.md), a
counts-not-rates anti-pattern indata-viz.md+ a named critic check inreview-rubrics.md, and the
"shade a rate, blank = no data" guidance in the docstring. Smoke covers projection, region matching
(incl. the -99 patch), NaN, div, and a real render.
Security — hardening pass (public-skill audit)
- SVG icon rasterizer no longer executes untrusted content (was the one real vuln).
icon_png()
accepts a local.svg, and whencairosvg/rsvg-convertare absent the headless-Chrome fallback
used to render it with JS +file://access — an attacker-supplied icon could read local files
(exfiltrated into the deck) or run JS/SSRF. Fixed withsanitize_svg()(a backend-agnostic control
run before every rasterizer): strips<script>/<foreignObject>/<iframe>/<image>/<audio>/
<video>/<set>/<animate>(incl. namespaced forms),on*handlers, any non-internal
href/xlink:href/src, and externalurl(...)— while preserving paths,<use href="#..">, and
the gradient refs recolor injects. Chrome also runs--disable-remote-fonts. Comments are stripped
first (no comment-split reassembly), and the input is size/complexity-bounded (>100KB or >600 elements
is refused in ~1ms) so the sanitizer itself can't be turned into a quadratic-time DoS. Verified across
all 13 icon libraries: real icons (flat + gradient + duotone) still render; a 16-payload adversarial
battery is fully neutralized; a ReDoS regression test guards the bound (smoke_deckkit.py). - Icon
nameis validated (fetch_svg) as a strict slug ([A-Za-z0-9._-], no..), closing a
path-traversal into the icon cache and a CDN-URL traversal to arbitrary jsDelivr/npm packages. - LibreOffice render is now time-bounded (
render_deck.py,timeout=300) so a malformed/hostile
.pptxcan't hang the pipeline — matching the timeouts every other soffice/ffmpeg call already had.