feat: work through the 2.0.0 milestone — 26 issues - #139
Merged
Merged
Conversation
…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>
This was referenced Aug 5, 2026
Closed
Closed
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_headerand the rest ofw:trPr(#106);Table.look,the
w:tblLookflags without which a styled table looks nothing like Word's preview(#97);
Table.widthincluding percentages,.indentand.cell_margins(#107);add_table(title=, description=)(#138).Paragraphs and sections —
ParagraphFormat.mark_font, the paragraph mark's own runproperties and the only place an empty paragraph's formatting lives (#105);
character-unit indents and line-unit spacing, plus the
w:start/w:endspellings Wordnow writes (#104);
bidiandtext_directionon paragraphs, sections and cells (#108);ParagraphFormat.bordersandSection.page_borders(#123).Parts that were never loaded — the theme part and
Font.theme_typeface, which isthe 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 unreachable —
InlineShape.imageandDocument.images(#124);OMML equations (#95); embedded OLE objects (#101).
Styles —
w:docDefaults(#99); the reachability closure behindStyles.usage(),Style.in_useandStyles.unused(#114);Styles.remove_unused(),LatentStyles.trim()andDocument.cleanup()on top of it (#115);Styles.import_from(),.extract()and.extract_xml()(#116).Content operations —
copy_to()on paragraphs, runs, rows and tables, with therelationship 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
Orientationhonoured on insert, as a rotation in the DrawingMLrather than in the pixels (#125).
The bundled template — the Word-authored
Hyperlink,CommentText,CommentTextCharandCommentReferencedefinitions adopted (#117), and 447 KB ofparts no generated document needs removed (#113).
Tooling — a
python -m docxCLI over the inspection and cleanup operations (#118).Decisions worth reading
Paragraph.text(Expose OMML equation content (m:oMath) - equations are silently dropped from Paragraph.text #95). Including it woulddescribe the document more truthfully, but
replace_text()measures offsets against.textand can only cut at run boundaries; text it cannot reach would mis-targetevery replacement after the first equation in a paragraph.
document-wide, so a duplicate is a second bookmark competing for anything that refers
to the name.
document still serializes to the same bytes.
w:nsidandw:tmplare not written for a new numbering definition (Make numbering definitions writable - define a list from scratch #98).Inventing them would claim a provenance the definition does not have.
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 thereal Word definition instead of synthesising one.
Not implemented
Adopt the Word-authored Hyperlink, CommentText and CommentReference style definitions #117 split out and landed the parts that did not.
for 2.x"; nothing here changes that.
[vba]extra #128 VBA module source — blocked on a real macro fixture, as the issue says. The27-byte
vbaProject.binin the repo is a stub with no valid OLE signature.prerequisites (Read and decompress VBA module source, behind an optional
[vba]extra #128 landed, a real fixture, a decision on where the layer lives) arenot met.
Checks
ruff check .clean, 2872 unit tests and 677 behave scenarios passing on both Python3.9 and 3.12. A self-review pass found and fixed two defects — a shape-id collision
between embedded objects, and a
KeyErrorfromInlineShape.imageon an unresolvablerelationship — 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.