@pptx-studio/model
v0.5.0
Published
The document model: sheets, the inheritance chain, and the resolver everything else reads
Maintainers
Readme
@pptx-studio/model
The document model: the sheets, the chain between them, and the resolver everything else reads.
A .pptx renders correctly or it does not, and almost all of the difference is here rather than in
any renderer. A slide placeholder that PowerPoint wrote has no geometry, no fill, no line and no
style of its own; every one of those comes down a chain of two hops whose rules are asymmetric,
undocumented, and different from what every implementation this project has read assumes.
Sub-phase 2.9. Everything below was measured against Microsoft PowerPoint rather than argued from
the standard; the fixture is corpus/ground-truth/sheets.json and the reasoning is
docs/adr/phase-2-geometry-and-paint/0024-model-parse-and-resolve.md.
undefined means the file did not say
Every visual property is T | undefined, and absence is a fact the file states rather than a gap to
be filled in.
PowerPoint proves this about its own output. Create a slide from a layout and its title placeholder
has no a:xfrm at all. Type into it and it still has none. Nudge it one point to the right and the
whole resolved rectangle appears at once — inherited two hops, from the master, baked in:
<!-- untouched, and after typing -->
<p:sp><p:nvSpPr>…<p:ph type="title"/>…</p:nvSpPr><p:spPr/>…</p:sp>
<!-- after moving it one point -->
<p:sp>…<p:spPr><a:xfrm><a:off x="850900" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm>…So a parser that defaults geometry at parse time destroys the only record of which shapes are still bound to their layout. That record is what Change Layout reads, what the inspector's provenance chips read, and what tells a theme swap which shapes to invalidate.
The two hops do not use the same key, and neither uses the pair
This is the finding of the sub-phase, and it contradicts the plan that led to it.
| hop | matches on | when nothing matches |
| --------------- | -------------------------------------------- | -------------------- |
| slide → layout | @idx alone. @type is never consulted | orphan |
| layout → master | the folded @type, taking the first | orphan |
Measured over 46 probes, each a slide placeholder with no geometry of its own whose rendered position names the layout placeholder it matched. Scored against the 34 that had a candidate set:
| candidate rule | right |
| --------------------------------- | ----- |
| @idx alone | 34/34 |
| family (title/content) and @idx | 24/34 |
| (type, idx) | 13/34 |
| @type alone | 6/34 |
A slide title at idx 0 takes a layout body at idx 0. A slide body at idx 2 takes a layout
sldNum at idx 2. A slide title at idx 9, against a layout that holds a title at idx 0, matches
nothing and renders at the origin with zero size. No type is privileged — not the title family,
not the header-and-footer trio, which is what the shuffled-type deck was built to find.
The asymmetry is not arbitrary:
- Every stock PowerPoint layout numbers its date, footer and slide-number placeholders 10, 11 and
12, and every stock master numbers the same three 2, 3 and 4. A second hop that looked at
@idxwould inherit nothing in the templates Office ships. - A slide master's placeholder vocabulary is only
title,body,dt,ftr,sldNumandhdr. A master carryingctrTitle,subTitle,objorpicis repaired on open. So a second hop that looked at the type as written would find nothing for thectrTitlein layout 1 or the<p:ph idx="1"/>in "Title and Content".
ctrTitle folds to title and every content type folds to body on the way up. And each hop asks
with the placeholder of the sheet it is leaving: a slide obj at idx 0 reaching a layout
ctrTitle lands on the master's title, not its body.
<p:ph/> with no attributes is type="obj" idx="0" — read back through PowerPoint's own object
model as ppPlaceholderObject. ECMA's default for the attribute is body. The two agree at the
first hop because it ignores the type, and at the second because obj folds to body; they agree
by construction rather than by luck, which is worth knowing.
Every binding comes from the part's own .rels
A slide part does not name its layout, a layout does not name its master, and a master does not name its theme. Each binding lives in exactly one place, and every other candidate is a trap:
p:sldLayoutIdLstis not the binding. A two-master deck saved by PowerPoint puts all 22 layouts in one flatppt/slideLayouts/folder numbered 1 to 22, and which master owns which is recorded only in the two masters' relationship parts.ppt/presentation.xml.relshas athemerelationship, it always points attheme1.xml, and reading it gives every slide the first master's palette. On a one-master deck — which is every deck most people ever test with — that is indistinguishable from correct. Measured: two masters, two themes, andaccent1resolves to a different colour on each.- A
.relsfile is not inrIdorder. PowerPoint routinely writesrId8first.
A missing binding is not an error. PowerPoint repairs a slide with no layout, a layout with no
master and a master with no theme rather than refusing them, so loadDocument records a problem and
leaves parent or theme as null. Refusing to write one is @pptx-studio/validate's job.
The style matrix, phClr, and the background share one rule
a:fillRef and p:bgRef index the same two lists with the same offset, and they were measured
independently against the same six theme entries. All six agreed.
| @idx | means |
| ------------ | ------------------------------ |
| 0 | nothing at all |
| 1…999 | a:fillStyleLst[idx − 1] |
| 1000 | nothing at all |
| 1001… | a:bgFillStyleLst[idx − 1001] |
| out of range | clamps to the last entry |
Out of range clamps: a fillRef idx="4" against a three-entry list paints the third, and a
bgRef idx="9999" paints the last background entry. PowerPoint opens both without repairing, so a
renderer that throws refuses a deck PowerPoint shows.
Each entry is a function of a colour. <a:solidFill><a:schemeClr val="phClr"><a:lumMod
val="60000"/></a:schemeClr></a:solidFill> is the body and the reference is the call, with the
reference's own colour child as the argument — and that argument may carry transforms of its own.
PowerPoint's shape-style gallery writes <a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr>
on seven of its forty-two entries. Miss phClr and every themed shape renders black.
a:fontRef/@idx is not a number. It is major, minor or none, and PowerPoint repairs a
numeric one.
The background takes the nearest sheet that declares a p:bg — slide, else layout, else master. An
absent p:bg is the only thing that inherits: a slide declaring <p:bgPr><a:noFill/> paints nothing
and does not fall through, which is why Background is a value rather than a nullable fill.
masterClrMapping means parent, not master
a:masterClrMapping says "the map already in force". For a slide, that is the layout's map. A
layout carrying an a:overrideClrMapping changes the colours of every slide bound to it, and a
slide's own override beats its layout's — measured with two overrides that disagreed, because two
identical ones cannot tell you which applied. dk1/lt1/dk2/lt2 bypass the map either way, as
2.6 established.
And the map that resolves a p:bgRef's colour is the one in force on the sheet being asked about,
not on the sheet the bgRef was written on. A slide whose clrMapOvr remaps bg1 gets a different
background out of the same master.
The resolver reports where it looked
resolve(shape, sheet, (s) => s.xfrm);
// → { value, origin: 'masterPh', explicit: false, sheet, shape }Carrying the origin costs one field and buys four features from one function — provenance chips,
per-property Reset, layout-compatibility scoring, and correct theme invalidation — all reading the
same function the renderer reads, so none of them can drift from what is on the screen. explicit
is not the same as origin === 'shape': a layout placeholder that declares a fill is explicit about
that fill, and the slide inheriting it is not.
Geometry is not special. Fills, lines, effects and p:style all travel the same chain, and all four
were measured arriving from the layout placeholder and from the master placeholder through a layout
that declared nothing.
A table is its grid, and the spans are the whole of a merge
A p:graphicFrame whose a:graphicData is a table carries shape.table: the a:tblPr flags and
style source, every a:gridCol, every a:tr with its cells, and each cell's spans (absent reads
as 1), flags (absent reads as false), body and a:tcPr. tableGrid(table) is what PowerPoint draws
from it, and the rule was measured in C7 (corpus/ground-truth/tables.json) on 82 tables, 52 of them
built to disagree with themselves:
- Each
a:tctakes the next column. A short row is padded with empty positions; a fifth cell in a four-column grid is dropped. gridSpanandrowSpanon the anchor are the whole story, 76 of 76. A position an earlier span claimed is covered whatever its own attributes say; a span stops at the grid edge or at the first claimed position; zero is one and a negative runs to the edge.hMergeandvMergechange nothing. The flags-only reading a person writes first fits 52 of 76, and PowerPoint rewrites the flags from the spans on save.a:tr/@his the least a row is drawn at, never the most (72/72 against 68/72 for a fixed height); a column is never narrower than its cells' side margins plus 2 pt; and the frame'sa:extsays nothing about the drawn size, which is the grid's sums (76/76 against 62/76).
Cell text is a TextBody like any other.
A table draws the built-in its GUID names, or none
BUILTIN_TABLE_STYLES is PowerPoint's own Table Styles gallery: the 74 styles, in gallery order,
each with its GUID, its name and the a:tblStyle PowerPoint writes for it, byte for byte. They were
enumerated by driving the gallery itself and read back as PowerPoint serialised them (C8,
corpus/ground-truth/table-styles.json); nothing was transcribed from another implementation.
tableStyleOf(table) is the style PowerPoint draws a table with, and C8 found one rule for it
across 84 packages in three themes:
- The
a:tableStyleId- or an inlinea:tableStyle's@styleId- names one of the 74, in any case, and that built-in is drawn (230/230 across every style in two themes). - Nothing in
ppt/tableStyles.xmlchanges the drawing: a redefined built-in draws PowerPoint's own (7/7), a custom style defined there draws nothing of itself, and@defis never applied. - Any other id, or none, returns
null: PowerPoint draws a 1-pt black grid with no fill, whatever the theme (3/3 against the theme'sdk1andtx1).
builtinTableStyle(id) parses one of the 74 afresh; parseTableStyle reads any CT_TableStyle,
defaulting nothing the file did not say. A GUID without braces or with padding, an edge with neither
a:ln nor a:lnRef, and an empty a:fill throw: PowerPoint repairs each.
The thirteen parts compose in schema order, property by property
Which part of a style reaches a cell, and which wins, was measured in C9 on 10,505 slides of every
built-in in three themes (corpus/ground-truth/table-cascade*.json), against what PowerPoint's
object model reported for each cell and what it drew at 4 px a point:
tablePartsAt(props, rows, cols, row, col)is ECMA-376's reading: the end rows and columns where their flags are on, bands counted past a first row or column, a corner where both of its ends apply.TABLE_PART_ORDERis the schema's order,wholeTbllowest andnwCellhighest, and it is the order they compose in for fills, text and edges alike.- Every property is found on its own: the highest part that states a fill, a colour, bold or italic wins it, whatever that part leaves unsaid.
tableCellFill: the cell's owna:tcPrfill, else the style's.tableBackground:a:tblPr's fill and effect list, each in place of the style'stblBgone, an empty list included, painted under the cells over the grid and never the frame as written.themedEffectsfollows aneffectRefinto the theme.tableEdgeLine: an edge's owner writes the line it draws, replacing the style's whole; a line no owner writes is the highest part's claim on that edge. The cell above or left owns a segment where its anchor is level with it, in the anchor's column or row; else the cell below or right, where its anchor is; else the cell above or left. An unmerged cell is always level. A table that resolves to no style drawsDEFAULT_GRID_LINE, 1 pt black. A position noa:tcreached owns its edges as a cell writing nothing does, and a dashed line is drawn dashed.- A merged cell takes its anchor's parts and every covered position's end and corner parts; its
bands are the anchor's. A covered cell's
a:tcPris never read, though PowerPoint keeps it on save. tableCellDiagonals: the anchor's owna:lnTlToBranda:lnBlToTr, across its whole span, as drawn: mirrored in an rtl table. No built-in states a diagonal.tableTextLayeris what the style gives a cell's text, and the text cascade reads it between the cell's own list style and the master'sp:otherStyle;p:defaultTextStyleis never read, and under a master with nop:txStylesa cell takes the built-inotherstyle whatever its frame.- A position or edge the table does not have throws
MODEL_TABLE_POSITION.
What is not here
- Nothing is painted. The renderers are 2.10.
- Table drawing and editing are 4.4; SmartArt is 4.5.
- Commands, undo and history are Phase 5; Change Layout is 7.4. What PowerPoint rewrites when a layout changes is recorded in the fixture and not acted on.
a:effectDagis not modelled — a directed graph of effect primitives PowerPoint has never been observed to write. Anundefinedthere means the shape re-emits byte for byte.- Group child coordinate spaces are parsed (
a:chOff/a:chExt) and not applied; that is 2.10.
Fixtures
corpus/ground-truth/sheets.json — 114 probes in 37 packages, sub-phase 2.9.
corpus/ground-truth/tables.json — 82 tables, one package each, and 21 slides PowerPoint authored, sub-phase 4.1.
corpus/ground-truth/table-styles.json — the 74 built-in table styles and 84 probe packages, sub-phase 4.2.
corpus/ground-truth/table-cascade.json and its five table-cascade-*.json siblings — 250 packages of
tables in three themes, every cell and edge PowerPoint reported and drew, sub-phase 4.3.
The measurement is a position, and PowerPoint reports it directly. C3 and C4 sampled bitmaps
because a fill and a stroke are pictures; an inheritance is not. A placeholder with no a:xfrm of
its own still has a position, Shape.Left reports it in points, and that is the resolver's own
answer read out of the resolver rather than reconstructed from what it painted. So every candidate
parent sits in a rectangle no other candidate shares, and the rectangle a probe lands in names the
parent it matched — one number, no fitting, no error bars.
23 of the 37 packages are hostile and each is alone in its own file. 16 were refused or repaired.
model.test.ts re-derives the matcher from the fixture's 34 recorded cases rather than comparing
against a summary of them, so a rule that drifts fails on the measurements.
See docs/adr/phase-0-foundation/0007-ground-truth.md, docs/adr/phase-2-geometry-and-paint/0021-colour.md, docs/adr/phase-2-geometry-and-paint/0022-fills.md,
docs/adr/phase-2-geometry-and-paint/0023-lines.md, docs/adr/phase-2-geometry-and-paint/0024-model-parse-and-resolve.md
docs/adr/phase-4-tables-and-smartart/0056-the-spans-are-the-merge.md,
docs/adr/phase-4-tables-and-smartart/0063-a-table-draws-the-built-in-its-guid-names.md and
docs/adr/phase-4-tables-and-smartart/0064-the-parts-compose-in-schema-order.md.
