Skip to content

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

@jooyoungseo

Summary

In a document made by officecli create, --prop style=Heading1 still gives a paragraph that Word treats as plain body text. The styles part defines only Normal, so the pStyle points at nothing. #350 reported this. #355 fixed the batch part (the warning now shows in batch too), but the rest is unchanged in 1.0.151:

  • The built-in heading styles are never defined, either by create or when first used.
  • validate and view issues pass the file.
  • view outline shows 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.md Quick Start and the word skill's "Minimal viable docx" example show (create, then add ... --prop style=Heading1) has no headings for them at all. The Navigation pane and any TOC are empty too. The agent checks view 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)

officecli create a.docx
officecli add a.docx /body --type paragraph --prop "text=Introduction" --prop style=Heading1
#   WARNING: style 'Heading1' not found in styles part — will be referenced as-is
officecli view a.docx outline   # ├── [1] "Introduction" (Heading1)
officecli view a.docx issues    # nothing about the missing style
officecli validate a.docx       # Validation passed: no errors found.
officecli close a.docx

What Word actually sees

Checked in Word for Microsoft 365 (16.0.19530) through COM, reading Paragraph.Style.NameLocal and Paragraph.OutlineLevel (1–9 = heading level, 10 = body text):

Style setup before --prop style=HeadingN What officecli wrote to styles.xml Word style Outline level Real heading?
None (plain create) Nothing (only Normal is defined) Normal 10 No
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> (no w:name) (empty) 10 No
Same, with --prop name=Heading4 <w:name w:val="Heading4"/> Heading4 10 No
Same, with --prop "name=Heading 5" <w:name w:val="Heading 5"/> Heading 5 5 Yes
Same, with --prop "name=heading 3" <w:name w:val="heading 3"/> Heading 3 3 Yes

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 style says name "Defaults to 'id' when omitted", but no w:name is written at all (row 2).
  • Even if it defaulted to the ID, Word doesn't recognize Heading4 as a heading name (row 3).

view outline shows all five rows as headings. (A custom style with --prop outlineLvl=0 does get outline level 1 in Word. The word skill mentions this for TOCs, but its examples don't use it.)

Suggested fix

  1. Define built-in styles when they're first used. When 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 means w:name="heading N" and w:outlineLvl w:val="N-1", with basedOn/next = Normal. Seeding Heading1–Heading6 in the non---minimal create baseline would also work.
  2. Fix add --type style for built-in IDs. Write w:name when it's omitted, as the help says. When the ID is HeadingN, default the name to the built-in heading N.
  3. Flag the dangling reference. Report a pStyle/rStyle with no matching w:styleId in view issues (or validate), and don't show it as a heading in view outline. This is part 2 of docx: batch drops the dangling-style warning (stdout, --json, MCP); the resulting pStyle passes validate and view issues #350's suggested fix.
  4. Fix the docs until item 1 ships. Have the SKILL.md Quick Start and the word skill'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=Normal

Environment

  • officecli 1.0.151 (officecli-win-x64.exe)
  • Windows 11 Enterprise 10.0.26200; Word for Microsoft 365 16.0.19530.20184

Activity

  1. argszero commented on Sep 20, 2026

    @argszero
    Contributor

    I read the source for item 2 of your suggested fix before anyone patches it, because the missing w:name is not an oversight — it is protected by two pinned round-trip bugs, so the fix has to be narrower than "write w:name when it's omitted".

    On main, src/officecli/Handlers/Word/WordHandler.Add.Structure.cs appends the element only when the caller passed a name:

    // L1348
    if (explicitName)
        newStyle.AppendChild(new StyleName { Val = styleName });

    and with only id given, styleName is 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 no w:name);
    • BUG-DUMP-STYLE-EMPTY-NAME — a style that carried no <w:name> must come back without one (auto table sub-styles Table1/2/3 … depend on it).

    So "write w:name when 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 (Heading4 without 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:

    1. Item 1 (define built-in styles when first used, or seed Heading1-Heading6 in the non---minimal blank) is a different change with a much wider blast radius — leave it to you, or a second PR once item 2 has landed?
    2. schemas/help/docx/style.json:40 still says the name defaults to the id, which stays false for custom ids. Amend it in the same PR (name defaults to the id only for HeadingN) or leave the wording to you?
  2. argszero commented on Sep 20, 2026

    @argszero
    Contributor

    Following up on my analysis above: here is that narrow fix for item 2, finished and checked against main today.

    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 for OutlineLevel to come out right. So for id=HeadingN the name is the missing piece, and the rest of the add --type style path 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: ImpliedHeadingName returns null for every id that is not Heading1–Heading9, so BUG-R7-08 ("never default the name to the id") and BUG-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 for Table1/2/3) are untouched. The implied name is also applied only at the emit site and never fed back into styleName, 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 through dump → batch will 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 time AddStyle runs, the dump's id=Heading2 with no name key 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's styles.xml by 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 why validate accepts it.

    What I could not check is the Word side: there is no Word on this machine, so the NameLocal/OutlineLevel columns 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–Heading6 in the non---minimal create baseline) — create-affecting, and the honest target is Word's own shape: examples/word/fields.docx defines Heading1 as <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 in view outline) — untouched, and it is the reason the tool disagrees with Word: with no <w:name>, view outline resolves the level from the styleId through the ^Heading([1-9])$ fallback in WordHandler.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=Title writes 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:40 still reads "display name. Defaults to 'id' when omitted." After this change the truth becomes "for Heading1–Heading9, the built-in heading 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 onto main and open one.

  3. goworm commented on Sep 21, 2026

    @goworm
    Contributor

    Reproduced (styles part with only Normal, dangling pStyle), and confirmed the consequence in desktop Word. Fixed on main (next release) — and I went further than the name-only patch: referencing Heading1–9, Title or Subtitle on 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 both add and set; the advisory says what was added. An existing definition is never overwritten, unknown ids keep the not-found advisory, and add --type style id=HeadingN without 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, and view outline finally reports the same thing Word sees.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions