What lives in src/deckwright/utils/: colour maths, string measurement, the fraction
vocabulary, polygon geometry, the deck-name rules, the one wording for an unknown key,
the typed narrowing over pf-core's env resolver, and the python-pptx primitives every
component draws with. This doc covers what each module is for, the constraints that keep it callable
from every layer above it, and the pf-core check each one has already been through.
These are library internals with no project content in them. Nothing here reads a deck
spec, a theme or a SlideCtx — a helper that needs one of those belongs in the
subsystem that owns it. utils/spans.py is the near-exception, and the section on it
says exactly how far it goes.
Before adding anything here, check whether pf-core already has it — that is a rule
with teeth, and bin/check-framework is what enforces it.
- The tier utils sits in
color.py— the WCAG mathstext.pyand_metrics.py— measuring a stringspans.py— names, never inchesnaming.py— one answer per deck namekeys.py— one wording for an unknown keyenv.py— the resolver, narrowed tostrpoly.py— reserved-region geometryshapes.pyanddeck.py— the python-pptx primitivesxml.py— parsing XML somebody else wrotemce.py— shapes insidemc:AlternateContenta11y.py— alternative text and the decorative flaglinks.py—[words](address)in copyprovenance.py— which manifest describes a deck- What was checked against pf-core
- Adding a helper here
bin/check-layers ranks utils at L2, beside motion and services. It may import
the standard library, python-pptx, and the L1 modules — src/deckwright/errors.py,
config.py, paths.py. It may not import theme, spec, layouts, components,
charts, qa or anything above them, and the refusal is exercised rather than assumed:
tests/test_layer_gate.py writes import deckwright.components into a copy of
utils/deck.py and asserts the gate exits 1 naming it.
That rank is the whole admission test. A helper that needs the theme, the grid or a slide context is not a util — it is a method on the context or a function in the subsystem holding that state. What survives the constraint is arithmetic and python-pptx mechanics, which is exactly what is here.
Two consequences, both easy to break by habit:
- No module here logs. These are pure functions; they raise, and the caller with the context decides whether that is worth a log line.
- No module here reads the environment or writes a file. A tunable belongs in the
subsystem that owns it; a helper that ever
needs to write one calls
pf_core.utils.io.atomic_write_text/_json/_bytesrather than reinventing the rename dance.
relative_luminance, contrast_ratio, required_ratio, delta_e (CIE76 colour difference)
and normalize_hex, plus the AA_NORMAL / AA_LARGE / LARGE_PT / DISTINCT_DELTA_E
constants. stands_off(fill, ground) is the rule for a fill against its ground: 3:1 apart
in luminance, or DISTINCT_DELTA_E apart in colour, so a saturated accent on a pale page
is not called invisible. qa's fill-ground check, conform's accent derivation and
diverge's bar colours all apply it. theme/palette.py, every component that
picks ink against a fill, imagery/scrim.py's auto-opacity solve and qa/geometry.py's
contrast check all decide against this one implementation, so a slide that passes at
build time and a deckwright qa finding cannot disagree about what 4.5:1 means.
The constants stay in code and out of both the theme and the environment. WCAG's
thresholds are the standard the QA check claims to implement, not an operational knob —
a deck that could lower AA_NORMAL in its theme would be reporting on a bar it moved
itself. LARGE_PT is 18.0 and WCAG's separate 14pt-bold allowance is deliberately not
honoured: components and QA both size bold text against the normal ratio.
normalize_hex raises ThemeError for anything that is not six hex digits. It takes no
error: parameter: every caller is holding a colour that came from a palette or a
template's colour scheme, so one class is right for all of them.
Never assert a colour property using contrast_ratio to compute the expectation —
docs/testing.md uses exactly that as its canonical circular test.
tests/utils/test_color.py pins the sRGB channel weights with literals (0.2126, 0.7152,
0.0722) for the same reason: a flat channel average also maps white to 1.0 and black to
0.0, so testing only the extremes cannot tell a correct implementation from a wrong one.
text_em(text, face) returns a width in ems; wrapped_lines(text, width_in=, size_pt=, face=) returns how many lines it takes in a box. A code listing has its own pair, since a
listing keeps its spacing where prose collapses it: listing_em(line, face) is a line's
width in the mono face, its trailing spaces hung, and listing_rows(line, width_in=, size_pt=, face=) the rows it wraps to, the spaces before each run charged on the row they
end. Both charge a space the monospaced table lacks — an em, figure or narrow no-break
space — a full em, since the face may not carry it and another face's em space is that wide. LINE_HEIGHT (1.2) is the line advance
those lines are multiplied by. Chrome band heights (layouts/chrome.py), card and table
geometry (components/_tablegeom.py, components/card.py), category-label room in
charts/native.py and QA's own geometry check all size off this pair, so a change here
moves the whole build at once.
_metrics.py holds per-character advance tables baked as literals from real font files:
CALIBRI (Carlito), ARIAL (LiberationSans), and CEILING, the per-character max
across Carlito, Liberation Sans, Verdana and DejaVu Sans. _metrics_faces.py holds the
brand families the bundled themes name (Poppins, Open Sans, Montserrat, Amatic, Sniglet,
Bebas Neue, Barlow Semi Condensed), each packed as one string of advances in GLYPHS
order so seven tables stay a few dozen lines, and COURIER — Liberation Mono, the metric
clone of the default mono face, one advance for every glyph. MONO is the widest
monospace advance measured (SF Mono), and table_for(face, mono=True) gives it to any face
setting a listing that has no monospaced table of its own — an unmeasured one, and one whose
name merely contains a proportional family's (Helvetica Monospaced would otherwise get
Arial's) — since every proportional table charges i, . and the space far under a
monospaced advance. Both monospaced tables also cover glyphs beyond GLYPHS, one column
each: COURIER every character Liberation Mono and Courier New both carry in Regular and
Bold, box drawing included, and MONO only the Latin every monospace cut measured carries
— Basic, Latin-1 and Extended-A — since an unmeasured face may lack anything beyond it, and
a glyph borrowed from another face can be wider. CEILING does not fold the family tables in, so
adding a family never moves the estimate for a face that has none. No font
file ships and none is read at runtime — that is
the point of baking them. Each family table is itself a max over its Regular and Bold
cuts, so no caller passes a bold flag, and table_for routes a heavy or black cut to
CEILING rather than its family, those being wider than the Bold folded in.
theme/load.py warns theme_face_unmeasured when a theme's face has no table of its
own, and every fit refusal built on the estimate appends estimate_caveat(face) — the
fallback is loose, a wide display face can outrun it, and nothing downstream can see either.
A listing passes mono=True to both measured and estimate_caveat, so a face that only
MONO measures is named as unmeasured.
A line breaks where LibreOffice breaks it. spaced_runs cuts text where a renderer may
break it: at a space other than a no-break, figure or narrow no-break one, after a hyphen
or dash, and on either side of a wide character. Not after a slash, since LibreOffice sets
a URL broken mid-word. Not after a hyphen before a digit or another dash, nor after one
opening a word (-v): UAX #14's LB25, LB21 and LB20a, each pinned in
tests/utils/test_text.py against a LibreOffice render of a box too narrow for the run. A CJK character breaks on either side, a hangul run only where Latin text would,
since Korean breaks at spaces, and _cjk.glue keeps a mark that may not start or end a line
with its neighbour (kinsoku), and a no-break space with the characters either side of it
unless a hyphen or en dash comes before it (LB12a).
Five traps for anyone editing this pair:
table_formatches Courier's names before Arial's. Arial's list carriesliberation, which would claim Liberation Mono and measure a listing proportionally.measured()compares tables by identity (is not CEILING,is not MONO). Return a copy fromtable_forand it silently reports every face as measured._routed, behindtable_for, islru_cached and returns the module-level dicts; keep it that way._MARGINinutils/text.pyerrs long by design. It covers what per-character summation cannot see — kerning, hinting, a renderer's own spacing. Lowering it is not tuning: a band sized one line short of its text draws straight through what sits below it, and neither QA'sboundscheck nor itsoverflowcheck can see a doubled-up chrome stack.- A lone word and a listing are measured without
_MARGIN.overlong_wordrefuses a build rather than sizing a box, andwrapped_linesbreaks a word too long for its line where the real width does; an allowance in either refuses, or reserves a second line for, a word that fits its widest cut.listing_emandlisting_rowsmeasure monospaced columns, which have no kerning to allow for, so the two agree on every line. advance_emover-counts every character no table carries, charging it the widest measured glyph of its class, so an accented run is never under-counted. CJK is the exception:_cjk.pycharges every ideograph, kana, hangul syllable and fullwidth formCJK_EM(1.0), the em square those scripts are drawn on in every face.
CJK lines break where the renderer breaks them. wrapped_lines sends any text carrying
CJK to a simulator built on _cjk.atoms: each ideograph and kana is breakable, a Latin or
hangul word moves whole (Korean breaks at spaces), a closing bracket, full stop, comma,
small kana or ー joins the character before it, an opening bracket joins the character
after it, and a trailing 、。,. hangs past a full line. The classes are W3C JLREQ's and
UAX #14's, pinned against LibreOffice renders in tests/utils/test_cjk.py, which also holds
text_em to never measure CJK narrower than any installed CJK font draws it. Latin-only
text never reaches the simulator.
tests/utils/test_metrics.py is both the gate and the generator. Run as a script
(bin/py tests/utils/test_metrics.py) it prints freshly measured dict literals
to paste in; run as a test it re-measures every baked value against the fonts it came
from, and separately holds text_em to never under-predict the real rendered width of
the heaviest cut of each routed family. Both halves skip when the measuring fonts are
absent, like the corpus.
The brand families are measured from Google Fonts' OFL files, which the repo does not carry.
Fetch each family's Regular and Bold cut (Sniglet's ExtraBold, Bebas Neue's single
Regular; Open Sans and Montserrat as their variable fonts, measured at the Regular and
Bold instances) and its OFL.txt from github.com/google/fonts/tree/main/ofl/<family>
into ~/.cache/deckwright/metric-fonts/<family>/, under the file names
_FACE_SOURCES in the test module lists. Without them the brand-face gates skip.
closest_match(name, options) shares the module but nothing else: it is the "did you
mean…" behind spec validation errors, a thin wrapper over difflib.get_close_matches
with a fixed cutoff.
The named fractions a placement can use (COL_SPANS, ROW_SPANS), the Share record a
split: child carries, and the parsers parse_span, parse_box and percent.
It lives in utils because it has two readers on the same tier: spec/_place.py rejects
an unknown name while parsing, and layouts/place.py resolves a known one against the
theme's grid. Either home would make the other import sideways for two tables of eight
strings.
It knows names, never inches. divides(name, divisor, key=) and
resolve(name, divisor, key=) answer in indices against a divisor the caller supplies;
the grid, the canvas and the content band all stay in layouts/place.py. What those
indices then become is placement.md; what an author writes is
authoring.md.
Two design points to preserve when adding a name or a parser:
- The parsers take the caller's error class. The same malformed
at:is aSpecErrorfrom a slide and aLayoutErrorfrom a chrome field, and utils cannot know which — so it raises the class it is handed. Follow that whenever a helper's failure means different things to different callers. spans_forraises on an unknown axis key rather than defaulting to one of the two vocabularies. Arows:error offering the column names reads as though the author wrote nonsense, when the code picked the wrong table. It raises a bareKeyError, and that is deliberate: an axis key that is neithercolsnorrowsis a caller's mistake, not an author's, so it has no place in an error an author will read.
slug_and_title(name) returns a deck's directory name and its display title from
whichever of the two was typed; deck_filename(title) folds the separators a path would
read (/, \, :) out of a leaf filename; NO_FOLD is the PyYAML dump width that
keeps a long title on one line.
Like spans.py it is here for its two readers: compile/scaffold.py writes the spec for
a new deck and spec/draft.py writes one drafted from a .pptx deckwright did not build.
Both have to land on the same directory and the same out: path for the same name.
The slug keeps letters and digits only, so a name carrying a slash or a .. cannot steer
a write out of the deck root. Case past a word's first letter survives, so ML-pipeline
titles as ML Pipeline. A name with nothing left after slugging raises SpecError unless
the caller passed a fallback.
refuse_unknown(keys, known, *, error=, where=, lead=, label=, suggest=) raises the
caller's error naming the first key outside known; unknown_field(...) is the sentence
it raises with, and prose_hint(key) the clause appended when the key holds a space. Every
unknown-key refusal in the spec, the components, charts, layouts and the theme goes through
refuse_unknown, so docs/errors.md can document one shape and an author sees the same
wording from every layer.
Three behaviours to keep when touching it:
- A key with a space in it is an unquoted comma. Every declared field is snake_case,
so a space means YAML split a flow mapping and truncated the value with it. The hint
says to quote the value, and the "did you mean" suggestion is suppressed for that key:
the nearest spelling of
items: one, twois never the answer. - A key that is not a string is YAML's reading of it.
on:loads asTrue,2021-01-01:as a date,~:asNone. Keys sort as text before the first is named, since those types do not order against a string, and the message names the key as YAML read it (true,2021-01-01,null) with a note to quote it — never a "did you mean". - The caller owns the sentence around it. Pass
where=for a full message, or leave it off for a fragment the caller prefixes;lead=andlabel=are the only wording that differs between callers. Do not build a second unknown-key message by hand — theerrors.mdrows quote this one.
env_str(arg, env_var, *, default=) is pf_core.utils.env.resolve_str with its return
narrowed from str | None to str. Every deckwright knob passes a real default, so the
None arm never happens; the wrapper says so once for the type checker instead of at each
call site. Use it for every DECKWRIGHT_* string knob — a binary path, a directory — and
keep the precedence it gives you: the caller's argument, then the variable, then the
default. Integer and boolean knobs call pf-core's resolve_int / resolve_bool
directly; those already return the narrow type.
This is the one module here whose whole body is a pf-core call, and it must never grow
into a resolver of its own. Two of pf-core's semantics ride through it unchanged: an
empty string is a value, not an absence — DECKWRIGHT_CHROME= is "configured to nothing",
and services/htmlshot.py strips and tests for exactly that — and only an unset
variable falls to the default.
point_in_poly, segments_cross, poly_hits_box and poly_x_span, in whatever units
the caller passes. Reserved in layouts/place.py is the only consumer: it stores a
region as canvas fractions, scales the points to inches, and asks whether a placement's
rect touches the region and how far the region reaches into a horizontal band.
The polygon is the reason this module exists. A brand's reserved corner is usually a
triangular wedge, and a bounding box around it would forbid the usable space above the
diagonal. poly_x_span is what lets a placement bounded by rows: above the wedge keep
the full content width.
poly_hits_box tests vertex containment both ways and then the edges, because two
rectangles can cross in a + with no vertex of either inside the other. Deleting the
edge pass leaves a gap no build would notice: the failure is a placement quietly allowed
to sit across a logo.
shapes.py is the drawing floor every native component sits on: solid, para,
textbox, rrect, rect, notes, bring_to_front. Positions and sizes are inches,
colours are RGBColor — resolution from theme roles happens above, in the component.
DEFAULT_FONT is only the fallback for a caller that passes no font; components pass
ctx.theme.face or ctx.theme.font_for(style) on every call.
Three behaviours in here are not python-pptx defaults and are relied on everywhere:
solid()clears the shadow (shape.shadow.inherit = False), which templates ship on by default for autoshapes.para()reuses the frame's first paragraph whenfirst=Trueand that paragraph is still empty, so a text frame does not open with a blank line.ALIGNandANCHORare both the enum maps and the accepted vocabulary:ALIGNSandANCHORSare whatspec/_place.py,components/_tablespec.pyandlayouts/chrome.pyvalidate against, so adding a key adds it to the wire format at the same instant.
deck.py holds the two presentation-level operations python-pptx does not expose.
open_presentation(path, what=, error=) exists because python-pptx surfaces a corrupt,
truncated or foreign package as any of PackageNotFoundError, KeyError, ValueError
or XMLSyntaxError — four classes sharing no base, which reach the CLI as a traceback
with no path in it. Every reader goes through it: compile/build.py and theme/load.py
for a template (default ThemeError), qa/inspect.py and compile/readback.py for a
built deck (error=SpecError, what="deck"). delete_slide(prs, index) drops the
slide's sldIdLst entry and releases its part relationship; compile/build.py calls it
newest-index-first to strip a template's own sample slides, because indices shift under
a forward loop.
Both modules carry deliberately broad except Exception clauses, each with a
noqa: BLE001 and a stated reason — no shared base class, a shape with no adjustment
handle, a relationship already dropped. Do not read those as licence to widen it.
Swallowing an exception silently is still forbidden; these are three named
python-pptx surfaces, not a house style.
from deckwright.utils.xml import fromstring
root = fromstring(archive.read("ppt/slides/slide1.xml"))One function, and every lxml parse in the package goes through it. lxml's default
parser expands entities declared in an inline DTD; this one refuses them, refuses
network access, and refuses the huge-tree relaxation.
That matters because a .pptx is usually not ours: conform, qa, inspect and
diff all read a package the user was handed. With expansion on, a file that merely
declares deckwright's own sample marker as an entity is accepted as one of ours, and a
DTD a few lines long expands to whatever size it likes.
tests/test_xml_safety.py is the gate, and it has two halves: the behaviour, and a
sweep refusing any direct etree.fromstring elsewhere in src/deckwright. Reach for the
helper, not the library.
from deckwright.utils.mce import resolved_shapes
for shape in resolved_shapes(slide.shapes): ...python-pptx's slide.shapes skips an mc:AlternateContent, which is how PowerPoint stores
an equation, a 3D model and other shapes from a newer namespace. resolved_shapes returns
the same proxies with each wrapper replaced by one branch: the mc:Fallback, or the first
mc:Choice when there is none. That is the branch ISO/IEC 29500-3 §7.5 gives a reader that
understands none of the extension namespaces, which deckwright is.
alternate(fallback, choice, requires=, namespace=) writes one: it puts the two branches
where fallback stood, the way equation: stores its maths. unread(element) says whether
an element sits in a branch that reader passes over, so a check can ignore what the render
never draws.
qa/inspect.py and compile/readback.py read through it, so inspect lists the shape and
diff stops reporting it gone. resolved_children(tree) is the same walk over every
child, shape or not; spec/_tree.py reads through it, so extract harvests the branch and
still names anything in it python-pptx cannot build.
Two callers ask which branch an element sits in rather than reading one:
apart(first, second)says whether no reader sees both elements, because anmc:AlternateContentholds them in different branches.qa/package.pyuses it so a Choice and its Fallback may share an id and a name while two shapes one reader sees may not.in_fallback(element)says whether the element sits in anmc:Fallback.motion/_tree.pyskips those when deciding which shapes earn a<p:bldP>, because PowerPoint reads the Choice.
describe(shape, alt=, decorative=) writes a shape's descr, or Office's
adec:decorative extension inside cNvPr's a:extLst. Without alt it removes any
existing descr, because python-pptx's add_picture writes the image's file name there.
described(shape) and description(cNvPr) read both back; the manifest and extract read
through the first, and qa's alt-text check, which has no shape proxies, through the second.
is_file_name(text) is the one test for a description that is only a file name.
spans(text) splits a line into runs around its links, plain(text) is what the line shows,
and addresses(text) lists where it links. text.py and versus's word floor measure
plain, so an address never widens a line, and figure_text stores alt: as plain.
shapes.para() writes one run per span, a link through link_run; ManifestRecorder.record
stores the words and the addresses; spec/_checks.py refuses a bad address before anything is
drawn; and spec/_tree.py writes a hyperlinked run back as markup for extract.
para(links=False) and record(literal=True) keep markup as written, which is what code
asks for. web_address_problem is the one test of an address, shared by the spec check,
para() and qa's link check.
A built deck carries its build id in dc:identifier, after BUILD_PREFIX.
find_manifest(deck, identifier) returns the manifest beside the deck under its own name,
else the one beside it recording that build id — so a copy saved under another name still
finds its build. diff reads a copy back through it, and extract takes a placement's
box from the manifest it finds. It lives here rather than in compile/ because spec/
sits below compile/ and cannot import it.
bin/check-framework's own EXEMPT table records the deliberate non-adoptions
project-wide. These are the ones specific to this package, so the check does not have to
be redone from memory:
| Helper here | Nearest pf-core | Why it stays |
|---|---|---|
color.py |
nothing | pf-core carries no colour module; the maths is the WCAG spec, not a utility choice |
text_em, wrapped_lines, _metrics.py |
nothing | font advances are a PowerPoint-shaped problem the framework has no reason to know about |
poly.py |
nothing | four functions of plane geometry; a C-extension dependency would cost more than it saves |
shapes.py, deck.py |
nothing | pf-core knows nothing about OOXML or python-pptx |
xml.py |
pf_core.parsers |
not a match: that is a stdlib html.parser walker rendering article HTML to plain text plus link records. pf-core does not depend on lxml at all, and has no XML tree parser to harden |
closest_match |
pf_core.utils.similarity |
different question, and the numbers say so — below |
keys.py |
nothing | pf-core's InvalidInputError carries a message, not a vocabulary; the wording is deckwright's |
env.py |
pf_core.utils.env.resolve_str |
it is the call, wrapped once to narrow the return type |
pf_core.utils.similarity.is_near_duplicate is the one that looks like a hit and is not.
It answers "are these two bodies of text near-duplicates" with a boolean, over character
4-shingles at a 0.75 Jaccard threshold. closest_match answers "which one of these
short identifiers did the author mean" and must rank a vocabulary and return None when
nothing is close enough. Shingling is the wrong instrument at that length: titel and
title share no 4-gram at all, so Jaccard scores them 0.0 and is_near_duplicate says
no, where difflib.SequenceMatcher scores 0.8 and the suggestion lands. difflib is
also standard library, not the third-party reach the framework-first rule is aimed at.
Nothing here hand-rolls what the framework does provide either: no logging, no
os.environ, no atomic-write dance, and no builtin ValueError or RuntimeError where
a deckwright.errors class belongs. bin/check-framework enforces all of that and names
the replacement in every failure.
- Check pf-core first. Start at
docs/pf-core/modules.md(the symlinkbin/setupcreates);../CLAUDE.mdstates the rule. The gate refuses the common cases, but it only knows the rules someone wrote down — the index is the actual check. - Check the tier. If it needs the theme, the grid, a spec document or a slide
context, it is not a util; put it in the subsystem that owns that state. L2 cannot
import L3, and
bin/check-layersruns in pre-commit and CI. - Keep it pure. Inputs and outputs, no logging, no environment reads, no file
writes. Take units from the caller rather than assuming inches or fractions —
poly.pyis unit-agnostic for exactly this reason. - Raise a
deckwright.errorsclass. Default it (asdeck.pydoes withThemeError) when one class is nearly always right; take it as anerror:parameter (asspans.pydoes) when the same failure means different things to different callers. - Test the thing a build cannot see. A helper's arithmetic is usually exercised by
the corpus already, and
docs/testing.mdsays not to restate that. What needs a unit test is what a green build hides: everyraise, every baked constant nothing downstream asserts on, and any measurement whose error direction matters — the wrap estimate has a floor test precisely because under-predicting is invisible until a render. - Write it up here and add the row to
README.mdif the module is new.