Skip to content

feat: work through the 2.0.0 milestone — 26 issues - #139

Merged
toxicphreAK merged 12 commits into
mainfrom
feat/2.0-issue-sweep
Aug 5, 2026
Merged

toxicphreAK merged 12 commits into
mainfrom
feat/2.0-issue-sweep

Conversation

@toxicphreAK

@toxicphreAK toxicphreAK commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Works through the 2.0.0 milestone: 26 of the 30 open issues implemented, the other
four assessed and left open with a reason.

What landed

Tables_Row.repeat_as_header and the rest of w:trPr (#106); Table.look,
the w:tblLook flags without which a styled table looks nothing like Word's preview
(#97); Table.width including percentages, .indent and .cell_margins (#107);
add_table(title=, description=) (#138).

Paragraphs and sectionsParagraphFormat.mark_font, the paragraph mark's own run
properties and the only place an empty paragraph's formatting lives (#105);
character-unit indents and line-unit spacing, plus the w:start/w:end spellings Word
now writes (#104); bidi and text_direction on paragraphs, sections and cells (#108);
ParagraphFormat.borders and Section.page_borders (#123).

Parts that were never loaded — the theme part and Font.theme_typeface, which is
the first way to find out what a document whose fonts come only from its theme is
actually rendered in (#96); endnotes (#94); the custom XML data store (#100); the VBA
project as bytes, with the content-type coupling both ways (#127).

Content that was unreachableInlineShape.image and Document.images (#124);
OMML equations (#95); embedded OLE objects (#101).

Stylesw:docDefaults (#99); the reachability closure behind Styles.usage(),
Style.in_use and Styles.unused (#114); Styles.remove_unused(),
LatentStyles.trim() and Document.cleanup() on top of it (#115);
Styles.import_from(), .extract() and .extract_xml() (#116).

Content operationscopy_to() on paragraphs, runs, rows and tables, with the
relationship remapping, drawing-id reassignment and bookmark handling a hand-written
deep copy gets wrong (#112); writable numbering definitions (#98); the caption API
(#102).

Images — EXIF Orientation honoured on insert, as a rotation in the DrawingML
rather than in the pixels (#125).

The bundled template — the Word-authored Hyperlink, CommentText,
CommentTextChar and CommentReference definitions adopted (#117), and 447 KB of
parts no generated document needs removed (#113).

Tooling — a python -m docx CLI over the inspection and cleanup operations (#118).

Decisions worth reading

Behaviour changes

Three, all in HISTORY.rst. The visible one: links in newly created documents are
theme-coloured rather than hardcoded blue, because add_hyperlink() now resolves the
real Word definition instead of synthesising one.

Not implemented

Checks

ruff check . clean, 2872 unit tests and 677 behave scenarios passing on both Python
3.9 and 3.12. A self-review pass found and fixed two defects — a shape-id collision
between embedded objects, and a KeyError from InlineShape.image on an unresolvable
relationship — plus three smaller points; each has a regression test, in the last
commit.

Review note

Ten commits, ~10k lines. Individually coherent, but this would normally have been
several PRs.

toxicphreAK and others added 12 commits August 5, 2026 13:53
…rders

Closes the table-properties, paragraph-properties and style-defaults group of
2.0.0 issues.

- #138 `add_table()` takes keyword-only `title` and `description` on `Document`,
  `BlockItemContainer` and `_Cell`, matching `Run.add_picture()`.
- #106 `_Row.repeat_as_header` plus the rest of `w:trPr` — `hidden`, `alignment`,
  `cell_spacing`, `width_before` and `width_after`.
- #97 `Table.look`, the six `w:tblLook` flags, with the legacy `@w:val` bitmask
  kept in step the way Word keeps it.
- #107 `Table.width` (including percentages, via the new `docx.shared.Pct`),
  `Table.indent` and `Table.cell_margins`. `CT_TblWidth.value` reads and writes
  every `w:type`, where `.width` only ever handled `dxa`.
- #99 `Styles.default_font` and `.default_paragraph_format` over `w:docDefaults`.
- #105 `ParagraphFormat.mark_font`, the paragraph mark's own run properties.
- #104 character-unit indents and line-unit spacing, plus the `w:start`/`w:end`
  indent spellings Word writes in recent files. Setting either unit clears its
  counterpart, since Word prefers the character value and a stale sibling
  silently wins.
- #108 `bidi` and `text_direction` on paragraphs, sections and cells, with the
  new `WD_TEXT_DIRECTION` enumeration.
- #123 `ParagraphFormat.borders` and `Section.page_borders`.

`_Border` and `_Borders` move to a new `docx.borders` module so all four
containers share one implementation; importing them from `docx.table` would have
been circular for paragraphs and sections.

`w:tblCellMar` reads and writes its edges through raw attributes, because the
edge tag names resolve to `CT_Border` — the constraint the note in
`oxml/__init__.py` already recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #124 `InlineShape.image` and `FloatingShape.image` resolve the shape's
  `r:embed` through the story part's relationships, plus `.svg_image` for the
  vector source of an SVG picture and `Document.images` for the package-level
  view. `.image` is None rather than an error for a chart, a SmartArt diagram or
  a linked picture, each of which is an ordinary document rather than a fault.
- #96 `ThemePart`, `Document.theme` and `Font.theme_typeface`. The last is the
  point of the exercise: a run whose font comes only from a theme token reported
  `Font.name` of None with no way to find the real typeface.

Both shapes and `Font` now carry their parent, so they can reach their part.
`InlineShape`, `FloatingShape` and `Font` keep the parent optional, since all
three are routinely constructed over a bare element.

`Document.theme` is None for a document with no theme part rather than creating
one, unlike the styles and settings parts — an empty theme would answer "what
typeface is this in" with a fiction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #94 endnote support. Footnotes and endnotes are the same feature in two
  places — the schema gives both `CT_FtnEdn` — so the oxml and proxy layers are
  generalized rather than duplicated: `_CT_FtnEdnCollection` and `_Notes` hold the
  shared behavior, and the two spellings differ only in tag names and the styles
  Word applies. Adds `Document.endnotes`, `Endnotes.add_endnote()`,
  `Run.add_endnote_reference()` and an `EndnotesPart` created on demand.
  `Document.replace_text(footnotes=True)` now reaches endnotes, which its
  docstring already claimed.

- #95 OMML equations. `Paragraph.math`, `BlockItemContainer.math` and
  `Document.math` expose the `m:oMath` content that was previously unreachable,
  each `Math` offering `.text`, `.xml` and `.is_display`.

  The issue asked for an explicit decision on whether `Paragraph.text` should
  include equation text. It should not. `replace_text()` and `isolate_range()`
  measure offsets against `Paragraph.text` and can only cut at run boundaries;
  equation text is in `m:t` inside `m:r` and unreachable to them, so including it
  would silently mis-target every replacement after the first equation in a
  paragraph. A wrong edit is worse than a missing character.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #100 `Document.custom_xml_parts` and `Document.add_custom_xml_part()`.
  `RT.CUSTOM_XML` and `CT.OFC_CUSTOM_XML_PROPERTIES` were already in
  `opc/constants.py` with nothing consuming them; the parts landed as generic
  `Part` objects with no way to reach them. `CustomXmlPart` is selected by
  reltype rather than content type, since a store item is `application/xml`.
  A test pins that opening and saving leaves an existing store byte-identical,
  which the issue called out as the thing to get right.

- #127 `Document.vba_project` (get, set, del), `Document.has_macros` and
  `Document.remove_vba_project()`. The content-type coupling is the part that
  matters: assigning a project switches the main part to the macro-enabled type,
  since Word silently ignores macros in a document that does not claim to be
  macro-enabled, and removing one switches it back, since Word warns the user
  about macros that are not there. `word/vbaData.xml` is removed alongside the
  project rather than left orphaned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #114 `Styles.usage()`, `Styles.unused` and `Style.in_use`. Getting "used"
  right is the whole job: it is a reachability closure over every story part,
  following `w:basedOn`, `w:next`, `w:link`, `w:styleLink`, `w:numStyleLink`,
  `w:lvl/w:pStyle` and the conditional formatting inside a table style, seeded
  from the direct applications and the `w:default="1"` styles. Dangling edges are
  dead ends rather than errors, and the visited set is what makes `w:next`
  pointing at its own style terminate. The closure runs on ids and translates to
  names only at the boundary.

- #115 `Styles.remove_unused()`, `LatentStyles.trim()` and `Document.cleanup()`,
  the last also dropping numbering definitions and image parts nothing
  references. This is destructive and the closure from #114 is the only thing
  between it and a document whose formatting quietly changed, so both features
  use one implementation. `Normal` is never removed — Word repairs a document
  that lacks it — and the default styles are kept unless explicitly asked for.
  Latent styles are a separate operation: removing an `lsdException` changes
  Word's gallery, not the rendering.

- #116 `Styles.import_from()`, `Styles.extract()` and `Styles.extract_xml()`.
  The extract prunes the bundled template's own 164 styles first, or the result
  would be mostly those rather than what was asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #117 lifts the `Hyperlink`, `CommentText`, `CommentTextChar` and
  `CommentReference` definitions verbatim out of
  `features/steps/test_files/comments-rich-para.docx` into the bundled template
  and `default-styles.xml`. These are authentic Word output rather than a guess
  at what Word would write, which is the objection that stalled #23.
  `_add_default_hyperlink_style()` and its calling branch are gone;
  `add_hyperlink()` resolves the style like everything else. `w:rsid` is stripped
  from each definition — it records the editing session Word wrote the style in
  and this library does not maintain rsids.

  Links now look slightly different: the real definition is theme-linked where
  the synthesised one was hardcoded blue. That is the point, and it is in
  HISTORY.

- #113 drops `word/stylesWithEffects.xml` (the Word 2007 fallback copy of the
  styles part, 438 KB, which Word 2013 and later ignore), `docProps/thumbnail.jpeg`
  (a preview of the *template's* page, shown by file managers as every generated
  document's thumbnail) and the template author's `customXml` bibliography store,
  along with their content-type overrides and relationships. 826 KB uncompressed
  down to 376 KB.

  The store had to go for #100's sake as well: `Document.custom_xml_parts` would
  otherwise report a data store item the caller never added.

`tests/test_template.py` pins both, including that the `.docx` and the unpacked
`default-docx-template/` agree — they are edited by hand with no build step
regenerating one from the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A JPEG off a phone is stored in the sensor's orientation with a tag saying how
to turn it for display. We read the tag's name and nothing else, so a portrait
photo went in sideways, and at the wrong aspect ratio whenever only a width was
given — the scaling derived the height from the stored ratio.

The rotation goes into the DrawingML rather than into the pixels. Rotating the
bytes would mean a JPEG decode/encode dependency this library does not have,
would lose quality, and would break the sha1 part deduplication that keeps one
copy of an image used twice; a test pins all three.

`px_width` and `px_height` keep meaning what they always meant — what is in the
file — and the new `px_display_width`, `px_display_height`, `display_width` and
`display_height` report the post-rotation shape. The scaling path uses the
display pair, which is the change that actually fixes the bug.

`wp:extent` becomes the display box so the layout reserves the right space,
while `a:ext` inside `pic:spPr` stays the unrotated box, which is what a
rotation about the shape's centre is measured from.

`honor_exif_orientation=False` is there for the image whose pixels are already
rotated and which carries the tag anyway — some encoders write both and there is
no reliable way to detect it.

The two fixtures are synthesised minimal Exif JPEGs rather than photographs;
their construction is recorded in the test module docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`copy.deepcopy(paragraph._p)` plus an `addnext()` works for plain text and
quietly breaks for anything else. Each repair here is one of those breakages:

- relationship references (`r:embed`, `r:id`, `r:link`) name relationships of the
  *source* part, so they are related into the destination afresh. Relating the
  same image blob back in gives the sha1 dedup for free, so a copy within one
  document does not duplicate the media.
- `wp:docPr/@id` must be unique document-wide; each copied drawing gets a free
  one.
- bookmarks are dropped rather than duplicated. A bookmark name is document-wide
  and a second bookmark of that name is not a copy — a cross-reference resolves
  to whichever it finds first. Dropping is the only outcome that is not silently
  wrong, and the docstring says so.
- a cross-document copy carries the styles (through `copy_style_from()`, so the
  `basedOn`/`next`/`link` closure comes too) and the numbering definitions,
  renumbered into the destination's id space. `missing_style` chooses between
  copying, dropping the reference, and raising.

Same-document copy and cross-document copy are one code path; the extra work is
skipped when source and destination are the same part.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #98 the write side of numbering. `CT_Lvl` gains setters for `start`, `numFmt`,
  `lvlText`, `lvlRestart`, `suff`, `lvlJc`, `pStyle` and `isLgl`, each placing its
  child in schema order — `CT_Lvl` is an `xsd:sequence` and Word rejects the
  children out of order. `CT_AbstractNum.add_level()` keeps levels in ascending
  `w:ilvl`, which Word also requires. `w:suff` was not modelled at all and is what
  controls the gap between the number and the text.

  `Numbering.add_definition()` allocates a free `abstractNumId` and `numId`, wires
  the `w:num` -> `w:abstractNum` link and places the abstract definition before
  every `w:num` (`CT_Numbering` is a sequence too).
  `add_numbered_definition()` and `add_bulleted_definition()` assemble the nine
  levels, which is the tedious half.

  `w:nsid` and `w:tmpl` are deliberately not written: they are what Word uses to
  recognise a definition as one of its own gallery entries, and inventing values
  would claim a provenance the definition does not have.

- #102 `Document.add_caption()` and `_Cell.add_caption()`. Everything a caption is
  built from already existed; this is the call that puts it together, including
  the step people get wrong — the `_Ref`-prefixed bookmark name, without which
  Word's cross-reference dialogue does not offer the caption as a target.

  The name is generated from a counter rather than Word's timestamp, since a
  timestamp would make the same document produce different bytes on each run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- #101 `Run.add_embedded_object()`, `Run.embedded_objects` and
  `Document.embedded_objects`. The read side is the useful half and the cheaper
  one: a document carrying embedded attachments previously gave no way to
  discover they exist.

  The visual is VML rather than DrawingML, so this shares nothing with
  `add_picture()` beyond relating the icon image in. VML attribute names are not
  namespace-qualified and their case is inconsistent (`ProgID`, `ShapeID`,
  `DrawAspect`), so they were taken from `ref/xsd/vml-officeDrawing.xsd` rather
  than from a sample document. `o:OLEObject/@ProgID` is what tells Word which
  application to launch, so `icon` is required and `prog_id` is documented as
  worth getting right.

- #118 a `python -m docx` CLI over the inspection and cleanup operations. The
  issue was explicitly optional and argued both ways; the case for is that these
  are diagnostic operations, and a diagnostic API with no command-line front end
  mostly does not get used.

  Kept to the terms the issue set: argparse only, every subcommand a thin shell
  over one public operation, `cleanup` refuses to write to its input, and
  `--check` exits non-zero when something would be removed so it can gate CI.
  `--json` on every reporting command. No `[project.scripts]` entry point — the
  issue said `python -m docx` alone commits to nothing, and it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects and three smaller points, each with a regression test.

- `add_embedded_object()` gave every object the same `v:shape/@id` and
  `o:OLEObject/@ObjectID`. `StoryPart.next_id` keeps only ids that are entirely
  digits, so the `_x0000_iNNNN` form is invisible to it and it returned 1 every
  time. Since `@ShapeID` is how an object names its visual, two objects resolved
  to the same shape. VML shape ids are now counted in their own space, for the
  same reason `next_bookmark_id` is separate from `next_id`.

- `InlineShape.image` raised `KeyError` for a blip whose relationship the package
  does not resolve — a relationship whose target was missing on load is dropped,
  so this is an ordinary document rather than a fault. Issue #124 asked for None
  or something specific and not a `KeyError` from the relationship lookup; it now
  reports None as it already did for a chart and a linked picture.

- `Document.images` assumed every image-typed relationship targets an
  `ImagePart`. `Package._gather_image_parts()` documents and guards the case
  where it does not; this now guards it the same way.

- `Numbering.add_definition()` accepted and wrote more than nine levels, which is
  out of schema. It raises now, and the two shorthands with it.

- A comment in `styles/transfer.py` said the opposite of what the code did, two
  `if TYPE_CHECKING: pass` blocks were left over, `Theme` accepted a `part` it
  discarded, and `EmbeddedObject.part_` is now `.embedded_part`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`:attr:`.latent_styles`` in `Styles.default_font` resolved to
`CleanupResult.latent_styles`, a NamedTuple field with no anchor, which aborts
the strict-mode docs build. Naming the class it belongs to resolves it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@toxicphreAK
toxicphreAK merged commit 4841a54 into main Aug 5, 2026
10 checks passed
@toxicphreAK
toxicphreAK deleted the feat/2.0-issue-sweep branch August 5, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant