Repository navigation
docx: style=Heading1 on a new document still gives no real heading in Word, which breaks screen-reader navigation (follow-up to #350) #407
Description
Activity
I read the source for item 2 of your suggested fix before anyone patches it, because the missing
w:nameis not an oversight — it is protected by two pinned round-trip bugs, so the fix has to be narrower than "writew:namewhen it's omitted".On
main,src/officecli/Handlers/Word/WordHandler.Add.Structure.csappends the element only when the caller passed a name:// L1348 if (explicitName) newStyle.AppendChild(new StyleName { Val = styleName });
and with only
idgiven,styleNameis pinned to""(L1166-1169). The comments at that site name what the shape is protecting:BUG-R7-08— never default the display name to the id (it changed the dump of every source style that has an id but now:name);BUG-DUMP-STYLE-EMPTY-NAME— a style that carried no<w:name>must come back without one (auto table sub-stylesTable1/2/3… depend on it).
So "write
w:namewhen it's omitted, as the help says" —schemas/help/docx/style.json:40, "display name. Defaults to 'id' when omitted." — would undo both.The narrow version that fixes your rows 2-5 without touching either invariant: when no name is given and the styleId is one of Word's built-in heading ids (
Heading1-Heading9), emit Word's built-in display name —heading N, lowercase with the space, which is the spelling your rows 4/5 show Word actually matching (Heading4without the space stayed at outline level 10). Custom ids keep today's no-name behavior, so nothing that already round-trips changes. The name table is already in that file (builtInNamesForUpsert, L1210-1218), so this is a lookup, not new data.Reproduced on 1.0.149 from this machine, and the site is byte-for-byte the same on
main:officecli create a.docx officecli add a.docx /styles --type style --prop id=Heading2 --prop type=paragraph --prop basedOn=Normal # word/styles.xml: <w:style w:type="paragraph" w:styleId="Heading2"><w:basedOn w:val="Normal" /></w:style> # → no <w:name> (your row 2) officecli add a.docx /styles --type style --prop id=Heading3 --prop "name=heading 3" --prop type=paragraph --prop basedOn=Normal # <w:name w:val="heading 3" /> present (the workaround)
If that reading is right, I'd like to send a scoped PR for item 2 (heading ids only) — unless you'd rather keep it, since you have been landing these in days.
Two things I would rather not decide for you:
- Item 1 (define built-in styles when first used, or seed
Heading1-Heading6in the non---minimalblank) is a different change with a much wider blast radius — leave it to you, or a second PR once item 2 has landed? schemas/help/docx/style.json:40still says the name defaults to the id, which stays false for custom ids. Amend it in the same PR (namedefaults to the id only forHeadingN) or leave the wording to you?
Following up on my analysis above: here is that narrow fix for item 2, finished and checked against
maintoday.Your table is what makes this safe to propose. Row 5 (
--prop "name=heading 3") shows Word keys the built-in heading off the name alone — no<w:outlineLvl>was needed forOutlineLevelto come out right. So forid=HeadingNthe name is the missing piece, and the rest of theadd --type stylepath can stay exactly as it is.diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs b/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs --- a/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs @@ -1216,6 +1216,23 @@ "List Paragraph", "No Spacing", "TOC Heading", "Default Paragraph Font", "Table Normal", "No List", }; + // Issue #407: the display name Word resolves a built-in heading by is + // `heading N` (lowercase + space — the convention builtInNamesForUpsert + // follows). It is the *only* channel: Word keys its built-in styles by + // name, so a style carrying `w:styleId="Heading2"` and no <w:name> is a + // nameless custom style to Word (body text), while this tool's own + // `view outline` still resolves level 2 — it falls back to the styleId + // when the style has no name (WordHandler.Helpers.Style + // .GetParagraphOutlineLevel). Map the nine built-in heading ids only: + // every other id keeps the "no name in, no name out" shape that + // BUG-R7-08 / BUG-DUMP-STYLE-EMPTY-NAME pinned for the dump→batch + // round trip (auto table sub-styles Table1/2/3 carry no name). + static string? ImpliedHeadingName(string id) => + id.Length == 8 + && id.StartsWith("Heading", StringComparison.OrdinalIgnoreCase) + && id[7] is >= '1' and <= '9' + ? $"heading {id[7]}" + : null; if (IdTaken(styleId)) { if (builtInIdsForUpsert.Contains(styleId)) @@ -1345,8 +1362,13 @@ // the decomposition's exact-element-multiset guarantee). BUG-R7-08 // (never default the name to the id) is still honored: no name in, no name // out. An explicit empty name (name="" passed) still emits <w:name w:val=""/>. - if (explicitName) - newStyle.AppendChild(new StyleName { Val = styleName }); + // Issue #407: one exception — a built-in heading id implies its display + // name, because <w:name> is how Word recognises the built-in heading at + // all (see ImpliedHeadingName above). Without it the style stays an + // unnamed custom style and Word renders its paragraphs as body text. + var impliedHeadingName = explicitName ? null : ImpliedHeadingName(styleId); + if (explicitName || impliedHeadingName != null) + newStyle.AppendChild(new StyleName { Val = impliedHeadingName ?? styleName }); if ((properties.TryGetValue("basedon", out var basedOn) || properties.TryGetValue("basedOn", out basedOn)) && !string.IsNullOrEmpty(basedOn)) newStyle.AppendChild(new BasedOn { Val = basedOn });
Why it is scoped to the nine heading ids. Both invariants I flagged stay intact:
ImpliedHeadingNamereturns null for every id that is notHeading1–Heading9, soBUG-R7-08("never default the name to the id") andBUG-DUMP-STYLE-EMPTY-NAME(a style added with an id and no name must round-trip without<w:name>— what makes the recursive style decomposition's element-multiset guarantee hold forTable1/2/3) are untouched. The implied name is also applied only at the emit site and never fed back intostyleName, so the collision rules below (NameTaken, the built-in name upsert) stay keyed on the caller's own name: a caller who claimed no name cannot have an unrelated existing style removed on the strength of one we invented.One behavioural trade-off, so you can weigh it. The only shape where this is not a pure no-op: a source docx carrying a nameless
styleId="Heading2"— row 2 of your table, and what 1.0.151 itself writes — round-tripped throughdump→batchwill now gain<w:name w:val="heading 2"/>on replay. That is the direction the issue wants (the replayed style becomes a real heading rather than a nameless custom one), but the replay is no longer element-for-element identical for that one shape. I could not find a clean discriminator to gate it on — by the timeAddStyleruns, the dump'sid=Heading2with nonamekey is indistinguishable from a user's--prop id=Heading2— so I left the honest behaviour and flagged it here rather than hiding it.Verification (the patched source is not buildable here — see the last line — so this is the patch level, plus the XML the change produces):
patch -p1 --dry-run --forward -i r2-407-style-name.patch # rc=0 on origin/main bytes, no .rej git apply --check r2-407-style-name.patch # rc=0 (stricter: hunk counts must match) # applied result byte-identical to the intended text; 1 file, +24/-2, 2 hunks
The emitted element — installed 1.0.149, with the
<w:name>put into a copy'sstyles.xmlby hand, nothing else touched:officecli create a.docx officecli add a.docx /styles --type style --prop id=Heading2 --prop type=paragraph --prop basedOn=Normal # before: <w:style w:type="paragraph" w:styleId="Heading2"><w:basedOn w:val="Normal" /></w:style> # get /styles/Heading2 → name= (empty) # after: <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2" /><w:basedOn w:val="Normal" /></w:style> # get /styles/Heading2 → name=heading 2 # validate → Validation passed: no errors found. officecli add a.docx /body --type paragraph --prop "text=Introduction" --prop style=Heading2 officecli view a.docx outline → ├── [1] "Introduction" (heading 2)
That is your row 5's shape.
<w:name>landing before<w:basedOn>is not luck — it is the order this codebase already enforces (StyleChildOrder:StyleName => 0,BasedOn => 2), which is whyvalidateaccepts it.What I could not check is the Word side: there is no Word on this machine, so the
NameLocal/OutlineLevelcolumns above are your measurements and mine stop at the XML plus this tool's reader.Deliberately not in this patch
- Item 1 (define built-in styles when they are first used, and/or seed
Heading1–Heading6in the non---minimalcreatebaseline) —create-affecting, and the honest target is Word's own shape:examples/word/fields.docxdefinesHeading1as<w:name w:val="heading 1"/>+<w:pPr><w:outlineLvl w:val="0"/></w:pPr>+b/color/sz. I would rather you set that shape than have me guess at it in a patch that touches every caller. - Item 3 (flag the dangling
pStyle, stop showing it as a heading inview outline) — untouched, and it is the reason the tool disagrees with Word: with no<w:name>,view outlineresolves the level from the styleId through the^Heading([1-9])$fallback inWordHandler.Helpers.Style.GetParagraphOutlineLevel, so it reports a heading for exactly the row-2 shape Word calls body text. Title/Subtitle— same shape (add /styles --type style --prop id=Titlewrites no<w:name>either, and Word keys built-ins by name there too). Left out to keep this atomic and heading-scoped; say the word and I will follow up.schemas/help/docx/style.json:40still reads "display name. Defaults to 'id' when omitted." After this change the truth becomes "forHeading1–Heading9, the built-inheading N; otherwise omitted". CONTRIBUTING puts docs in your post-merge cleanup, so I left that line alone.
I could not open this as a PR: the checkout I have of this repo is mounted read-only, so there is no branch to push. The patch above is the deliverable, ready to
git apply. If you would rather review it as a PR, point me at a writable clone and I will rebase it ontomainand open one.- Item 1 (define built-in styles when they are first used, and/or seed
Reproduced (styles part with only Normal, dangling
pStyle), and confirmed the consequence in desktop Word. Fixed onmain(next release) — and I went further than the name-only patch: referencingHeading1–9,TitleorSubtitleon a document that does not define it now materializes Word's own definition (display name, basedOn/next Normal, outline level, default size/colour/keep settings) the first time the id is used, on bothaddandset; the advisory says what was added. An existing definition is never overwritten, unknown ids keep the not-found advisory, andadd --type style id=HeadingNwithout a name gets Word's display name (custom ids stay nameless — round-trip contract). Verified with a real Word render: Heading 1/2 paragraphs come out as headings, so Navigation pane and screen-reader jumps work, andview outlinefinally reports the same thing Word sees.


Summary
In a document made by
officecli create,--prop style=Heading1still gives a paragraph that Word treats as plain body text. The styles part defines onlyNormal, so thepStylepoints at nothing. #350 reported this. #355 fixed thebatchpart (the warning now shows inbatchtoo), but the rest is unchanged in 1.0.151:createor when first used.validateandview issuespass the file.view outlineshows the paragraph as a heading.Why this matters (accessibility): Screen-reader users (JAWS, NVDA, and others) rely on headings to move around a Word document. A document built the way the
SKILL.mdQuick Start and thewordskill's "Minimal viable docx" example show (create, thenadd ... --prop style=Heading1) has no headings for them at all. The Navigation pane and any TOC are empty too. The agent checksview outline, sees headings, and reports the document as done.While working around it I found a second problem: defining the style yourself only works if its name is exactly the built-in
heading N(details below).Repro (officecli 1.0.151)
What Word actually sees
Checked in Word for Microsoft 365 (16.0.19530) through COM, reading
Paragraph.Style.NameLocalandParagraph.OutlineLevel(1–9 = heading level, 10 = body text):--prop style=HeadingNcreate)Normalis defined)add /styles --type style --prop id=Heading2 --prop type=paragraph --prop basedOn=Normal<w:style w:type="paragraph" w:styleId="Heading2"><w:basedOn w:val="Normal"/></w:style>(now:name)--prop name=Heading4<w:name w:val="Heading4"/>--prop "name=Heading 5"<w:name w:val="Heading 5"/>--prop "name=heading 3"<w:name w:val="heading 3"/>Word matches a style to its built-in heading by the name
heading N(case-insensitive, with the space), not by the style ID. So:help docx add stylesaysname"Defaults to 'id' when omitted", but now:nameis written at all (row 2).Heading4as a heading name (row 3).view outlineshows all five rows as headings. (A custom style with--prop outlineLvl=0does get outline level 1 in Word. Thewordskill mentions this for TOCs, but its examples don't use it.)Suggested fix
style=names a built-in style ID that isn't in the styles part (Heading1–Heading9,Title,Subtitle, …), insert Word's built-in definition, as Word does the first time you apply a built-in style. For headings that meansw:name="heading N"andw:outlineLvl w:val="N-1", withbasedOn/next=Normal. SeedingHeading1–Heading6in the non---minimalcreatebaseline would also work.add --type stylefor built-in IDs. Writew:namewhen it's omitted, as the help says. When the ID isHeadingN, default the name to the built-inheading N.pStyle/rStylewith no matchingw:styleIdinview issues(orvalidate), and don't show it as a heading inview outline. This is part 2 of docx:batchdrops the dangling-style warning (stdout, --json, MCP); the resulting pStyle passes validate and view issues #350's suggested fix.SKILL.mdQuick Start and thewordskill's "Minimal viable docx" example define the heading styles first. Agents copy those examples as they are.Workaround
officecli add a.docx /styles --type style --prop id=Heading1 --prop "name=heading 1" --prop type=paragraph --prop basedOn=Normal --prop next=NormalEnvironment
officecli-win-x64.exe)