Companion to SPEC.md. The spec is the contract: what to build, what the
public API looks like, what the quality gates are. This document is the
meta-guidance: how to think about building it, what order to do it in, where
the implementation will tempt you to cut corners, and how to know when
something is actually working versus merely looking right.
Read both before starting. The spec without these notes leads to reasonable code that drifts on the things that matter; these notes without the spec lead to well-organized code that doesn't meet the contract.
The thing to internalize before writing any code: OOXML correctness is the hard part of this project, not Python design. The algorithms are unsurprising. The decomposition is laid out in the spec. What's hard is producing XML that Word actually accepts and renders the way you intend — because Word's tolerance for malformed input is asymmetric, silent failure modes are common, and python-docx's abstractions stop right before the parts where correctness gets subtle.
Three implications follow from this:
Tight feedback loops matter more than usual. Don't write 500 lines of cascade resolver and then run the tests. Write the smallest unit, verify it produces correct XML against a hand-checked reference, then build on it. The cost of being wrong at the XML layer is higher than the cost of being wrong at the API layer, because XML errors compound silently.
Verification has to go beyond pytest. Unit tests catch shape errors.
They don't catch "Word opens this file but renders it differently than you
expected" — that requires actually opening it. LibreOffice headless +
visual inspection of the rendered output is the substitute for human Word
testing on a Linux runner. Build that into the workflow from week one, not
as a polish step.
Specs and tests precede implementation, then implementation precedes documentation, then documentation precedes commit. This is Command Coding in practice: you have a spec (this doc + SPEC.md), you write the test fixture and the test, you write the minimum implementation to pass, you write the docstring, you commit. Inverting this order — writing implementation first and tests after — is how subtle correctness bugs survive.
The phases below are ordered by dependency, not by importance. Don't parallelize across phases until Phase 2 is complete; the foundation has to be solid before the rest can rest on it.
- Repository skeleton:
pyproject.toml, directory tree from SPEC §2,.gitignore,LICENSE, emptyREADME.md - CI configuration: pytest, mypy strict, ruff. Get CI green on a trivial test before going further.
core/ns.pyandcore/oxml.pywith tests. These are small (~50 lines each) and trivial to verify. Every later module depends on them.core/ids.pywith tests for: registry from empty doc, registry from doc with existing IDs, collision avoidance,.reserve()raising on already-issued values._testing/ooxml_asserts.pywith at least the basic asserts. Build out as later tests demand more.- The import-invariant test (SPEC §9.1). Write this early — it will catch architectural drift the moment it happens.
- The fixture-building script in
tests/fixtures/build_fixtures.py, generating at minimumempty.docxandmultistyle.docx. The other fixtures come as tests need them.
Phase 1 is done when: pytest passes, mypy --strict passes, ruff check
passes, CI is green, and a placeholder integration test that builds a doc
through core/ helpers and saves it can be opened by python-docx.
This is the largest single phase and the most consequential. Implement in this order:
styles/theme.py— read-only theme color resolution. Self-contained, testable in isolation, needed by the cascade resolver. Implement the theme color lookup, thenthemeShade/themeTint/lumMod/lumOfftransforms per ECMA-376 17.18.40.- Cascade resolver, no provenance —
resolve_effective_formattingwalking the six layers from SPEC §4. Toggle handling. Cycle detection. Get this completely right before adding provenance. - Tests for the cascade — at least one test per layer in isolation, then layered combinations, then the toggle XOR cases.
- Provenance pass — modify the resolver to track provenance optionally.
This should be a separate pass that doesn't change the values returned
when
include_provenance=False. Verify the no-provenance path still produces identical output. - Theme resolution edge cases — missing theme part, malformed theme, theme references that don't match any defined color.
Phase 2 is done when: every test in the spec's "Test requirements" for
inspect passes, the provenance output is human-readable, and
examples/inspect_document.py produces output that matches its docstring's
sample format on at least three test fixtures.
With inspection solid, modification has a built-in verification loop: modify a style, resolve a paragraph using it, assert the values match. This loop catches almost all correctness bugs.
create_style,modify_style,delete_style,apply_styleensure_stylewith the known-built-ins table. This is the trickiest piece — generate the built-in definitions by extracting them from a doc Word has materialized them in, not by guessing.StyleProxyandlist_styles- Round-trip tests for every operation
Phase 3 is done when: the create-then-resolve round-trip works for all
documented properties, latent built-in materialization works for every
style in the known-built-ins table, and examples/restyle_existing.py
demonstrates a meaningful restyle (e.g., change Heading1 color across a
document with many headings).
The docx-forms skill prototype is the starting point. Port FormBuilder
into controls/builder.py, adapting it to use IdRegistry from core/.
Carry over the skill's test harness assertions into tests/test_controls.py.
Then write controls/read.py — this is new code. The ControlValue
dataclass and the read_controls / set_control_value / clear_control
trio. Test round-trips: build → save → read → modify → save → re-read.
Phase 4 is done when: every test from the skill harness passes (adapted
to the library structure), read_controls correctly identifies all five
control types, and the populate-then-read round-trip works for every type.
Small, focused, mostly mechanical. fields/simple.py, fields/update.py,
protection/document.py. Idempotency tests for mark_fields_dirty and
protect_document matter — these are the obvious cases where calling
twice should not produce two elements.
- Complete the examples directory
- Write
README.md,ARCHITECTURE.md - Generate
API.md(via pdoc or mkdocs-material) - Final quality-gate sweep against SPEC §13
- Layer 3 smoke tests with LibreOffice
Total: ~10–14 working days for v0.1. Plan for 50% more — OOXML edge cases will eat time you don't expect.
The first session is about getting the foundation right, not making progress on features. Specifically:
git init, repo skeleton matching SPEC §2's tree (empty__init__.pyfiles in every package, even the ones with no code yet).pyproject.tomlwith dependencies, dev dependencies, build system, tool configs for mypy and ruff.- A trivial test in
tests/test_smoke.pythat importsdocx_plusand assertsTrue. Runpytest. It should pass. - CI configuration (GitHub Actions, GitLab, whatever the host is). It
should run:
pytest,mypy --strict docx_plus/,ruff check docx_plus/,ruff format --check docx_plus/. Push, watch it go green. - The first real code:
core/ns.py(~30 lines, namespace constants andqn). Its test (~20 lines, asserts the Clark notation is correct). Commit. core/oxml.py(~80 lines,el,sub,xpath,remove). Its tests. Commit.
End of day 1: a green repository with the foundation primitives. No features yet, but every subsequent commit builds on solid ground. This matters because if CI breaks on day 5, you want the bisect range to be five days, not the entire project history.
A few patterns will recur across modules. Worth establishing the conventions early and using them consistently.
Never construct XML elements directly with lxml.etree.SubElement or
python-docx's OxmlElement. Always go through core/oxml.py:
from docx_plus.core.oxml import el, sub
from docx_plus.core.ns import qn
# Instead of:
# style = OxmlElement('w:style')
# style.set(qn('w:type'), 'paragraph')
# Use:
style = el("w:style", **{"w:type": "paragraph"})This makes it trivial to add hooks later (validation, logging, namespace verification) without rewriting every call site.
OOXML containers like CT_Settings and CT_PPr have required child
ordering. Inserting an element at the wrong position produces a file Word
will silently "repair" — which sometimes works, sometimes doesn't, and is
always a bug waiting to surface.
Don't append blindly. Use the pattern from the skill's protect():
anchor = parent.find(qn("w:knownLaterSibling"))
if anchor is not None:
anchor.addprevious(new_element)
else:
parent.append(new_element)Or, for elements that should come first: parent.insert(0, new_element),
guarded against existing instances.
Functions that modify document state should be idempotent where it makes
sense — calling mark_fields_dirty(doc) twice produces the same result as
calling it once. The pattern:
def mark_fields_dirty(doc):
settings = doc.settings.element
existing = settings.find(qn("w:updateFields"))
if existing is not None:
existing.set(qn("w:val"), "true")
return
el = sub(settings, "w:updateFields", **{"w:val": "true"})Test the idempotency explicitly. It's exactly the kind of thing that breaks silently when someone "improves" the function later.
Use Paragraph | Run | _Cell style unions, not Union[...]. Use Optional
explicitly for nullable returns; don't rely on T | None for return types
where the user needs to think about whether None is meaningful. For OOXML
element types, use lxml.etree._Element — yes, the underscore-prefixed name
is the canonical one.
The hardest single module in the library. A few specific recommendations:
Implement layers in order, not all at once. Get docDefaults working end-to-end first (resolver + tests + a fixture that exercises only docDefaults). Then add the paragraph style chain. Then numbering. Then direct formatting. The order roughly tracks complexity, and each layer's tests live in the resolver's correctness when later layers are added.
Toggle properties are the highest-risk part. Write a dedicated test
file for them: tests/test_cascade_toggles.py. Cases to cover:
- Style defines bold, no further override → effective bold
- Style A bold, style B basedOn A bold → effective not bold (XOR)
- Style A bold, style B basedOn A
w:b w:val="false"→ effective false (explicit override, not XOR) - Direct formatting bold on paragraph using a non-bold style → effective bold
- Direct formatting
w:b w:val="false"on paragraph using a bold style → effective not bold
These cases will look pedantic in the test file. Keep them. They are the ones that fail in production six months later when someone tweaks the implementation.
Cycle detection lives in one place. When walking basedOn, keep a set
of visited style IDs. If the next style is in the set, raise
StyleCascadeError with a clear message including the cycle path. Don't
spread cycle detection across multiple functions.
Provenance is a separate pass, not interleaved. First implement the
resolver returning values only. Then implement provenance as either (a) a
second walk that records sources, or (b) a single walk with provenance
tracking gated behind the include_provenance flag. Option (b) is more
efficient; option (a) is easier to verify. Pick (a) for v0.1 unless
profiling shows it matters. The cost of getting provenance wrong is a
worse debugging experience for users; the cost of getting the values wrong
is silent rendering bugs.
Theme resolution can fail gracefully. If themeColor="accent1" is
encountered but theme1.xml is missing or malformed, return the
unresolved theme name as the value and set partial=True. Don't raise.
Users running the inspector on diverse real-world documents will hit this,
and a raise turns the inspector into something they can't use.
Unit tests are necessary but not sufficient. The supplementary techniques:
Unzip and inspect. A .docx is a zip. To check what your code
actually produced:
unzip -d unpacked my_test_output.docx
xmllint --format unpacked/word/document.xml | less
xmllint --format unpacked/word/styles.xml | lessGet fluent at this. When a test fails and you can't see why from the assertion alone, unzip and look. The XML is the ground truth, not your mental model of what the code should have produced.
Diff against Word's own output. When implementing something with subtle correctness requirements (style materialization, complex fields, content controls), the most reliable verification is: produce the construct in Word, save, unzip, look at what Word wrote. Reproduce that structure in your code. Diff your output against Word's. The places where they differ are the places to investigate.
LibreOffice headless conversion as a smoke test. Running
soffice --headless --convert-to pdf <file> against your output catches a
broad class of "file is structurally invalid" bugs. Not a substitute for
correctness assertions, but a fast signal that the file at least parses
cleanly. Run it in CI on every example and every test fixture.
OOXML schema validation. The ECMA-376 schemas (.xsd files) can
validate your output against the strict schema. Useful for end-of-phase
verification, less useful during active development (the error messages
are dense). Set this up but don't make it part of the unit test loop —
make it part of the pre-release checklist.
Things that will go wrong if you're not watching for them.
Namespace declarations on the root. Some elements require their
namespace to be declared on the document root, not just on the element
itself. The w14 namespace for checkboxes is the canonical example —
python-docx's default Document() doesn't declare it, so adding a w14
element silently produces XML that some renderers can't parse. The skill's
FormBuilder handles this implicitly because python-docx 1.2.0 happens to
declare w14 — verify this is the case in the library's test suite and add
explicit declaration logic if not.
OxmlElement is python-docx-private. The docx.oxml.OxmlElement
helper is used throughout the python-docx codebase but is not part of its
public API. Using it directly works today and may break in the future. The
library uses lxml.etree directly via core/oxml.py to avoid this
exposure. Don't reach for OxmlElement even when it would be convenient.
paragraph._p is python-docx-private. Same warning. The library will
need to reach into python-docx internals occasionally — accept that, but
contain it. The pattern: capability modules access internals only through
small adapter functions in core/, so when python-docx changes, one place
needs updating.
w:id is one of many ID-like attributes. Don't confuse w:id on
SDTs with r:id (relationship IDs), w:bookmarkStart/@w:id (bookmark
IDs), w:commentRangeStart/@w:id (comment IDs). They're separate
namespaces with separate uniqueness requirements. IdRegistry in v0.1
handles only w:id on w:sdt elements. The other ID types can share the
infrastructure later but should be separate registries.
Style ID vs style name. The styleId (w:styleId attribute) is the
machine-readable identifier; the name (w:name) is the human-readable
display name. Word often shows the name in its UI but stores references by
ID. The library should accept IDs everywhere and treat names as a
convenience. Don't confuse them in API design — a function that takes "the
style" should take the ID, not the name.
python-docx versions matter. python-docx 1.0 made breaking changes
from 0.8. The library targets 1.0+. Don't rely on behavior from earlier
versions; don't assume future versions will be stable. The dependency pin
in pyproject.toml should be tight.
Latent built-ins look like missing styles. When ensure_style(doc, "Heading1") is called on a fresh document, Heading1 is not in
styles.xml — but it is defined by Word's defaults. The library has to
know about this distinction. The known-built-ins table in styles/modify.py
is what makes that knowledge explicit. Build it by extracting style
definitions from documents Word has materialized them in.
Some kinds of tests pay off more than others in this library.
High-value: round-trip tests (build → save → read → assert), toggle
property cases, cycle detection cases, schema-order assertions (the
"documentProtection precedes defaultTabStop" style), idempotency
tests.
Medium-value: structural assertions (right element with right attributes), error-condition tests (does this raise the right error?).
Low-value: tests that assert "calling this function doesn't crash" without checking output. Tests that mock python-docx (don't — use real documents).
The shared assertion library pays for itself. Every time you write
the same XPath query in three different tests, that's a candidate for
_testing/ooxml_asserts.py. Refactor early, not at the end.
Fixtures should be generated, not committed. The
build_fixtures.py script is the source of truth. Commit the script,
not the .docx files it produces. This means: anyone can inspect what
the fixture is, regenerate it if dependencies change, and verify the
fixture-building code is itself correct.
Smoke tests for examples. Each example script in examples/ should
run successfully as part of the test suite. Catches the case where the
library's public API changed and the examples weren't updated.
When LibreOffice opens a file but Word says "Word found a problem with content," or vice versa, the workflow:
- Unzip the file.
unzip -d broken broken.docx. - Validate each part.
xmllint --noout broken/word/document.xml— does it parse as XML at all? Repeat forstyles.xml,settings.xml,numbering.xml. - Check
[Content_Types].xml. Every part must be registered with the right content type. A new custom XML part without a content-type registration is silently invalid. - Check
_rels/.relsandword/_rels/document.xml.rels. Every relationship must point to a part that exists. - Compare against a known-good file. If you have a file Word produced that contains the same construct, unzip it and diff. The difference is your bug.
- Schema-validate. Run the ECMA-376 schemas against the part. Errors point to specific attributes or child elements.
- Bisect. If you can't find it, comment out half your modifications and try again. Repeat until the file is accepted, then re-add the smallest piece that breaks it.
This procedure isn't elegant, but it's deterministic, and the bug is always findable. The instinct to "stare at the code and hope" is the trap. Open the file, look at the XML.
Each phase has a clear exit criterion beyond "the tests pass":
- Phase 1: Foundation is green in CI; placeholder integration test builds a doc and python-docx re-opens it.
- Phase 2:
examples/inspect_document.pyproduces useful output on three real-world documents (not just fixtures). The provenance feature works end-to-end. - Phase 3:
examples/restyle_existing.pydemonstrably changes a document's appearance when viewed in Word/LibreOffice. - Phase 4:
examples/build_form.pyandexamples/populate_form.pyround-trip. The form opens in Word and the controls work. - Phase 5: Page numbers in a generated document show the correct
values after Word opens it (verifies
mark_fields_dirty). - Phase 6: Every gate in SPEC §13 is green.
Don't move on from a phase until its exit criterion is met. The temptation is to leave loose ends to fix later; the cost is debugging multi-phase issues without knowing which phase introduced them.
A user encountering the library should be able to:
pip install <package>, no surprises in dependencies- Skim the README in 60 seconds and understand what the library does
- Run any example without modification, on any platform with Word or LibreOffice installed
- Use
resolve_effective_formattingon a real-world document and get output that helps them understand why a paragraph looks the way it does - Use
modify_styleto restyle a document in the Word-native way, see the change reflected when the document is opened in Word - Build a fillable form with
FormBuilder, distribute it, and have recipients fill it in Word without issues - Read the API docs and find every public function documented with at least one example
Anything less than this is incomplete v0.1 and should not ship.
Anything beyond this is v0.2 and should not ship in v0.1.
Hold the line in both directions.
Tracks state across multi-session work. Each entry: date, phase, what was done, what's next. Most-recent at top.
Systematic review of the v0.2 in-place expansion before tagging.
Findings compiled in issues.md (79 total: 5 Critical, 17 High, 23
Medium, 21 Low, 13 Nit), resolved across themed sessions, each a
separate commit cross-referencing finding ids.
-
Session A — cascade correctness. C2 (run
rStyleapplies before direct rPr), H1 (table-style row/col precedence), H2 (dstrikeread + surfaced), H4/H5 (band2 reachability + instance band-size). H3 closed wontfix (paragraph-mark rPr scope is the pilcrow, per ECMA-376 17.3.1.31). -
Session B — schema / part wiring. C1 (footnote/endnote separator seeding), C3 (
pgBorderschild order), H6 (edit verbs strip all block-level children), H7 (offsetFrom+ points unit), H8 (single-passclear_all_comments). -
Session C — error taxonomy + interleave. C4 (SPEC §9.7/§16 raw- exception carve-out), H9 (single-walk table-style chain), H10 (drop duplicated
insert_before_first_anchor), M1/M2 (field instruction validation). -
Session D — publishing hardening. H11/M16 (field-instruction injection validation), H12 (
add_toclevels validation), H13 (additional_styles/\tswitch), M14 (Caption-style doc note), M15 (optionallabel). Newpublishing/_validate.py. -
Session E — docs / release-day. H17 (six toggles writable via
create_style/modify_style, +12 round-trip tests — the one code change in this session); H14 (test count refresh — this entry); H15/ M17 (four undocumented errors + referencemembers:audit, eight drifted symbols restored); H16 (SPEC reframed as v0.1 contract + §15 annotated); M4/M8/M19 (docstringRaises/ scope notes); C5 (alpha→beta classifier; PyPI banner deferred to a publish decision); nits N1/N3/N9/N10/N13. -
Gates green at Session E. 631 tests pass;
mypy --strictclean;ruff checkclean;mkdocs build --strictclean. -
Session F — tests & smells. Closed every remaining finding. Cascade: theme-font resolution + accurate
partial(M10, M9, M13). Comments: registry seeds fromcommentRangeEnd(M3), cleanup preserves shared-run text (M6),clear_all_comments(remove_part=…)(L17), ms timestamps (L1). Styles modify: style-type-aware matching + multi-part reference scanning (M11, M12). Layout:Bordercolor validation + schema-strictset_columns/ section-breakw:type(M5, M7),distancevalidation (L15), settings dedup (M18). Core hygiene:core/errors.pyextraction (L14),xpathcache (L11), uniformetreeimports (L13), sharedbody_document_for(N4). Docs/tests:notes-v0_*refs removed (M20), conftest/fixture duality resolved + LibreOffice covers all examples (M21, M22), TEST_GAPS status note (M23), plus L2–L10/L16/L18–L21 and nits N2/N5–N8/N11/N12. -
Gates green at Session F. 717 tests collected (709 pass, 8 LibreOffice- skipped);
mypy --strictclean;ruff checkclean;mkdocs build --strictclean. All 79 issues.md findings resolved (H3 wontfix; L7 already resolved in Session A).
Locked scope per notes-v0_2-expansion-scope.md at repo root: extend
0.2.0 in-place rather than bumping to 0.3.0. Two groups of work:
Tier A "unfinished business" (closes published CHANGELOG and v0.1
"Known limitations" bullets) plus a new publishing/ module.
- Phase 0 — Toggle property completion. Extended
_TOGGLE_RPRandResolvedFormattingindocx_plus/styles/inspect.pywith the six previously-deferred toggles (cs_bold,cs_italic,emboss,imprint,outline,shadow). All twelve ECMA-376 17.7.3 toggles are now surfaced; the docstring caveat about "spec-recognised but not yet exposed" is gone. 18 new tests (3 parametrised × 6 toggles). - Phase 1 — Bulk comment delete.
clear_all_comments(doc)indocx_plus/comments/anchor.pyroutes each id throughdelete_comment. 5 new tests (empty doc, full clear, content preservation, idempotency, round-trip). - Phase 2 — In-place edit verbs.
edit_comment(doc, id, text)incomments/anchor.py;edit_footnote/edit_endnoteinnotes/write.py. Shared paragraph-builder helpers extracted from both add and edit paths.CommentNotFoundErrorandNoteNotFoundError(bothDocxPlusError, KeyError). 18 new tests total (8 comment edits + 10 note edits + reserved-id parameterised cases). - Phase 3 — Layout completion. New
docx_plus/layout/line_numbering.pywithset_line_numbering(section, *, count_by, restart, start, distance)anddocx_plus/layout/borders.pywithBorderdataclassset_page_borders(section, *, top, bottom, left, right). Both schema-strict viacore.insert_before_first_anchor. 19 new tests (9 line numbering + 10 borders) covering emission, validation, idempotency, schema-position, and round-trip.
- Phase 4 — Conditional table-style branches. Extended the
cascade resolver in
docx_plus/styles/inspect.pyto apply<w:tblStylePr>branches in ECMA-376 17.7.6.5 precedence order. NewTableContextdataclass; auto-derives from_Cellposition or takes an explicit override. Closesdocs/TEST_GAPS.mdN2. 13 new tests intests/test_styles_table_conditional.py. - Phase 5 —
publishing/module. Three helpers —add_toc,add_caption,add_table_of_figures— composingcore.build_complex_field. None auto-callmark_fields_dirty(would violate the no-cross-capability-imports invariant); the user calls it once before save. New examplepublishing_layout.py. 23 new tests across three test files + smoke test wiring. Bibliography intentionally deferred to v0.3 (depends on CXML data binding, also v0.3). - Phase 6 — Release polish. README capability bullets + two new
usage blocks (layout extras, publishing);
CHANGELOG.md[0.2.0]extended with "Added — in-place expansion" subsection and rewritten "Deferred to v0.3+" list;mkdocs.ymlnav extended with five new reference pages;docs/index.mdcapability bullets- four new quickstart blocks;
docs/API.mdpublishing/section - updated module rows;
docs/ARCHITECTURE.md§1 layout tree, §7.7 (layout) extended, new §7.10 (publishing), §9 error table (two new errors), §10 test count (532), §11 v0.3 deferred list rewritten;docs/TEST_GAPS.mdN2 closed; this entry;tests/test_import_invariant.pyCAPABILITIESset extended to cover all nine capability subpackages.
- four new quickstart blocks;
- Gates green. 532 tests pass (was 431 + 101 new); 93.10%
coverage;
mypy --strictclean on 48 source files;ruff checkclean. Every new file (layout/line_numbering.py,layout/borders.py, all threepublishing/modules) at 100% line coverage.
Held for v0.3+: w15 threaded comments (respond / resolve /
reopen); STYLEREF / sequence-field cross-references; CLI;
Custom XML Parts data binding; bibliography; tracked changes;
glossary placeholder text; password-protected forms; theme writing;
restyle planner; sections / headers / footers first-class API.
Locked scope per notes-v0_1-scope.md §6: full four-module cycle, layout
at the floor (cols + mid-doc breaks + evenOdd), no w15 threading, no
deadline. Ordered comments → layout → bookmarks+xrefs → notes per §5.
- Phase 0 —
core/parts.pyfoundation. Addedget_or_create_part(doc, spec)plus threePartSpecconstants (COMMENTS_SPEC,FOOTNOTES_SPEC,ENDNOTES_SPEC). Registered internal_FootnotesPart/_EndnotesPartXmlPartsubclasses withPartFactory.part_type_forso existing footnote / endnote parts round-trip with parsed XML rather than raw blobs. python-docx already registersCommentsPartitself, so the helper transparently picks up the correct class for each content type. - Refactored
core/ids.py. Extracted_IdRegistryBaseso per- namespace registries (CommentIdRegistry,BookmarkIdRegistry,FootnoteIdRegistry,EndnoteIdRegistry) sharenext/reserve/issuedwithout duplicating mechanics.IdRegistry(SDT) unchanged behaviour. - Hoisted to core.
_build_complex_field→build_complex_field(used by cross-references);_insert_before_first_anchor→insert_before_first_anchor(used by layout's evenOddHeaders flag). Both moves remove cross-capability imports — SPEC §9.1 stays clean. - Phase 1 —
comments/.add_commentwrites all four OOXML elements python-docx skips:w:commentRangeStart/w:commentRangeEnd/CommentReferencemarker run / the<w:comment>body incomments.xml. Targets accepted:Run,Paragraph(≥1 run required),(Run, Run)tuple.read_commentspairs each part-side body with its body-side range and extracts the anchored text.delete_commentis idempotent and removes all four pieces. - Phase 2 —
layout/.set_columns(<w:cols>, equal/unequal widths, separator),insert_section_break(clones the trailingsectPr, setsw:type, calls python-docx'sCT_P.set_sectPr),enable_distinct_even_odd_headers/disable_…(toggles<w:evenAndOddHeaders/>insettings.xmlvia the schema-strict anchor pattern). - Phase 3 —
bookmarks/.add_bookmark/delete_bookmark/read_bookmarksplusadd_cross_reference(kind="text"|"page", hyperlink=True). Cross-refs reusecore.build_complex_fieldwithREF/PAGEREFinstructions and the\hflag for hyperlink behavior. Bookmark names validated against Word's rules ([A-Za-z_][A-Za-z0-9_]{0,39}). - Phase 4 —
notes/.add_footnote/add_endnote/read_footnotes/read_endnotes. Two separate parts; ids -1 / 0 (separator, continuationSeparator) are unissuable.read_*filters separator entries out of results. - Examples. Four new runnable scripts in
docx_plus/examples/:add_comments,multi_column_layout,bookmarks_and_xrefs,footnotes_and_endnotes. Wired intotest_examples_smoke.py(smoke + reopen-validity). - Release polish. Version bumped 0.1.0 → 0.2.0 in
pyproject.toml,docx_plus/__init__.py, andtests/test_smoke.py. README capabilities list + per-module usage blocks. CHANGELOG 0.2.0 section under Unreleased. - Gates green. 431 tests pass (was 348 + 83 new); 92.64% coverage;
mypy --strictclean on 46 source files;ruff checkclean.
Deferred to v0.3+: w15 threaded comments; layout line numbering and
page borders; in-place comment / note editing; cross-references to
headings / numbered items / captions (STYLEREF, sequence fields);
rest of SPEC §15 unchanged.
- Pre-flight scope check. Reviewed
notes-v0_1-scope.md(an untracked discussion artifact about pullinglayout/,comments/,bookmarks/,notes/forward from v0.2). Decision: ship 0.1.0 as specced; the notes file's own recommendation. Phase 6 stayed narrowly scoped. - Survey finding. README +
docs/ARCHITECTURE.md+docs/API.md+mkdocs.ymlalready covered the Phase 4+5 surface (the Phase 3.6 doc commit reached forward). The progress-log "Next session" callout understated how much was already done — task #9 (docs update) and task #10 (API.md re-index) reduced to small touch-ups. - Four runnable examples under
docx_plus/examples/(in-package per ARCHITECTURE.md §1; pyproject.toml already wires ruff/coverage exclusions):inspect_document.py— prints effective formatting + provenance for every paragraph in SPEC §11 output shape. No-arg form builds a small in-memory demo (Title / Heading1 / body) viaapply_style.restyle_existing.py— modifiesHeading1(color C00000, size 20, bold, new spacing) and verifies viaresolve_effective_formattingthat the first Heading1 paragraph picks up the change.build_form.py— onboarding form with all five control types (text / multiline text / dropdown with (display,value) tuples / combobox / date picker / checkbox), 7 controls,protect_document(mode="forms").populate_form.py— builds the form viabuild_onboarding_form, fills viaset_control_value, clears one field withclear_control, re-reads to show the mixed filled/placeholder state. The three docx-writing examples write toPath.cwd()so the smoke test'ssubprocess.run(cwd=tmp_path)keeps the source tree clean.
tests/test_examples_smoke.py(7 tests): every example exits 0 with no args; each docx-writing example produces a file python-docx can reopen with non-empty paragraphs.tests/test_examples_libreoffice.py(3 tests, gated): converts each example's output to PDF viasoffice --headless --convert-to pdf, asserts exit-0 and non-empty PDF. Bothpytest.mark.requires_libreoffice(declared inpyproject.toml) and ashutil.which-drivenskipifare applied viapytestmarkso the suite is a no-op on dev boxes lacking soffice..github/workflows/ci.ymlinstalls libreoffice on the ubuntu/3.13 leg only — the four other matrix legs autoskip.- Coverage gate flipped on (TEST_GAPS B1).
fail_under = 90added to[tool.coverage.report]+--cov-fail-under=90inci.yml. Closing the previous 77% onstyles/inspect.pyrequired two new test files before the gate could pass:tests/test_cascade_numbering.py(7 tests) — exercises cascade layer 4 end-to-end via a newbuild_numberedfixture that injects a customabstractNum(indent + bold atlvl[0]) +num(id=100) into python-docx's already-shipped numbering part. Provenance, the happy path, two unparsable-attribute branches (numId garbage / ilvl garbage), the unknown-numId branch, missing-w:val branch, and the no-numPr negative-case.tests/test_cascade_run_target.py(5 tests) — exercises Run-target branches (direct rPr, paragraph-style inheritance,w:rStylelinked-char chain) and_Celltargets (_apply_cell_cascade+ table-style walk).- Plus
if TYPE_CHECKING:and@overloadadded toexclude_linesinpyproject.toml— standard exclusions previously missing. Final numbers: 91.76% aggregate, styles 90.7%, core 91%, controls 93%.
- API.md / mkdocs strict.
docs/API.md"Phase 6 stub" paragraph rewritten to point at the four examples; the../docx_plus/exampleslink replaced with prose because mkdocs --strict treats outside-docs/ links as broken.mkdocs.yml'sstrict: trueis now enabled (was commented out with a "Phase 6" todo);mkdocs build --strictis clean. Thedocs.ymlworkflow already used--strictper c970733. - README.md. Status banner switched from "early development (v0.1 in progress)" → "v0.1 complete"; capability list "in progress — see roadmap below" → present-tense; roadmap row 6 marked complete.
- SPEC §13 type-ignore audit. The six existing
# type: ignoreannotations instyles/inspect.py(4) andstyles/modify.py(2) had mypy error categories but no human-readable justification; added one-line explanations per the SPEC §13 last bullet ("No # type: ignore without an accompanying comment explaining why"). - TEST_GAPS.md. B1 entry moved to the
## Resolvedsection with date and resolution notes citing the two new test files and the fixture. BLOCKER section now empty. - Wheel + sdist build green.
uv buildproducesdist/docx_plus-0.1.0.tar.gzanddocx_plus-0.1.0-py3-none-any.whl;import docx_plusanddocx_plus.__version__ == "0.1.0"both confirmed via a fresh subprocess. - SPEC §13 final sweep — all 9 bullets green:
- ✓ All tests pass — 304 passed, 3 skipped (LibreOffice; CI ubuntu/3.13 leg installs soffice)
- ✓
mypy --strict docx_plus/— 25 source files, no issues - ✓
ruff check docx_plus/(+ tests/) — all checks passed - ✓ Coverage ≥ 90% — 91.76% aggregate, all three packages ≥90%
- ✓ All four examples run without error
- ✓ Layer 3 smoke wired (autoskip when soffice absent)
- ✓ ARCHITECTURE.md, API.md, README.md exist and are current
- ✓ Import-invariant test passes — 12 cases
- ✓ No
# type: ignorelacks justification
v0.1 is feature-complete. Outstanding before tagging 0.1.0: (a) push
two commits to origin/main (currently 2 ahead per git status), (b)
answer the two questions in notes.md (build/fixtures setup + the v0.2
comments/refs/endnotes interface discussion — the latter is what
notes-v0_1-scope.md already explores). v0.2 backlog is enumerated in
SPEC §15.
Next session — tag 0.1.0 (post-resolution of notes.md questions) or
start v0.2 work per the recommended order in notes-v0_1-scope.md §5:
comments/ → layout/ → bookmarks/ + cross-refs → notes/.
- Lint debt cleared (from Phase 3.6 / Phase 4 carry-over):
uv run ruff check tests/ --fixresolved 9 of 10 issues (I001 import sorts × 5, F401 unused imports × 2, quoted-annotation cleanups × 2). The remainingB017intests/test_styles_inspect.py:310was hand-narrowed frompytest.raises(Exception)topytest.raises(dataclasses.FrozenInstanceError). Six test files reformatted viaruff format.tests/now passes bothruff checkandruff format --check. core/ns.pyxml namespace: addedXML = "http://www.w3.org/XML/1998/ namespace"constant and"xml"key inNSMAP, soqn("xml:space")works. Needed byw:instrText(and the field resultw:t) to keep Word from collapsing the surrounding whitespace on the field instruction. Addedtest_qn_xml_namespacetotest_core_ns.pyand updated thetest_nsmap_keysset expectation.fields/simple.py— three public functions all routing through a single private_build_complex_field(p_element, instruction, initial_text)helper that emits the canonical 5-run sequence (begin / instrText / separate / result-text / end). Eachw:instrTextand the resultw:tgetxml:space="preserve".add_page_number_field(paragraph, *, field="PAGE", format=None)— builds" PAGE "(or" {field} {format} "); seeds initial result"1"so offline viewers see something before Word recalculates. AcceptsPageFieldName = Literal["PAGE", "NUMPAGES", "SECTIONPAGES"].add_date_field(paragraph, *, format="MMMM d, yyyy", auto_update=True)— emitsDATE \@ "..."whenauto_update=True,CREATEDATE \@ "..."otherwise. Initial text empty (Word fills on open).add_field(paragraph, *, instruction, initial_text="")— generic passthrough; strips and re-wraps the instruction in single spaces so callers can pass it either way.- All three return the begin
<w:r>element so callers can navigate or relocate the field. Usesparagraph._p(python-docx-private — called out in §7 as an acceptable contained internal-API touch).
fields/update.py— single functionmark_fields_dirty(doc)matching the §4 reference (idempotent, updates existingval="false"→"true"). Uses anchor-before-insertion against a 12-entry tuple of CT_Settings children that come afterw:updateFields(w:hdrShapeDefaults,w:footnotePr,w:endnotePr,w:compat,w:docVars,w:rsids,w:mathPr,w:themeFontLang,w:clrSchemeMapping,w:shapeDefaults,w:decimalSymbol,w:listSeparator). python-docx's stocksettings.xmlincludesw:compatandw:rsids, so blank documents hit the anchor path (verified in test).protection/document.py— three public functions per SPEC §8:protect_document(doc, *, mode="forms")accepts the fourProtectionModeliterals (forms/readOnly/comments/trackedChanges). Emits<w:documentProtection w:edit=MODE w:enforcement="1"/>. Idempotent: a second call replaces the mode rather than stacking.unprotect_document(doc)removes the element, no-op when absent.is_protected(doc)returns presence-only (does not introspect mode).- Anchor-before-insertion against an 11-entry tuple of CT_Settings
children that come after
w:documentProtection(w:autoFormatOverride,w:styleLockTheme,w:styleLockQFSet,w:defaultTabStop,w:autoHyphenation, etc.). python-docx's stock settings hasw:defaultTabStop, so blank documents hit the anchor path — schema-correctness is verified bytest_protect_document_precedes_defaultTabStop. - Both fields/update.py and protection/document.py duplicate the 4-line
_insert_before_first_anchorhelper privately rather than share viacore/. Premature abstraction risk over DRY purity; SPEC §9.1 also forbids capability-to-capability imports.
- No new error classes for Phase 5. Both modules' inputs are Literal-typed; mypy catches misuse statically. Runtime misuse produces a structurally-valid file with a semantically-wrong field/edit attribute that Word will surface in its UI — consistent with SPEC's "no silent fallbacks" but without piling on validation that duplicates the type system.
_testing/ooxml_asserts.pygained the two helpers SPEC §10 promises:assert_protected(doc, mode=None)— verifies presence +w:enforcement="1"; optionally validatesw:edit.assert_field_dirty(doc)— verifiesw:updateFields val="true".
docx_plus/fields/__init__.pyanddocx_plus/protection/__init__.pyre-export the public surface (5 symbols + 4 symbols respectively). Top-leveldocx_plus/__init__.pyunchanged — consistent with Phase 4 pattern of consuming viafrom docx_plus.<module> import ....- Tests:
test_fields.py(24 tests) +test_protection.py(18 tests). Coverage: every field type's structure assertion (instruction text, run count, initial result, xml:space presence), generic-field whitespace normalisation, two-fields-in-one-paragraph composition, save→reopen round-trips per field type. For protection: every mode (parametrised over the four Literal values), schema-position invariant, idempotency, mode-replacement, unprotect-after-protect, round-trip, plus negative tests on the assertion helper. Thetest_import_invariant.pyparametrised test picked upfields/andprotection/automatically (12 cases now, up from 10). - Round-trip smoke (transient, not committed): a script that does
protect + 2 PAGE fields + mark_fields_dirty + save + reopen confirms the
produced ~36KB
.docxsurvives the full chain;is_protected()returns True,assert_protected(mode="forms")passes,assert_field_dirtypasses, andparagraphs[0].textreads as"Page 1 of 1"(the seeded initial results). No LibreOffice on this dev box so the soffice headless smoke is deferred to Phase 6's CI plumbing. - Quality gates green: pytest 285/285 (was 239, +46: 24 fields + 18
protection + 1 ns + 3 import-invariant parametrisations), mypy --strict
(21 source files, +3), ruff check + ruff format --check on both
docx_plus/andtests/(the test-file lint debt is now gone — tests/ is part of the gate too).
Phase 5 exit criteria status: SPEC §7 + §8 contracts implemented;
mark_fields_dirty idempotent and schema-correct; protect_document
schema-correct (precedes w:defaultTabStop); the assertion helpers from
SPEC §10 are in place. The "page numbers show correct values after Word
opens" criterion from IMPLEMENTATION.md §10 is only partially verifiable
in CI — the structural + python-docx round-trip is the test-suite
proxy; full verification needs Microsoft Word (or LibreOffice headless,
which Phase 6 will wire up).
Next session — Phase 6: Polish. Per IMPLEMENTATION.md §2 / SPEC §11 + §13:
write the four examples (inspect_document.py, restyle_existing.py,
build_form.py, populate_form.py); add Layer 3 LibreOffice-headless
smoke tests gated behind pytest.mark.requires_libreoffice; final
quality-gate sweep against SPEC §13 (the 90% coverage threshold from
tool.coverage.report needs to be enforced via --cov-fail-under in
CI — flagged in docs/TEST_GAPS.md B1); update README.md /
ARCHITECTURE.md to cover the Phase 4+5 surface (forms / fields /
protection sections); API.md index gets four new module entries.
Budget 1-2 days.
- User produced
tests/fixtures/word_samples/sample-2.docxcovering the 24 remaining entries (Header/Footer, BodyText family, MacroText, BalloonText, IndexHeading, TOC3–9). All 22 of the actively-sourced targets now have authoritative Word values. - Confirmations (no change needed): TOC3–9 indent progression
480/720/960/1200/1440/1680/1920 exactly matches the previous
240-twip-step refinement. BodyText / BodyText2 (line=480 = 2.0x) /
BodyText3 (sz=16 = 8pt) all already correct. IndexHeading already
carries
bold: True(Word adds majorHAnsi theme fonts on top, but those aren't writer-supported and are documented as a limitation). - Refinements:
- Header / Footer: added
line_spacing: 1.0so the header/footer paragraph overrides Normal's 1.08 line spacing back to single, as Word does. Tabs (center@4680, right@9360) intentionally omitted — no tab-stop support in_write_paragraph_property. - MacroText / MacroTextChar: font
Courier→Consolas(Word's actual choice for monospace styles); addedspacing_after: 0on MacroText. 9-tab progression at every 480 twips omitted (same limitation as Header/Footer). - BalloonText / BalloonTextChar: font
Tahoma→Segoe UI(Word 365's current default for comment balloons); size 8.0 → 9.0; addedline_spacing: 1.0on BalloonText.
- Header / Footer: added
- Tier G header comment updated to cite the sample as authoritative.
- Gates: pytest 239/239, mypy --strict clean, ruff check + format clean.
- Extracted authoritative style XML from a Word-saved sample
(
tests/fixtures/word_samples/sample-1.docx) the user produced after applying each latent style to a paragraph and saving in Word 365. The sample materialised 60 styles, of which 23 of our 47 target latent entries. - Refined
_BUILTIN_STYLESindocx_plus/styles/modify.py:- TOC2..9 indent progression: was 220-twip step (220/440/660/880/ 1100/1320/1540/1760); now 240-twip (240/480/720/960/1200/1440/1680/ 1920) to match Word's stock pattern.
- TOCHeading: added
spacing_before: 240to match Word's stock spacing (keptoutline_level: 9andq_format: True). - TOAHeading: removed
font_size: 12.0— Word inherits size via the majorHAnsi theme font, so a literal override is wrong. Keptboldandspacing_before: 120. - Index1: indent 200/-200 → 240/-240 (hanging indent in twips).
- CommentText: dropped
spacing_after: 0(Word doesn't set it), addedline_spacing: 1.0andfont_size: 10.0. - CommentTextChar / CommentSubjectChar: added
font_size: 10.0. - BlockText: indent 1440/1440 → 1152/1152, dropped
spacing_after: 120, addeditalic: Trueandcolor_rgb: "156082"(Word's accent1-tracked grey-blue). Border (pBdr) intentionally omitted — our property writer doesn't model paragraph borders.
- Tier E and Tier F header comments updated to cite the Word-sample as the source of authority (replacing the prior "ECMA-376 pattern, refine later" notes).
- Bug fix flushed out by the sample:
TableofFiguresandTableofAuthoritieswere keyed underTableOfFigures/TableOfAuthorities(capitalO) in_BUILTIN_STYLES. Word writes the styleId with a lowercaseo. Renamed the keys and refined defaults from the sample (spacing_after: 0on both;indent_left: 240, indent_first_line: -240on TableofAuthorities).IntenseQuote/IntenseQuoteCharalso refined: removedbold(Word doesn't set it), color2F5496→0F4761(Word's accent1-shaded), addedspacing_before/after: 360,indent_left/right: 864,alignment: "center".Captioncolor44546A→0E2841(Word's text2 theme color), addedline_spacing: 1.0. - Out of scope for this pass: Header/Footer, Body Text 1-3 family, Macro Text, Balloon Text, Index Heading, TOC3-9 spot checks (the 240-step is the source-of-truth) — these didn't materialise in the sample (Word only materialises a latent style after it's applied to content). Current entries stay as ECMA-376-pattern defaults.
- Gates: pytest 239/239 green, mypy --strict clean, ruff check + format clean. Visual smoke check on materialised XML confirms every refined entry round-trips correctly via python-docx.
- Note on
ensure_stylesemantics: for styles already present in python-docx's bundleddefault.docx(TOCHeading, ListBullet, etc.),ensure_styleis a no-op — the template wins. The refined_BUILTIN_STYLESentries take effect for stripped templates, third-party docs missing the style, andremap_styles(create_missing= True)paths. This is the intentional idempotency contract.
_BUILTIN_STYLESindocx_plus/styles/modify.pygrew from 22 → 107 entries (+85). Coverage is now well past SPEC §5's "at minimum" set, into the territory of "every style a real Word user is likely to reach for" per the Exhaustive scope choice.- Tier A — structural essentials (6):
NoSpacing,Header,HeaderChar,Footer,FooterChar,TableGrid. - Tier B — inline emphasis (character) (7):
Strong,Emphasis,IntenseEmphasis,SubtleEmphasis,IntenseReference,SubtleReference,BookTitle. - Tier C — linked Char counterparts of paragraph styles (13):
Heading1Char–Heading9Char,TitleChar,SubtitleChar,QuoteChar,IntenseQuoteChar. These are the targets oflinked_styleon the paragraph-style entries; without them the link refs would dangle on docs that need both materialised. - Tier D — list paragraph variants (18):
List/List2/List3,ListBullet/2/3/4/5,ListNumber/2/3/4/5,ListContinue/2/3/4/5. The WordnumPrplaceholder child is intentionally omitted — the table seeds shape (basedOn=Normal + indent), and callers wanting actual auto-numbering should attach a numbering definition separately. - Tier E — TOC / index / table-of- navigation* (16):
TOCHeading,TOC1–TOC9,IndexHeading,Index1,TableofFigures,TableofAuthorities,TOAHeading.TOC1–TOC9use Word's stock 240-twip per-level indent progression. - Tier F — footnotes / endnotes / comments / balloons (12):
FootnoteText/Char,FootnoteReference,EndnoteText/Char,EndnoteReference,CommentText/Char,CommentReference,CommentSubject/Char,BalloonText/Char. Word uses the legacyannotation*names (annotation text,annotation reference,annotation subject) — we follow that convention forw:namewhile exposing the modernComment*style ids users actually type. - Tier G — misc text-block (13):
BodyText/Char,BodyText2/Char,BodyText3/Char,MacroText/Char,HTMLPreformatted/Char,PlainText/Char,NormalIndent,BlockText. - Defaults sourcing — important per IMPLEMENTATION.md §7 "Latent
built-ins": ~40 of the 85 new entries (Header/Footer/NoSpacing/Strong/
Emphasis/list variants/BodyText/MacroText/heading & title Char styles/
TOCHeading/TableGrid) had structural fields extracted directly from
python-docx's bundled
default.docxtemplate — these are Word-authoritative. The remaining ~45 (TOC1–9, footnote/endnote/comment/ balloon family, index, table-of-*, HTMLPreformatted, PlainText, NormalIndent, BlockText) are truly latent (in the latentStyles block but never materialised) and use Word's documented defaults from ECMA-376 conventions. Action item: refine these against a Word-saved sample doc when one is available — the user offered to produce one in MS Word. - Theme-color attributes (
themeColor,themeShade,asciiTheme) and presence-only flags (semiHidden,unhideWhenUsed) are intentionally not emitted — they require new property kinds in_BUILTIN_STYLES's schema. Result: visible formatting is correct but theme-tracked colors resolve to plain RGB. Acceptable for v0.1; can extend if needed. - Quality gates green: pytest 239/239 (the existing
test_ensure_style_all_known_builtins_succeediterates every entry in_BUILTIN_STYLESso all 85 new entries are exercised), mypy --strict (18 source files), ruff check, ruff format --check ondocx_plus/. - Smoke check (transient script): 32 sampled new entries
ensure_style'd, saved via python-docx, reopened, and present in the resultingstyles.xml. No regressions in the 239-test suite.
Next: when a Word-saved sample doc is provided, re-extract authoritative defaults for the ~45 truly-latent entries to replace the ECMA-376-pattern guesses. Then proceed to Phase 5 (Fields + protection).
controls/builder.pyaddsFormBuilderwithadd_text_control,add_dropdown(incl. combobox viaeditable=True),add_date_picker,add_checkbox, plussave(path). Signatures match SPEC §6.1 verbatim (keyword-only after the paragraph). The class self-injects the latentPlaceholderTextcharacter style on construction and verifies the document root declares thew14namespace (raisesMissingNamespaceError(DocxPlusError)if not). All XML built viacore/oxml.el/sub; allw:idvalues issued bycore/ids.IdRegistry(constructor accepts an optional registry to share between builders). sdtPr child order matches the docx-forms skill prototype:[alias?], tag, id, [showingPlcHdr?], <type-marker>— Word's CT_SdtPr schema-correct sequence.controls/read.pyaddsControlValue(frozen dataclass),read_controls(withby="tag" | "alias"),set_control_value,clear_control, plus four typed errors:ControlNotFoundError(DocxPlusError, KeyError),DuplicateTagError(DocxPlusError, ValueError),ValueNotInListError(DocxPlusError, ValueError),ControlTypeError(DocxPlusError, TypeError). Type detection dispatches on the marker child of sdtPr; rich-text SDTs (no marker) are silently skipped per SPEC §6 v0.1 scope. Dropdown matching triesw:valuethenw:displayText; combobox accepts freeform fallback. The auto-prepended empty-value placeholder list-item is filtered out during matching so it cannot shadow real entries. Date values round-trip throughw:date/@w:fullDate(ISO 8601); the rendered text in sdtContent uses a best-effort human form (full Word date-format-token translation is out of scope for v0.1 — the canonical machine value is@w:fullDate).controls/__init__.pyre-exports the public surface (12 symbols)._testing/ooxml_asserts.pygainscount_controls(doc, control_type=None)— uses the same_classify_sdthelper asread_controlsso there's one source of dispatch truth.tests/fixtures/build_fixtures.pygainsbuild_existing_form(path)which constructs three SDTs (filled text, placeholder dropdown, checked checkbox) by hand without FormBuilder so read-side tests verify schema tolerance. Wired intobuild_allandconftest.py'sexisting_form_docx_pathfixture.tests/test_controls_builder.py(24 tests) andtests/test_controls_read.py(26 tests) cover: every control-type round-trip via save→reopen; multiline text; dropdown items as strings vs(display, value)tuples; combobox vs dropdown emission; checkbox glyph/state sync; multi-control IdRegistry continuity; existing-id seeding from third-party docs; sdtPr child order assertions per control type; placeholder-style materialisation idempotency;read_controlsplaceholder-state,by="alias"filtering, duplicate-tag detection; set/clear round-trips per type; dropdown by-value vs by-display matching; combobox freeform passthrough;ControlTypeErrorfor type mismatches;ControlNotFoundErrorfor unknown tags; read on the externally-built fixture; rich-text SDT (no marker) silently skipped.- Quality gates green: pytest 239/239 (was 187, +52), mypy --strict
(18 source files, +2), ruff check, ruff format --check (all on
docx_plus/).tests/test_import_invariant.pycontinues to pass —controls/imports only fromdocx_plus.core(thePlaceholderTextstyle def is duplicated inline inbuilder.pyrather than imported fromstyles.modify, honouring SPEC §9.1). - Pre-existing lint debt in test files unchanged (Phase 3.6 flag):
ruff still reports issues on
tests/test_core_ns.py,tests/test_styles_inspect.py,tests/test_styles_modify.py,tests/test_styles_theme.py,tests/test_theme_edge_cases.py. Out of scope for Phase 4. The ruff config gatesdocx_plus/only, so the CI workflow still passes — but a small "lint-tests cleanup" pass is worth doing before Phase 5. - Reference artifacts:
docx-skill-files.zip(the source skill prototype) and.skill-ref/(extracted contents) are now in.gitignore. TheFormBuilderdesign closely mirrors the prototype'sdocx_forms.py, with three changes: (1) ID issuance routed throughIdRegistryinstead of an ad-hoc set, (2) all element construction throughcore/oxml, (3)protect()deliberately omitted — protection belongs to Phase 5/6protection/document.pyper SPEC §8. core/parts.pyre-confirmed unnecessary for v0.1 controls (which are fully inline in the document; no Custom XML Part bindings until v0.2 repeating sections). The Phase 1 plan note is now stale; will revisit if Phase 5 fields needs it.
Phase 4 exit criteria status: SPEC §6 contract implemented, all five
control types round-trip, read_controls discovers all of them with
correct types, set_control_value/clear_control round-trip per type,
externally-built docs read correctly, all four quality gates pass.
Next session — Phase 5: Fields and protection. Per IMPLEMENTATION.md
§2 / SPEC §7 + §8: implement fields/simple.py
(add_page_number_field, add_date_field, add_field) and
fields/update.py (mark_fields_dirty); then protection/document.py
(protect_form, the form-protection enforcement that locks everything
except content controls — this is what turns a doc-with-widgets into an
actual form). The schema-order check from SPEC §6 line 583 ("Form-protection
enforcement is correct") gets validated end-to-end here. Budget 1 day.
Resolve the test-file lint debt at the start of the session if quick.
README.mdrewritten from a 9-line stub into a real quickstart for what Phase 1–3.5 actually ships: motivation, install, three runnable snippets (inspect / modify / ensure-built-in), and a phase status table. Forms, fields, protection listed as not-started rather than fabricated.docs/ARCHITECTURE.mdauthored — present-tense reference covering layout, the six-layer cascade walkthrough with line citations intoinspect.py, schema-strict insertion patterns (_STYLE_CHILD_ORDER/_PPR_CHILD_ORDER/_RPR_CHILD_ORDERand_ordered_insert), the Phase 3.5 four-step style remap, the_BUILTIN_STYLEStable contents and the "Word-2013-defaults vs python-docx-ships-Word-2007" gotcha, the §9 invariants, the typed-error hierarchy table, and the SPEC §10 three-layer test strategy.docs/API.mdauthored — hand-curated index of every public symbol grouped by module, with cross-links intoARCHITECTURE.md. Points at the MkDocs-generated full reference underdocs/reference/.docs/TEST_GAPS.mdauthored — the audit deliverable. Severity-tiered (BLOCKER / IMPORTANT / NICE-TO-HAVE) with file:line citations to every test that does or doesn't cover the claim. Recommended fix order at the bottom. Resolved-entries section ready for items to move down as gaps close. Top blocker: SPEC §13's 90% coverage gate is defined in[tool.coverage.*]but nofail_underis set and CI doesn't pass--cov-fail-under. Top importants: save→reopen round-trips missing for 3 of 5 modify operations; schema-order assertion only oncreate_style, not onmodify_style's merge path; cycle detection only tested with 2-node cycles;delete_style(force=True)only smoke-checked.mkdocs.ymlat repo root configures a Material-theme MkDocs site with mkdocstrings (Python handler, Google docstyle, source links). Nav covers Home / Architecture / API Index / Reference (six per-module pages underdocs/reference/) / Test Gaps.pyproject.tomlswapspdocformkdocs+mkdocs-material+mkdocstrings[python]in both[project.optional-dependencies] devand[tool.uv] dev-dependencies..gitignorealready excludedsite/so no change needed there.docs/index.mdis a slimmed mirror of the README, tailored for the MkDocs landing page (the README still serves GitHub).docs/reference/— six pages, one per public-surface module (core-ns,core-oxml,core-ids,styles-inspect,styles-modify,styles-theme), each a thin context paragraph plus::: moduledirective for mkdocstrings to auto-render.- No source code touched. No tests added (audit deliverable is the report,
per the approved plan). Tests still 187/187 green;
mypy --strict(16 files) still green. - Lint regression surfaced (pre-existing, not introduced here): on
uv sync --extra devruff updated to 0.15.13 and now flags 7 issues in test files that prior phase gates claimed were green —I001× 4 (import sort) intests/test_core_ns.py,tests/test_styles_inspect.py,tests/test_styles_modify.py,tests/test_theme_edge_cases.py;F401× 2 (unused imports) intests/test_styles_inspect.pyandtests/test_styles_theme.py;B017× 1 (pytest.raises(Exception)) intests/test_styles_inspect.py:310. Plusruff formatwants to reformat 4 test files. All trivially auto-fixable (uv run ruff check --fix && uv run ruff format) but out of scope for this documentation-only block. Flag for the start of the next session.
Phase 3.6 exit criteria status: README/ARCHITECTURE/API/TEST_GAPS in
place; MkDocs configuration committed; pdoc removed from deps. Phase 6's
"API.md, ARCHITECTURE.md, README.md exist and are current" gate
from SPEC §13 is met for the Phase 1–3.5 surface; those docs will need
extension as Phases 4–5 land but the structure is fixed and the
mkdocstrings handler picks up new symbols automatically.
Next session — Phase 4: Forms (unchanged from the prior entry). Per
IMPLEMENTATION.md §2 / SPEC §6: port FormBuilder from the docx-forms
skill into controls/builder.py, adapting it to use IdRegistry from
core/. Then write controls/read.py (ControlValue, read_controls,
set_control_value, clear_control). Round-trip tests per control type.
Budget 1–2 days. Worth resolving TEST_GAPS.md B1 (coverage threshold)
and I1 (save→reopen for modify/apply/delete) alongside or just before,
so Phase 4 lands on a tighter test floor.
find_matching_style(doc, target_id) -> str | None— case/space-insensitive lookup against bothw:styleIdandw:nameof every defined style. Returns the trivial match when the id is already defined exactly. Solves the "doc was authored with 'Heading 1' (with space) but my code references 'Heading1'" problem.remap_styles(doc, *, targets=None, mapping=None, create_missing=False)— bulk reconciliation. For each target id walks four steps: exact match → explicit mapping → matcher → optional create-from-built-ins. Rewrites body references (w:pStyle,w:rStyle,w:tblStyle) so subsequentapply_styleworks without translation. Style-to-style refs insidestyles.xml(basedOn,next,link) are intentionally left alone — keeps the remap a non-destructive rewrite. Whencreate_missing=Truefalls through, the new style is materialised via_materialise_builtinso it inherits the doc's customised Normal automatically throughbasedOn(no special inheritance plumbing needed).ensure_stylegains amatch_existing=Falseflag. When True it consultsfind_matching_stylebefore falling back to the built-ins table / custom-create path. The returned proxy may have astyle_idthat differs from the requested one — callers usingapply_styleshould passproxy.style_id(or useremap_stylesfor document-wide normalisation).- 18 new tests (187 total): matcher coverage on id and name with case/space
normalisation;
remap_stylesfor the four resolution steps, body-ref rewriting, default-targets behaviour, unresolved-omission, and the "create_missing inherits from customised Normal" round-trip via the cascade;ensure_stylewithmatch_existingTrue/False. - Quality gates green:
pytest187/187,mypy --strict(16 files),ruff check,ruff format --check.
styles/modify.py— full Phase 3 surface:create_style,modify_style,apply_style,delete_style,ensure_style,list_styles, plusStyleProxy,StyleInfo, and four typed errors (StyleExistsError,StyleNotFoundError,StyleInUseError,UnknownStylePropertyError(DocxPlusError, TypeError)so the SPEC §5 contract that unknown kwargs raiseTypeErrorsurvives the typed-error invariant from §9.7).- Property kwargs accepted by
create_style/modify_styleuse the same field names asResolvedFormatting, so cascade output round-trips back through the modifier without translation. The supported set covers every SPEC §4 field documented as writeable:font_name,font_size,bold,italic,underline,strike,color_rgb,highlight,caps,small_caps,vanish,vert_align,alignment,indent_*,spacing_*,line_spacing*,keep_*,page_break_before,outline_level. Composite elements (w:ind,w:spacing,w:rFonts) use merge semantics — multiple kwargs targeting the same XML element combine rather than overwrite. - Schema-strict child orderings for
CT_Style,CT_PPr,CT_RPrare enforced by_ordered_insert; teststest_*_children_ordered_correctlyverify the schema order survives create/modify operations. This avoids the silent-Word-repair failure mode called out in IMPLEMENTATION.md §4. - Toggle properties (
bold,italic,caps,small_caps,strike,vanish) follow SPEC §5 semantics:Truewrites presence,Falsewritesw:val="false",Noneremoves the element so XOR with the parent style resumes. Inspect.py's reader was extended in this phase to honourw:val="false"onkeepNext/keepLines/pageBreakBeforeso the write→read round-trip is symmetric for those flags. ensure_styleis idempotent and aware of latent built-ins. The known-built-ins table (_BUILTIN_STYLES) covers every id SPEC §5 requires "at minimum":Normal,Heading1–Heading9,Title,Subtitle,Quote,IntenseQuote,ListParagraph,Caption,Hyperlink,PlaceholderText,DefaultParagraphFont,TableNormal,NoList. Built-ins materialise withoutw:customStyle="1"(they are not user-defined) andNormal/DefaultParagraphFont/TableNormal/NoListcarryw:default="1". Word-2013-era defaults were used for font sizes/colors (e.g. Heading1 = 16pt, color #2F5496); python-docx ships its own Word-2007 versions of most of these (Heading1 = 14pt, color #365F91), soensure_stylereturns the existing definition unchanged on a freshDocument()— it only consults the built-ins table when the id is genuinely missing.apply_styleacceptsParagraph | Run | _Cell. Cell support iterates the contained paragraphs and writesw:pStyleto each (matches the "apply style to selection" semantics from Word's UI; OOXML has no per-cell style reference). Type validation runs before any attribute access so non-targets raiseTypeErrorcleanly.delete_stylescans the body (w:pStyle,w:rStyle,w:tblStyle) and the styles part (w:basedOn,w:next,w:link,w:numStyleLink,w:styleLink) for inbound references. Refs from the style being deleted to itself are excluded so a self-referential basedOn doesn't block its own deletion. Headers/footers are not scanned in v0.1 — documented limitation; revisit when a caller exercises it.- 63 new tests in
tests/test_styles_modify.py(169 total). Coverage spans: every public function's happy path; every supported property via cascade round-trip; schema-order assertions for style/pPr/rPr children; toggle True/False/None semantics; modify-preserves-others; modify-clears-with-None; if_missing="create" fall-through; apply_style to paragraph/run/cell with replace-existing semantics; delete_style reference detection (paragraph + basedOn) and force=True override; ensure_style idempotency, latent materialisation when absent, and "returns existing unchanged" when python-docx already shipped it; list_styles type filtering and include_latent; StyleProxy modify+delete delegation; full save→reopen round-trip via python-docx for both a custom style andHeading1. - Quality gates green locally:
pytest169/169,mypy --strict(16 files),ruff check,ruff format --check.
Phase 3 exit criteria status: create-then-resolve works for every
documented property (cascade round-trip is the test oracle); modify
preserves untouched properties; ensure_style is idempotent and
materialises latent built-ins from the table; apply_style round-trips
through python-docx serialisation. The examples/restyle_existing.py
exit criterion from IMPLEMENTATION.md §10 is deferred to Phase 6 along
with the rest of the examples directory.
Next session — Phase 4: Forms. Per IMPLEMENTATION.md §2 / SPEC §6:
port FormBuilder from the docx-forms skill into controls/builder.py,
adapting it to use IdRegistry from core/. Then write controls/read.py
(ControlValue, read_controls, set_control_value, clear_control).
Round-trip tests for each control type: build → save → re-open → read →
modify → save → re-read. Budget 1–2 days (mostly a port).
styles/theme.py— read-only theme color resolution.load_theme()readsword/theme/theme1.xmlvia the document part'sthemerelationship, parsesa:clrSchemeinto a key → hex map.resolve_theme_color()handles the WordprocessingML name aliases (text1=dk1, background1=lt1, etc. per ECMA-376 17.18.97) plusthemeTintandthemeShademodifiers. Transforms forthemeTint/themeShade/lumMod/lumOffare exported as standalone functions and verified against known input/output pairs intest_styles_theme.py(22 tests).styles/inspect.py— the cascade resolver.resolve_effective_formattingwalks the six SPEC §4 layers in order: docDefaults → tableStyle → paragraph style chain → numbering → direct pPr → direct rPr, plus linked character style forRuntargets._Accumulatorholds in-progress state and provenance side-by-side; the same walk produces both the value output and the optional provenance dict gated oninclude_provenance. Toggle parity follows ECMA-376 17.7.3 —w:val="false"/"0"resets, all other values XOR.- Cycle detection / depth limit (max 11) on the basedOn walk; both raise
StyleCascadeError. Theme failures (missing part, malformed XML, unknown name) are non-fatal:partial=Trueplus the unresolved theme name incolor_rgbso debugging output stays useful (SPEC §4 "Theme references"). - Conditional table formatting (
w:tblStylePrfor firstRow/lastRow/etc.) is deferred — the table style chain's base pPr/rPr is applied but conditional variants are not. Documented inline; revisit before Phase 6 if a real caller exercises it. - Theme font tokens (
majorAscii,minorHAnsi, …) pass through as-is infont_namefor v0.1. Resolving to actual typefaces would needa:fontSchemeparsing intheme.py; not yet a tested requirement. - New fixture:
themed.docx(style withthemeColor="accent1"+themeShade="80") exercises the theme path end-to-end. - Tests added (49 new, 105 total):
test_styles_theme.py(22),test_styles_inspect.py(21),test_cascade_toggles.py(9 — all 5 cases from IMPLEMENTATION.md §5 plus parity variants),test_cascade_provenance.py(10 — including the SPEC §4 invariant that provenance flag does not change values),test_theme_edge_cases.py(9 — strip-rel and corrupt-blob scenarios). - Quality gates green locally:
pytest105/105,mypy --strict(15 files),ruff check,ruff format --check.
Phase 2 exit criteria status: cascade resolver works on every layer in
isolation and in combination; toggle XOR honours ECMA-376 17.7.3; cycle and
depth-limit errors are raised; theme resolution handles the missing /
malformed / unknown-name edge cases. The provenance feature is plumbed
through end-to-end. The "examples/inspect_document.py produces useful
output on three real-world documents" criterion from IMPLEMENTATION.md §10
is deferred to Phase 6 (Polish) along with the rest of the examples
directory — the cascade core is ready for those examples to consume.
Next session — Phase 3: Style modification. Per IMPLEMENTATION.md §2,
the order is: create_style / modify_style / delete_style /
apply_style → ensure_style with the known-built-ins table (the trickiest
piece; extract definitions from a Word-materialised doc, don't guess) →
StyleProxy / list_styles → round-trip tests for every operation. Budget
2–3 days. The Phase 2 cascade gives Phase 3 a free round-trip verifier:
modify a style, resolve a paragraph using it, assert the values match.
uvenvironment on Python 3.13;requires-python = ">=3.10"in pyproject.pyproject.tomlcarries hatchling build, mypy strict, ruff (default +Don package, ignored on tests), pytest, coverage configs.- Repository skeleton matches SPEC §2 flat layout:
docx_plus/package sibling totests/, empty__init__.pyfiles in every subpackage so imports resolve cleanly and the import-invariant test has real targets. docx_plus/core/__init__.pydefinesDocxPlusError(SPEC §9.7 root).core/ns.py— namespace URI constants +qn().core/oxml.py—el,sub,xpath,remove.xpathusesetree.XPath(..., namespaces=NSMAP)so it works on both raw lxml elements and python-docx'sBaseOxmlElementsubclasses (whose ownxpathmethod doesn't accept anamespaces=kwarg — pitfall worth remembering).core/ids.py—IdRegistryscansw:sdt/w:sdtPr/w:idon both body and settings parts;next()usessecrets.randbelowfor unpredictability (not strictly required but cheap insurance);reserve()raisesDuplicateIdError(DocxPlusError, ValueError)so SPEC §3's documentedValueErrorand SPEC §9.7's typed-error invariant both hold.core/parts.py— Phase 1 stub. Fleshed out in Phase 4._testing/ooxml_asserts.pyseeded withassert_ids_uniqueandassert_style_defined(only the asserts that have callers this phase).tests/fixtures/build_fixtures.pyproducesempty.docxandmultistyle.docx(Base → Mid → Top paragraph style chain, with bold-XOR toggles for the Phase 2 cascade tests to assert against).- Tests: smoke, ns (8 cases), oxml (8 cases), ids (10 cases including
registry seeding + non-SDT ID exclusion), import-invariant (parametrized
over every capability dir), integration smoke (round-trip SDT through
core/oxml+core/ids, reopen with python-docx). 32 tests, all green on Python 3.13. - Quality gates green locally:
pytest(32/32),mypy --strict(13 files),ruff check,ruff format --check. .github/workflows/ci.yml— matrix 3.10/3.11/3.12/3.13 with uv, pytest + mypy --strict + ruff check + ruff format --check. Not yet exercised — no remote.LICENSE(MIT placeholder per SPEC §14; confirm before publishing).
Phase 1 exit criteria status: all met. Foundation primitives stable; import-invariant guard in place to prevent architectural drift through the rest of the build.
Next session — Phase 2: Style inspection. Per IMPLEMENTATION.md §2,
the order inside Phase 2 is: styles/theme.py (read-only theme color
resolution) → cascade resolver without provenance → toggle XOR tests →
provenance pass → theme edge cases. Largest single phase; budget 3–5 days.
End of implementation notes.