diff --git a/.gitignore b/.gitignore index 710fc1561..8a761ea81 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Session.vim # -- built documentation site -- site/ + +# -- Playwright MCP session artifacts -- +.playwright-mcp/ diff --git a/.playwright-mcp/page-2026-08-05T09-21-55-291Z.yml b/.playwright-mcp/page-2026-08-05T09-21-55-291Z.yml deleted file mode 100644 index fa2e9a75b..000000000 --- a/.playwright-mcp/page-2026-08-05T09-21-55-291Z.yml +++ /dev/null @@ -1,217 +0,0 @@ -- generic [active] [ref=e1]: - - link "Skip to content" [ref=e2] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#python-docx-ng - - banner [ref=e3]: - - navigation "Header" [ref=e4]: - - link "python-docx-ng" [ref=e5] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/ - - generic [ref=e9]: - - generic [ref=e10]: python-docx-ng - - generic: Home - - generic [ref=e12]: - - radio "Switch to dark mode" - - generic "Switch to dark mode" [ref=e13] [cursor=pointer] - - radio "Switch to light mode" - - dialog [ref=e16]: - - search [ref=e17]: - - generic [ref=e18]: - - textbox "Search" [ref=e19] - - navigation "Search": - - button "Clear" - - generic: Initializing search - - link "toxicphreAK/python-docx-ng" [ref=e24] [cursor=pointer]: - - /url: https://github.com/toxicphreAK/python-docx-ng - - generic [ref=e29]: - - main [ref=e30]: - - generic [ref=e31]: - - navigation "Navigation" [ref=e35]: - - generic [ref=e36]: python-docx-ng - - list [ref=e37]: - - listitem [ref=e38]: - - link "Home" [ref=e39] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/ - - listitem [ref=e41]: - - generic: User guide - - navigation "User guide" [ref=e42]: - - list [ref=e43]: - - listitem [ref=e44]: - - link "Installing" [ref=e45] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/install/ - - listitem [ref=e47]: - - link "Quickstart" [ref=e48] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/quickstart/ - - listitem [ref=e50]: - - link "API basics" [ref=e51] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/api-concepts/ - - listitem [ref=e53]: - - link "Documents" [ref=e54] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/documents/ - - listitem [ref=e56]: - - link "Text" [ref=e57] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/text/ - - listitem [ref=e59]: - - link "Tables" [ref=e60] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/tables/ - - listitem [ref=e62]: - - link "Sections" [ref=e63] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/sections/ - - listitem [ref=e65]: - - link "Headers and footers" [ref=e66] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/hdrftr/ - - listitem [ref=e68]: - - link "Understanding pictures and shapes" [ref=e69] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/shapes/ - - listitem [ref=e71]: - - link "Comments" [ref=e72] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/comments/ - - listitem [ref=e74]: - - link "Understanding styles" [ref=e75] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/styles-understanding/ - - listitem [ref=e77]: - - link "Working with styles" [ref=e78] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/styles-using/ - - listitem [ref=e80]: - - generic: How-to - - navigation "How-to" [ref=e81]: - - list [ref=e82]: - - listitem [ref=e83]: - - link "Finding and replacing text" [ref=e84] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/search-replace/ - - listitem [ref=e86]: - - link "Numbered and bulleted lists" [ref=e87] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/numbering/ - - listitem [ref=e89]: - - link "Fields and tables of contents" [ref=e90] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/fields/ - - listitem [ref=e92]: - - link "Bookmarks" [ref=e93] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/bookmarks/ - - listitem [ref=e95]: - - link "Tracked changes" [ref=e96] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/revisions/ - - listitem [ref=e98]: - - link "Images" [ref=e99] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/images/ - - listitem [ref=e101]: - - link "Watermarks" [ref=e102] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/watermarks/ - - listitem [ref=e104]: - - link "Footnotes" [ref=e105] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/footnotes/ - - listitem [ref=e107]: - - link "Form fields and content controls" [ref=e108] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/form-fields/ - - listitem [ref=e110]: - - link "Document properties" [ref=e111] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/document-properties/ - - listitem [ref=e113]: - - link "Templates and embedded files" [ref=e114] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/templates/ - - listitem [ref=e116]: - - link "Migrating from 0.9.x" [ref=e117] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/migrating-from-0-9/ - - listitem [ref=e119]: - - generic: API reference - - navigation "API reference" [ref=e120]: - - list [ref=e121]: - - listitem [ref=e122]: - - link "docx" [ref=e124] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/api/docx/ - - navigation "Table of contents" [ref=e131]: - - list [ref=e133]: - - listitem [ref=e134]: - - link "What it can do" [ref=e135] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#what-it-can-do - - listitem [ref=e137]: - - link "Getting started" [ref=e138] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#getting-started - - listitem [ref=e140]: - - link "For language models" [ref=e141] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#for-language-models - - article [ref=e144]: - - link "Edit this page" [ref=e145] [cursor=pointer]: - - /url: https://github.com/toxicphreAK/python-docx-ng/edit/main/docs/index.md - - heading [level=1] [ref=e148]: - - text: python-docx-ng - - link "¶" [ref=e149] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#python-docx-ng - - paragraph [ref=e150]: - - code [ref=e151]: python-docx-ng - - text: is a Python library for creating and updating Microsoft Word ( - - code [ref=e152]: .docx - - text: ) files. It is a downstream superset of - - link "python-docx" [ref=e153] [cursor=pointer]: - - /url: https://github.com/python-openxml/python-docx - - text: — the distribution is - - code [ref=e154]: python-docx-ng - - text: ", but the importable package is" - - code [ref=e155]: docx - - text: ":" - - generic [ref=e157]: - - navigation [ref=e158]: - - button "Copy to clipboard" [ref=e159] [cursor=pointer] - - code [ref=e160]: from docx import Document - - paragraph [ref=e161]: The two distributions cannot be installed side by side. - - heading [level=2] [ref=e162]: - - text: What it can do - - link "¶" [ref=e163] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#what-it-can-do - - generic [ref=e165]: - - navigation [ref=e166]: - - button "Copy to clipboard" [ref=e167] [cursor=pointer] - - code [ref=e168]: "from docx import Document from docx.shared import Inches document = Document() document.add_heading(\"Document Title\", 0) p = document.add_paragraph(\"A plain paragraph having some \") p.add_run(\"bold\").bold = True p.add_run(\" and some \") p.add_run(\"italic.\").italic = True document.add_heading(\"Heading, level 1\", level=1) document.add_paragraph(\"Intense quote\", style=\"Intense Quote\") document.add_paragraph(\"first item in unordered list\", style=\"List Bullet\") document.add_paragraph(\"first item in ordered list\", style=\"List Number\") document.add_picture(\"monty-truth.png\", width=Inches(1.25)) records = ( (3, \"101\", \"Spam\"), (7, \"422\", \"Eggs\"), (4, \"631\", \"Spam, spam, eggs, and spam\"), ) table = document.add_table(rows=1, cols=3) hdr_cells = table.rows[0].cells hdr_cells[0].text = \"Qty\" hdr_cells[1].text = \"Id\" hdr_cells[2].text = \"Desc\" for qty, id, desc in records: row_cells = table.add_row().cells row_cells[0].text = str(qty) row_cells[1].text = id row_cells[2].text = desc document.add_page_break() document.save(\"demo.docx\")" - - paragraph [ref=e169]: - - img "Example output" [ref=e170] - - heading [level=2] [ref=e171]: - - text: Getting started - - link "¶" [ref=e172] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#getting-started - - list [ref=e174]: - - listitem [ref=e175]: - - strong [ref=e176]: - - link "Installation" [ref=e177] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/install/ - - text: — install from PyPI with - - code [ref=e178]: pip - - text: or - - code [ref=e179]: uv - - text: . - - listitem [ref=e180]: - - strong [ref=e181]: - - link "Quickstart" [ref=e182] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/quickstart/ - - text: — open a document, add content, save it. - - listitem [ref=e183]: - - strong [ref=e184]: - - link "API reference" [ref=e185] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/api/docx/ - - text: — every module, class and property. - - listitem [ref=e186]: - - strong [ref=e187]: - - link "Migrating from 0.9" [ref=e188] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/user/migrating-from-0-9/ - - text: — what changed in 2.0.0. - - heading [level=2] [ref=e189]: - - text: For language models - - link "¶" [ref=e190] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/#for-language-models - - paragraph [ref=e191]: - - text: The documentation is published in the - - link "llms.txt" [ref=e192] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/llms.txt - - text: format, with the full text at - - link "llms-full.txt" [ref=e193] [cursor=pointer]: - - /url: https://toxicphreak.github.io/python-docx-ng/llms-full.txt - - text: . Point any tool that reads - - code [ref=e194]: llms.txt - - text: at those URLs — for example - - link "mcpdoc" [ref=e195] [cursor=pointer]: - - /url: https://github.com/langchain-ai/mcpdoc - - text: ", which serves them to an editor over MCP." - - button "Back to top" - - contentinfo [ref=e196]: - - generic [ref=e199]: - - generic [ref=e200]: Distributed under the MIT licence. - - text: Made with - - link "Material for MkDocs" [ref=e201] [cursor=pointer]: - - /url: https://squidfunk.github.io/mkdocs-material/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a95be7328..55e209336 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,16 +15,15 @@ open an issue here too if you want it tracked. **Anything upstream has declined, or has not adopted, belongs here.** That is the whole reason this fork exists — tracked changes, fields, watermarks, floating images and the -rest. See the [2.0.0 milestone](https://github.com/toxicphreAK/python-docx-ng/milestone/1) -for what is planned and what is deliberately not started yet. +rest. See the [open issues](https://github.com/toxicphreAK/python-docx-ng/issues) for what +is planned and what is deliberately not started yet. If you are unsure, open an issue and ask before writing code. ## Branches -- **`main`** is the 2.0.0 line and the only branch that takes work. It was restarted - from upstream v1.2.0 in August 2026; the fork's own features are being re-applied on - top one at a time. +- **`main`** is the 2.x line and the only branch that takes work. It was restarted from + upstream v1.2.0 in August 2026; the fork's own features have been re-applied on top. - **`master`** is the 0.9.x record, kept for historical reference. Never target it, and never port work back onto it. - The **`upstream`** remote is read-only. diff --git a/HISTORY.rst b/HISTORY.rst index c11ea0c6a..20a049f24 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,8 +3,14 @@ Release History --------------- -Unreleased -++++++++++ +2.1.0 (2026-08-05) +++++++++++++++++++ + +Twenty-six issues from the 2.0.0 milestone. The theme, endnotes, OMML equations, the +custom XML data store, the VBA project and embedded OLE objects were all parts a +document could carry that nothing here could reach; they are reachable now. Styles grew +a usage closure and the cleanup built on it, numbering became writable, and content +gained a ``copy_to()`` that repairs what a deep copy breaks. Behaviour changes ~~~~~~~~~~~~~~~~~ @@ -142,6 +148,30 @@ New features ``Run.add_picture()``, 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. +- ``Document.add_custom_xml_part()`` accepts an ``item_id``. The GUID it otherwise + generates was the one thing in this library's output that was not a function of its + input, which quietly cost byte-reproducibility for any document carrying a data store. + +Fixes +~~~~~ + +- Every entry point that takes a path now accepts an ``os.PathLike`` as well as a ``str``. + ``add_picture()``, ``add_float_picture()``, ``add_image_watermark()``, + ``add_alt_chunk()``, ``add_embedded_object()`` and ``Image.from_file()`` tested + ``isinstance(x, str)`` and fell through to the stream branch, so a ``pathlib.Path`` + failed with ``AttributeError: 'PosixPath' object has no attribute 'seek'`` — a message + that says nothing about what was wrong. The README claimed this worked; now it does. +- ``python -m docx`` reports a document it cannot open rather than ending in a traceback. + ``PackageNotFoundError`` — what a missing file or a non-package raises, and the most + common failure there is — is neither a ``FileNotFoundError`` nor a ``BadZipFile``, so it + went uncaught. A password-protected file now says so, and a ``--names`` or ``--keep`` + naming a style the document does not define is reported rather than raised. +- ``styles list --used`` and ``--unused`` are mutually exclusive. Passing both matched + nothing and printed an empty list, which reads as an answer rather than as the + contradiction it is. +- ``copy_to()`` keeps a copied drawing's ``wp:docPr/@name``. Only ``@id`` has to be + unique; the name is the shape's own — the source file name for a picture — and + overwriting it discarded information and labelled a copied chart "Picture 3". 2.0.0 (2026-08-05) ++++++++++++++++++ diff --git a/README.md b/README.md index 01e6fddae..f141511d7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ CVE-2026-41066. text, tables, sections, styles - [How-to pages](https://toxicphreak.github.io/python-docx-ng/user/search-replace/) — the features listed below, each with worked examples +- [Command line](https://toxicphreak.github.io/python-docx-ng/user/cli/) — + `python -m docx info`, `styles report`, `cleanup` - [API reference](https://toxicphreak.github.io/python-docx-ng/api/docx/) — every module, generated from the source - [Migrating from 0.9.x](https://toxicphreak.github.io/python-docx-ng/user/migrating-from-0-9/) @@ -77,33 +79,52 @@ Everything upstream v1.2.0 does, plus: **Editing and review** + Tracked changes — read revisions, and accept or reject them individually or in bulk -+ Comments, footnotes, and cross-run search and replace that survives Word's run splitting ++ Comments, footnotes, endnotes, and cross-run search and replace that survives Word's run splitting + A deletion API — `.delete()` on paragraphs, runs, tables, rows and columns ++ `copy_to()` on paragraphs, runs, rows and tables — with relationships, ids, styles and numbering repaired **Content people ask for** + Fields and a table of contents — `Paragraph.add_field()`, with builders for PAGE, TOC, REF, SEQ and the rest -+ List numbering — read a paragraph's number, apply a list, restart it -+ Bookmarks, `Paragraph.add_hyperlink()`, and captions via SEQ ++ Captions — `Document.add_caption()` writes the label, the SEQ field and the `_Ref` bookmark Word's cross-reference dialogue needs ++ List numbering, readable *and* writable — define a list from scratch, apply it, restart it ++ Bookmarks and `Paragraph.add_hyperlink()` + Watermarks, text and image, written into the header where Word expects them + Floating (anchored) images with text wrapping, alongside inline ones + Legacy form fields — read and fill text inputs, check boxes and drop-downs + AltChunk — embed HTML, RTF or another `.docx` for Word to import on open ++ Embedded OLE objects — discover and extract a document's attachments, or add one **Formatting** -+ Table and cell borders — `Table.borders["top"].line = WD_LINE_STYLE.SINGLE` ++ Borders on tables, cells, paragraphs and pages — `Table.borders["top"].line = WD_LINE_STYLE.SINGLE` ++ Table width (including percentages), indent, cell margins, and the `tblLook` style flags ++ Row properties — `repeat_as_header`, `hidden`, alignment, cell spacing, `dont_split` + Paragraph and run shading, including the pattern and its colour -+ Multi-column section layout, and row `dont_split` ++ Document defaults (`w:docDefaults`) — the bottom of the inheritance chain, and often the only place the base font is set ++ The paragraph mark's own formatting, which is what an empty paragraph is formatted with ++ Right-to-left and vertical text on paragraphs, sections and cells ++ Character-unit indents and line-unit spacing ++ Multi-column section layout + Outline level — drives the outline shown in navigation panes and PDF bookmarks + Font scaling, theme typefaces, East Asian and complex-script typefaces + +**Styles** + ++ Which styles are actually used — a reachability closure over every story part, not a scan of the body ++ `Document.cleanup()` — remove the styles, numbering and media nothing points at; 20 KB down to 9 KB ++ Bulk style transfer — import a house `.dotx`'s styles, or extract a document's into one + Copying a style between documents, with its `basedOn`/`next`/`link` closure and numbering **Files and formats** -+ `.docm` (macro-enabled) and `.dotx`/`.dotm` (template) support ++ `.docm` (macro-enabled) and `.dotx`/`.dotm` (template) support, plus reading, transplanting and stripping the VBA project itself + SVG, EMF, WMF and WebP image support -+ Custom and extended document properties (`docProps/custom.xml`, `docProps/app.xml`) ++ Image extraction — `InlineShape.image`, `FloatingShape.image`, `Document.images` ++ EXIF orientation honoured, so a portrait photo off a phone is not inserted sideways ++ Custom and extended document properties (`docProps/custom.xml`, `docProps/app.xml`), and the custom XML data store content controls bind to ++ The theme part — the fonts and colours a `minorHAnsi` token resolves through ++ OMML equations, readable through `Document.math` + Reproducible documents — the same input produces byte-identical output + Accepts `pathlib.Path` anywhere a path is taken @@ -111,14 +132,18 @@ Everything upstream v1.2.0 does, plus: + Alt text on pictures, inline shapes and tables +**Tooling** + ++ `python -m docx` — `info`, `styles report/list/extract` and `cleanup`, with a `--check` mode that works as a CI gate + **Robustness** + Tolerates oversized attribute values the default `lxml` parser rejects + Corrupt, truncated and password-protected files raise something that says which + Custom namespaces in `xpath()` calls -Some things are deliberately not here yet — reading ISO Strict documents, endnotes, -equations, embedded OLE objects and charts among them. See the +Some things are deliberately not here yet — reading ISO Strict documents, charts and +SmartArt, and decompressing VBA module source among them. See the [issue tracker](https://github.com/toxicphreAK/python-docx-ng/issues) for what is planned, and [HISTORY.rst](https://github.com/toxicphreAK/python-docx-ng/blob/main/HISTORY.rst) for the full changelog. diff --git a/docs/user/cleanup.md b/docs/user/cleanup.md new file mode 100644 index 000000000..61debee28 --- /dev/null +++ b/docs/user/cleanup.md @@ -0,0 +1,171 @@ +# Style usage and cleanup + +A document created by this library defines 168 styles. A one-paragraph document +references ten of them. Most of `word/styles.xml` is Word's built-in gallery, carried +along because the template it came from carried it. + +This page is about finding that out and doing something about it. The command-line +front end for everything here is [`python -m docx`](cli.md). + +## Which styles are in use + +```python +usage = document.styles.usage() + +print(usage) +# 168 styles defined, 10 in use, 158 unused; 131 latent style exceptions +``` + +[`Styles.usage()`][docx.styles.styles.Styles.usage] returns a +[`StyleUsage`][docx.styles.usage.StyleUsage], a named tuple of style **ids**: + +| Field | Meaning | +| --- | --- | +| `defined` | every style id in the styles part, in document order | +| `used` | the ids in the reachability closure of what the document applies | +| `unused` | `defined` minus `used`, in document order | +| `reference_counts` | how many times each id is *directly* applied; a style reachable only indirectly counts zero | +| `latent` | names declared in `w:latentStyles` that the document does not define | + +Iterating a `StyleUsage` yields the ids in use. + +The convenience accessors work in names, as the rest of the styles API does: + +```python +for style in document.styles.unused: + print(style.name) + +document.styles["Heading 7"].in_use # -> False +``` + +### "Used" is a closure, not a scan + +A scan of `w:pStyle` in `word/document.xml` gets the wrong answer in four ways, and each +of them is a real document: + +- **Every story part counts, not just the body.** Headers, footers, footnotes, endnotes + and comments are separate parts with their own style references. +- **A style can be reachable without ever being applied** — as the `w:basedOn` of a used + style, as its `w:next`, as its `w:link`, from a numbering level's `w:pStyle`, or from + the `w:tblStylePr` conditional formatting inside a table style. +- **The `w:default="1"` styles apply to content that names no style at all.** They are + used by definition and have zero direct references. +- **`Normal` is never dead.** Word repairs a document that lacks it, and the repair + dialogue is worse than the bloat. + +So "used" is computed as a reachability closure: seed from the direct applications, the +default styles and `Normal`, then follow the reference edges until the set stops growing. +A dangling edge — a `w:basedOn` naming a style that is not defined — is a dead end rather +than an error, because that is legal and common. + +Two keyword arguments adjust the seed: + +```python +# -- treat a style you are about to apply as used, along with its dependencies -- +document.styles.usage(keep=("Quote",)) + +# -- the narrower question: what is reachable by reference alone? -- +document.styles.usage(seed_defaults=False) +``` + +## Removing the unused ones + +```python +removed = document.styles.remove_unused() +len(removed) # -> 158 +``` + +[`Styles.remove_unused()`][docx.styles.styles.Styles.remove_unused] returns the ids it +removed. It takes the same `keep`, and `keep_defaults` (`True` by default) protects the +`w:default="1"` styles. + +!!! warning + + This is destructive and it is not undoable within the open document. A style you + intend to apply later is unused *now*, so name it in `keep`: + + ```python + document.styles.remove_unused(keep=("Quote", "Intense Quote")) + ``` + +## Cleaning up the whole document + +[`Document.cleanup()`][docx.document.Document.cleanup] runs three passes and returns a +[`CleanupResult`][docx.cleanup.CleanupResult]: + +```python +result = document.cleanup() + +print(result) +# removed 158 styles, 3 numbering definitions, 3 abstract numbering +# definitions, 0 media parts and 0 latent style exceptions + +result.styles # -> the style ids +result.num_ids # -> the w:num numIds +result.abstract_num_ids # -> the w:abstractNum abstractNumIds +result.media # -> the partnames of the image parts +result.latent_styles # -> how many w:lsdException overrides went +``` + +The three passes, each of which can be turned off: + +**Styles** — the closure above. + +**Numbering definitions** — a `w:num` no `w:numPr` points at is dead, and a +`w:abstractNum` no surviving `w:num` points at is dead too. Chains through +`w:numStyleLink` are followed, so a definition kept alive only indirectly survives. + +**Orphan media** — an image part related from nothing, which is what +[`.delete()`][docx.text.paragraph.Paragraph.delete] on a paragraph holding a picture +leaves behind. Only the document part's own image relationships are considered: a header +image belongs to the header part and is not orphaned by anything happening in the body. + +```python +document.cleanup( + styles=True, + numbering=True, + media=True, + latent_styles=False, # -- off by default, see below -- + keep=("Quote",), +) +``` + +Styles are pruned before numbering, so a numbering definition kept alive only by a style +that is about to go is correctly seen as dead. + +### Latent styles are separate on purpose + +`latent_styles` is `False` by default. A `w:lsdException` is a *behaviour declaration* +for a style the document does not define — whether it shows in Word's style gallery, and +in what order. Removing one changes what a user sees in the UI rather than how the +document renders, which is a different kind of change from removing a style definition. + +[`LatentStyles.trim()`][docx.styles.latent.LatentStyles.trim] does it on its own, and +returns how many went. It removes *every* override — the bundled template carries 137, +one per built-in Word might offer — while leaving the defaults on the `w:latentStyles` +element itself, which are what the overrides were overriding: + +```python +document.styles.latent_styles.trim() # -> 137 +``` + +That count is not the same as `usage.latent`, which is the narrower set of latent names +the document does not also *define*. The two answer different questions and it is worth +not confusing them. + +## What this is worth + +For a document generated from the bundled template and then cleaned: + +| | Uncompressed | On disk | +| --- | --- | --- | +| As generated | 376 KB | 20 KB | +| After `cleanup()` | 40 KB | 9 KB | + +Almost all of it is `word/styles.xml`. Whether that matters depends on what you are +doing — it is nothing for one document and a great deal for a hundred thousand of them. + +## Moving styles between documents + +The other side of the same coin: rather than pruning what a template brought, bring only +what you want. See [Templates and embedded files](templates.md#importing-a-templates-styles). diff --git a/docs/user/cli.md b/docs/user/cli.md new file mode 100644 index 000000000..b1a5c7e7c --- /dev/null +++ b/docs/user/cli.md @@ -0,0 +1,129 @@ +# Command line + +`python -m docx` answers the questions people ask about a `.docx` one at a time: what +styles it defines, which of them are actually used, and why the file is 900 KB. + +It is deliberately thin. Every subcommand maps onto one public library operation and +holds no logic of its own, so there is nothing reachable through the command line that is +not reachable from Python. There is no new dependency — it is `argparse` only. + +```console +$ python -m docx --help +usage: python -m docx [-h] {info,styles,cleanup} ... + +Inspect and clean up Word documents. + +positional arguments: + {info,styles,cleanup} + info list the parts of a document with their sizes + styles inspect a document's styles + cleanup remove unused styles, numbering and media +``` + +## `info` — what is in the package + +The fastest answer to "why is this file like this": the part inventory, biggest first. + +```console +$ python -m docx info report.docx + 350527 word/styles.xml + 7642 word/theme/theme1.xml + 5513 word/numbering.xml + 2811 word/fontTable.xml + 2535 word/settings.xml + 1586 word/document.xml + ... + 375746 TOTAL uncompressed (20199 on disk) +``` + +This is how the 438 KB `word/stylesWithEffects.xml` that used to ship in the bundled +template turned up. `--json` emits the same data for a script to consume. + +## `styles` — what it defines and what it uses + +```console +$ python -m docx styles report report.docx +168 styles defined (30 character, 1 list, 37 paragraph, 100 table) +10 in use, 158 unused +131 latent style exceptions +``` + +"In use" is a reachability closure over every story part, not a scan of the body — see +[Style usage and cleanup](cleanup.md) for what that means and why the distinction +matters. + +`styles list` prints the names, optionally filtered: + +```console +$ python -m docx styles list report.docx --used +Normal +Default Paragraph Font +Normal Table +No List +List Bullet +... +``` + +`--used` and `--unused` are mutually exclusive; asking for both would match nothing. + +`styles extract` writes the styles out to a document of their own, which is how you turn +a document you like the look of into a template: + +```console +$ python -m docx styles extract house-style.docx -o house.dotx --as-template +$ python -m docx styles extract report.docx -o headings.docx --names "Heading 1,Heading 2" +``` + +Named styles come with their `basedOn` / `next` / `link` closure, so the extract is a +document that opens without repair. + +## `cleanup` — remove what nothing points at + +```console +$ python -m docx cleanup report.docx -o small.docx +removed 158 styles, 3 numbering definitions, 3 abstract numbering definitions, 0 media parts and 0 latent style exceptions +wrote small.docx +``` + +Two rules this command keeps: + +**It never writes to its input.** `-o` is required. Someone will point it at their only +copy. + +**`--check` is a CI gate.** It reports what *would* be removed and exits non-zero if +anything would be, so a pipeline can fail a build that has grown dead weight: + +```console +$ python -m docx cleanup report.docx --check +would have removed 158 styles, 3 numbering definitions, ... +$ echo $? +1 +``` + +| Flag | Effect | +| --- | --- | +| `--keep NAMES` | comma-separated style names to preserve, along with their dependencies | +| `--no-styles` | leave the style definitions alone | +| `--no-numbering` | leave the numbering definitions alone | +| `--no-media` | leave orphaned image parts alone | +| `--latent-styles` | *also* drop the latent-style exceptions — off by default, because this changes what a user sees in Word's style gallery | +| `--json` | emit the full list of what went, not just the counts | + +## Exit codes + +These end up in scripts, so they are part of the contract: + +| Code | Meaning | +| --- | --- | +| `0` | success | +| `1` | `cleanup --check` found something to remove | +| `2` | the document could not be opened, or an argument named something that is not there | + +A document that cannot be opened is reported as a message on stderr, never as a +traceback — including the password-protected case, which is a different thing from a +corrupt file: + +```console +$ python -m docx info nope.docx +error: cannot open document: Package not found at 'nope.docx' +``` diff --git a/docs/user/copying.md b/docs/user/copying.md new file mode 100644 index 000000000..e3377cf50 --- /dev/null +++ b/docs/user/copying.md @@ -0,0 +1,94 @@ +# Copying content + +`copy_to()` duplicates a paragraph, a run, a table row or a whole table — into the same +document or a different one. + +```python +paragraph.copy_to(document) +row.copy_to(table) +table.copy_to(other_document) +run.copy_to(paragraph) +``` + +## Why not `copy.deepcopy()` + +Duplicating content is the operation most often written by hand against this library, and +the hand-written version works for plain text and quietly breaks for anything +interesting. `copy_to()` is a deep copy plus a repair for each of these: + +- **A picture's `r:embed`** names a relationship id belonging to the *source* part, so a + copied image is either the wrong image or a dangling reference. +- **A hyperlink's `r:id`** has the same problem, and points at an external target that + may not exist in the destination package. +- **`wp:docPr/@id`** must be unique document-wide, and a deep copy duplicates it. +- **A bookmark name** is document-wide, and a duplicate is not a copy of a bookmark: Word + treats it as a second bookmark of the same name, and anything referring to that name + resolves to whichever it finds first. +- **Across documents**, a `w:pStyle` names a style that may not be there and a `w:numPr` + names a `numId` that certainly means something else. + +Relating the same image into the destination reuses the sha1 deduplication, so copying a +picture into a document that already has it does not add a second copy of the bytes. + +## Where the copy goes + +Appended to the end of the container by default; `before` and `after` place it: + +```python +paragraph.copy_to(document) # -> at the end of the body +paragraph.copy_to(document, before=document.paragraphs[0]) +paragraph.copy_to(cell) # -> into a table cell +row.copy_to(table, after=table.rows[0]) +``` + +Appending to a body that ends in a `w:sectPr` inserts before it, since the section +properties have to stay last. Passing both `before` and `after` raises `ValueError`. + +Each returns a proxy for the copy, so a repeated row is one line: + +```python +template_row = table.rows[0] +for record in records: + new_row = template_row.copy_to(table) + new_row.cells[0].text = record.name +``` + +## Copying into another document + +A cross-document copy brings what the content refers to with it. `missing_style` decides +what happens to a style the destination does not define: + +| Value | Behaviour | +| --- | --- | +| `"copy"` (default) | copy the style across with its `basedOn` / `next` / `link` closure | +| `"drop"` | remove the style reference; the content falls back to the destination's default | +| `"raise"` | raise `ValueError` | + +```python +paragraph.copy_to(other_document, missing_style="drop") +``` + +A style reference the *source* cannot resolve either is left alone — it is already broken +there, and inventing a target would be worse than carrying the break over. + +Numbering comes across too, and this is the part that is easy to get wrong by hand. A +`w:numId` means something else in the destination, so leaving it alone numbers the copied +paragraph according to whichever list happens to hold that id — a silent wrong answer +rather than a visible failure. The definition is copied and the copy repointed at it, and +the source's `w:nsid` is dropped so the destination's list gallery does not show two +definitions as the same list. + +## Bookmarks are dropped, not renamed + +```python +paragraph.add_bookmark("Target") +copy = paragraph.copy_to(document) + +[b.name for b in document.bookmarks] # -> ["Target"], not two of them +``` + +This is the one place `copy_to()` deliberately loses something. Renaming would leave a +bookmark nothing points at; keeping the name would leave two competing for every +reference to it. Dropping is the only outcome that is not silently wrong — use +[`Paragraph.add_bookmark()`][docx.text.paragraph.Paragraph.add_bookmark] on the copy to +bookmark it afresh. diff --git a/docs/user/document-properties.md b/docs/user/document-properties.md index cd8f7bc01..939b5d089 100644 --- a/docs/user/document-properties.md +++ b/docs/user/document-properties.md @@ -125,3 +125,64 @@ from docx import fields paragraph = document.add_paragraph("Matter: ") paragraph.add_field(fields.doc_property("Matter number")) ``` + +## The custom XML data store + +A different thing from the properties above, and easy to confuse with them. The three +kinds of properties are flat named scalars — a string, a number, a date. The **custom XML +data store** holds arbitrary XML documents, each in a part of its own +(`customXml/item1.xml`, with an `itemProps1.xml` sidecar declaring its schemas). + +This is where a document-generation pipeline keeps the structured data its content +controls are bound to. Word's data binding points a content control at an XPath into one +of these items, so the control's displayed text and the stored data stay the same thing. + +```python +part = document.add_custom_xml_part( + "" + "2026-0141450.00" + "", + schema_refs=("urn:example:invoice",), +) + +part.item_id # -> "{...}", the GUID Word identifies the item by +part.schema_refs # -> ("urn:example:invoice",) +part.xml # -> the XML as text +``` + +Reading them back: + +```python +for part in document.custom_xml_parts: + print(part.partname, part.item_id, part.schema_refs) + print(part.xml) +``` + +[`Document.custom_xml_parts`][docx.document.Document.custom_xml_parts] is a tuple in +relationship order, empty for a document that has no data store. +[`add_custom_xml_part()`][docx.document.Document.add_custom_xml_part] accepts a `str` or +`bytes`, allocates the next free partname, and generates the `w:itemProps` sidecar with a +GUID of its own — Word requires the sidecar, and an item without one is a repair prompt. + +`schema_refs` names the namespaces the item uses. It is optional and it is what Word's +XML mapping pane lists, so supplying it is what makes the item usable for data binding +through the UI. + +!!! warning + + **The generated GUID is the one thing in this library's output that is not a function + of its input.** Everything else about a saved document is deterministic — see + [Reproducible output](documents.md#reproducible-output). If that matters to you, supply the `item_id`: + + ```python + document.add_custom_xml_part(xml, item_id="{...}") + ``` + + It only has to be unique within the document. + +!!! note + + A document created by this library carries **no** data store. The bundled template + used to ship an empty bibliography item left over from its author's Word session, + which meant `custom_xml_parts` reported a store the caller had never added. That is + gone as of 2.1.0. diff --git a/docs/user/documents.md b/docs/user/documents.md index bb18d8eb0..42bc83e83 100644 --- a/docs/user/documents.md +++ b/docs/user/documents.md @@ -27,7 +27,7 @@ If you want more control over the final document, or if you want to change an ex Things to note: - You can open any Word 2007 or later file this way (`.doc` files from Word 2003 and earlier won't work). Macro-enabled `.docm` files and `.dotx` / `.dotm` templates open too. -- Not every part of a document has an API yet — embedded OLE objects and charts, for instance. Anything without one is left untouched and written back out unchanged on save, so opening and re-saving a document never silently discards content it does not understand. +- Not every part of a document has an API yet — charts and SmartArt diagrams, for instance. Anything without one is left untouched and written back out unchanged on save, so opening and re-saving a document never silently discards content it does not understand. - If you use the same filename to open and save the file, `python-docx` will obediently overwrite the original file without a peep. You'll want to make sure that's what you intend. ## Files that cannot be opened @@ -63,3 +63,55 @@ that says which: The `'rb'` file open mode parameter isn't required on all operating systems. It defaults to `'r'` which is enough sometimes, but the 'b' (selecting binary mode) is required on Windows and at least some versions of Linux to allow Zipfile to open the file. Okay, so you've got a document open and are pretty sure you can save it somewhere later. Next step is to get some content in there ... + +## Reproducible output + +The same document data serialises to the same bytes, every time, on any machine. Three +things make that true: + +- Every zip member is stamped with a fixed timestamp — the zip epoch, 1980-01-01 — rather + than the time of the save. +- Parts are written in partname order. +- Relationships are written in numeric `rId` order. + +So a build that generates a document twice produces two identical files, and a checksum is +a meaningful thing to compare. `tests/test_reproducible.py` pins this. + +The one exception is [`add_custom_xml_part()`][docx.document.Document.add_custom_xml_part], +which generates a random GUID unless you supply one — see +[The custom XML data store](document-properties.md#the-custom-xml-data-store). + +Anything you put in a document yourself is of course yours to keep deterministic. A +timestamp in [core properties](document-properties.md), or text built from +`datetime.now()`, changes the input and therefore the output. + +## Reducing the size of what you produce + +A document generated from the bundled template is around 20 KB on disk, and most of that +is the 168 style definitions Word's own template carries. `Document.cleanup()` removes +what nothing points at and takes it to about 9 KB: + +```python +document.cleanup() +``` + +See [Style usage and cleanup](cleanup.md), and [`python -m docx cleanup`](cli.md) for +doing it to a file you already have. + +## Parts beyond the body + +A `.docx` is a package of parts, and several of them have an API of their own. The ones +not covered by another page: + +| | | +| --- | --- | +| [`Document.theme`][docx.document.Document.theme] | the theme fonts and colours — see [Theme fonts](text.md#theme-fonts) | +| [`Document.custom_xml_parts`][docx.document.Document.custom_xml_parts] | the custom XML data store — see [Document properties](document-properties.md#the-custom-xml-data-store) | +| [`Document.vba_project`][docx.document.Document.vba_project] | the macro project — see [Macros](templates.md#macros) | +| [`Document.embedded_objects`][docx.document.Document.embedded_objects] | embedded OLE objects — see [Embedded OLE objects](templates.md#embedded-ole-objects) | +| [`Document.images`][docx.document.Document.images] | the images the body embeds — see [Images](images.md#reading-the-images-already-in-a-document) | +| [`Document.math`][docx.document.Document.math] | the equations — see [Equations](text.md#equations) | +| [`Document.endnotes`][docx.document.Document.endnotes] | the endnotes — see [Footnotes](footnotes.md#endnotes) | + +`Document.part.package.iter_parts()` reaches everything, typed where a part class exists +and as a plain `Part` where one does not. diff --git a/docs/user/fields.md b/docs/user/fields.md index 898eea4a6..6c007e955 100644 --- a/docs/user/fields.md +++ b/docs/user/fields.md @@ -94,9 +94,68 @@ body.add_field(fields.page_reference("intro")) [`page_reference()`][docx.fields.page_reference] the *page number* it falls on. See [Bookmarks](bookmarks.md) for the ways to create one. -## Captions that renumber +## Captions -`SEQ` is the counter behind Word's captions. Each name is its own sequence: +[`Document.add_caption()`][docx.document.Document.add_caption] writes the whole thing — +the label, the self-renumbering `SEQ` field, and the bookmark a cross-reference needs: + +```python +document.add_picture("architecture.png") +caption = document.add_caption("Figure", "the architecture") +``` + +That gives a paragraph in the "Caption" style that Word renders as "Figure 1 the +architecture", where the number is a field it recomputes whenever figures are inserted or +removed. Read back before Word has computed it, the number is simply missing: +`caption.text` is `"Figure the architecture"`. + +```python +caption.paragraph # -> the Paragraph, for any further formatting +caption.label # -> "Figure" +caption.text # -> the text as the document currently reads it +caption.number # -> the number Word last computed, or None +caption.bookmark_name # -> "_Ref000000001" +``` + +`number` is `None` for a caption this library has just written: a `SEQ` field's result is +cached in the document, so it reads back only after a round trip through Word. + +Referring to it later is one call: + +```python +document.add_paragraph("See ").add_field( + fields.cross_reference(caption.bookmark_name) +) +``` + +**The bookmark name is the part that matters.** Word's cross-reference dialogue offers +only targets whose bookmark name follows its own `_Ref` convention, so a caption +bookmarked with an arbitrary name works but is invisible in the UI — the user cannot +reference it. `add_caption()` generates names in that shape. (Word derives its own from a +timestamp; a counter is used here instead, because a timestamp would make the same +document generate different bytes on each run.) That shape also means the bookmark does +not appear in [`Document.bookmarks`][docx.document.Document.bookmarks], which leaves out +the ones Word maintains for itself; `Bookmarks.iter_all()` reaches it. + +Four keyword arguments adjust the result: + +```python +document.add_caption("Table", "Revenue by region", separator=": ") +document.add_caption("Figure", "A cat", restart_at_heading_level=1) # -> "Figure 3-2" +document.add_caption("Figure", "A cat", before=document.paragraphs[0]) +document.add_caption("Figure", "A cat", style=None) # -> no style applied +``` + +`separator` is what goes between the number and the text. `restart_at_heading_level` +starts the numbering over at each chapter, which is the "Figure 3-2" convention. +[`_Cell.add_caption()`][docx.table._Cell.add_caption] does the same inside a table cell. + +Each label is its own sequence, so figures, tables and equations number independently. + +### Assembling one by hand + +`SEQ` is the counter underneath, and it is still there if you want to build a caption +yourself: ```python caption = document.add_paragraph("Figure ", style="Caption") @@ -104,13 +163,12 @@ caption.add_field(fields.sequence("Figure")) caption.add_run(": the architecture") ``` -Pass `restart_at_heading_level` to start the numbering over at each chapter, which gives -the "Figure 3-2" style: - ```python caption.add_field(fields.sequence("Figure", restart_at_heading_level=1)) ``` +This is what `add_caption()` does, minus the bookmark. + ## Reading the fields in a document [`Document.fields`][docx.document.Document.fields] returns every field in the body, and diff --git a/docs/user/footnotes.md b/docs/user/footnotes.md index 941a8da10..b994ec70f 100644 --- a/docs/user/footnotes.md +++ b/docs/user/footnotes.md @@ -57,3 +57,45 @@ the number printed in the margin. are now [`Document.footnotes`][docx.document.Document.footnotes] plus [`Run.add_footnote_reference()`][docx.text.run.Run.add_footnote_reference], which separates creating the note from placing the mark. + +## Endnotes + +Endnotes are the same shape, one part over: they live in `word/endnotes.xml` and collect +at the end of the document or section rather than at the foot of the page. + +```python +paragraph = document.add_paragraph("The engine was never built") +endnote = document.endnotes.add_endnote("Babbage, 1837.") +paragraph.runs[-1].add_endnote_reference(endnote) +``` + +[`Document.endnotes`][docx.document.Document.endnotes] supports `len()`, iteration and +lookup by id, exactly as `footnotes` does, and an +[`Endnote`][docx.footnotes.Endnote] has `.text`, `.endnote_id` and `.add_paragraph()`: + +```python +for endnote in document.endnotes: + print(endnote.endnote_id, endnote.text) + +document.endnotes.get(1) # -> the Endnote, or None +``` + +`word/endnotes.xml` is created on demand the first time you add one, as the footnotes part +is, so a document that has no endnotes carries no endnotes part. + +!!! note + + As in the footnotes part, ids `-1` and `0` are taken by the separator and continuation + separator — the rules drawn above endnote text — so the first real endnote is id `1`. + Iterating skips the separators, so what you get is the endnotes a reader would count. + +A document can carry both kinds at once — they are independent sequences, numbered +separately, and Word renders footnotes in Arabic numerals and endnotes in lower-case Roman +by default. + +[`replace_text()`][docx.document.Document.replace_text] reaches endnotes under +`footnotes=True`, which covers both note stories: + +```python +document.replace_text("Babbage", "Charles Babbage", footnotes=True) +``` diff --git a/docs/user/images.md b/docs/user/images.md index b2f901c79..80713c4f6 100644 --- a/docs/user/images.md +++ b/docs/user/images.md @@ -111,3 +111,87 @@ Without `svg_fallback` the image is embedded but may not display. The format is detected from the file's own header rather than its extension, so a mislabelled file still works. An unrecognised one raises [`UnrecognizedImageError`][docx.image.exceptions.UnrecognizedImageError]. + +## Photos from a phone: EXIF orientation + +A camera does not rotate the pixels when you turn it sideways. It stores the sensor's +landscape frame and writes an EXIF `Orientation` tag saying which way up it goes. A +portrait photo off a phone is therefore a *landscape* JPEG with a tag on it, and inserting +it naively puts it in the document on its side — and, if you gave only a width, at the +wrong aspect ratio too, because the height was derived from the stored dimensions. + +[`add_picture()`][docx.text.run.Run.add_picture] and +[`add_float_picture()`][docx.text.run.Run.add_float_picture] honour the tag: + +```python +shape = document.add_picture("photo-from-phone.jpg", width=Inches(2)) + +shape.width # -> Inches(2) +shape.height # -> Inches(4), from the displayed 2:4 shape, not the stored 4:2 +``` + +The rotation goes into the DrawingML — `a:xfrm/@rot`, in sixtieths of a degree — and never +into the pixels. The image part stays byte-identical to the file on disk, so the sha1 +deduplication keeps working and the same photo inserted twice is still one part. + +Pass `honor_exif_orientation=False` to insert the stored frame unrotated. + +[`Image`][docx.image.image.Image] reports both sets of dimensions, and the distinction is +the whole point: + +```python +from docx.image.image import Image + +image = Image.from_file("photo-from-phone.jpg") + +image.orientation # -> 6, "rotate 90° clockwise to display" +image.is_rotated # -> True, this orientation exchanges width and height + +image.px_width # -> 4032, the frame as stored +image.px_display_width # -> 3024, the frame as a viewer shows it + +image.width # -> the stored width as a Length +image.display_width # -> the displayed width as a Length +``` + +`px_width` and `px_height` keep meaning what they always meant — the stored dimensions — +so nothing that read them changed behaviour. The `display_*` accessors are the new ones. +[`scaled_dimensions()`][docx.image.image.Image.scaled_dimensions] scales from the display +aspect ratio by default and takes the same `honor_exif_orientation=False`. + +An image whose format cannot carry the tag, or which does not set it, reports +`orientation == 1`, for which everything above is a no-op. + +## Reading the images already in a document + +The counterpart of `add_picture()`. Each shape hands back the image it displays: + +```python +for shape in document.inline_shapes: + image = shape.image + if image is None: + continue # -- a chart, a diagram, a linked picture + print(image.filename, image.content_type, image.px_width, image.px_height) + Path(image.filename).write_bytes(image.blob) +``` + +[`InlineShape.image`][docx.shape.InlineShape.image] and +[`FloatingShape.image`][docx.shape.FloatingShape.image] are `None` rather than an error +when there is no image to give — a chart, a SmartArt diagram, a picture linked to a file +on disk rather than embedded, or a relationship the document does not resolve. + +For an SVG picture, `.image` is the raster fallback Word displays and +[`.svg_image`][docx.shape.InlineShape.svg_image] the vector source: + +```python +shape.image.content_type # -> "image/png", the fallback +shape.svg_image.content_type # -> "image/svg+xml", the original +``` + +[`Document.images`][docx.document.Document.images] is the package-level view — the distinct +images the body embeds, deduplicated, with no shape needed to reach them: + +```python +for image in document.images: + print(image.filename, len(image.blob)) +``` diff --git a/docs/user/numbering.md b/docs/user/numbering.md index ebc8d3242..1b7fa9a7d 100644 --- a/docs/user/numbering.md +++ b/docs/user/numbering.md @@ -147,3 +147,81 @@ definition directly, when you want the new list without repointing any paragraph ```python restarted = document.numbering.restart(item.numbering.num_id, ilvl=0, start=1) ``` + +## Defining a list from scratch + +Everything above joins or restarts a list the numbering part already defines. When the +format you want is not in the template — Roman numerals at the top level, a bullet +character of your own, a particular indent step — define one: + +```python +definition = document.numbering.add_numbered_definition() +document.add_paragraph("one").set_numbering(definition.num_id) +``` + +[`add_numbered_definition()`][docx.numbering.Numbering.add_numbered_definition] and +[`add_bulleted_definition()`][docx.numbering.Numbering.add_bulleted_definition] are the +two common cases. Both take a `depth` (nine levels by default, which is what Word writes) +and an `indent_step`: + +```python +from docx.enum.numbering import WD_NUMBER_FORMAT +from docx.shared import Inches + +legal = document.numbering.add_numbered_definition( + depth=3, + formats=[ + WD_NUMBER_FORMAT.UPPER_ROMAN, + WD_NUMBER_FORMAT.UPPER_LETTER, + WD_NUMBER_FORMAT.DECIMAL, + ], + indent_step=Inches(0.3), +) + +bullets = document.numbering.add_bulleted_definition(bullets=["—", "·"]) +``` + +[`add_definition()`][docx.numbering.Numbering.add_definition] is the general form, taking +a level specification directly. Both shorthands are built on it: + +```python +definition = document.numbering.add_definition([ + {"number_format": WD_NUMBER_FORMAT.DECIMAL, "level_text": "%1.", "start": 1}, + {"number_format": WD_NUMBER_FORMAT.LOWER_LETTER, "level_text": "%2)"}, +]) +``` + +In a `level_text`, `%1` interpolates the counter of level 0, `%2` that of level 1, and so +on — `"%1.%2."` is what produces "2.3.". A definition has at most nine levels; more raises +`ValueError`. + +Each returns a [`NumberingDefinition`][docx.numbering.NumberingDefinition], whose `num_id` +is what [`set_numbering()`][docx.text.paragraph.Paragraph.set_numbering] takes. + +!!! note + + `w:nsid` and `w:tmpl` are deliberately not written. They are the identifiers Word uses + to recognise a definition as one of its own and to match it against a gallery entry; + inventing values would make Word treat unrelated lists as the same list, and they are + optional. + +## Changing a level + +[`NumberingLevel.set()`][docx.numbering.NumberingLevel.set] changes an existing level in +place, and returns the level so calls chain: + +```python +definition.level(0).set( + number_format=WD_NUMBER_FORMAT.UPPER_ROMAN, + level_text="%1.", + suffix="tab", + alignment="left", + indent=Inches(0.5), + hanging_indent=Inches(0.25), + start=1, +) +``` + +`suffix` is what follows the number — `"tab"`, `"space"` or `"nothing"` — and +`restart_after_level` is what makes a sub-list start over when the level above it advances. +Every argument is keyword-only and optional; the ones you leave out are left alone. diff --git a/docs/user/sections.md b/docs/user/sections.md index 169599d61..7fbf85bea 100644 --- a/docs/user/sections.md +++ b/docs/user/sections.md @@ -115,3 +115,55 @@ from docx.enum.section import WD_SECTION two_up = document.add_section(WD_SECTION.CONTINUOUS) two_up.column_count = 2 ``` + +## Page borders + +A page border is a section property, spelled the same way as the table, cell and +paragraph borders: + +```python +from docx.enum.table import WD_LINE_STYLE +from docx.shared import Pt, RGBColor + +borders = document.sections[0].page_borders + +for edge in ("top", "bottom", "left", "right"): + borders[edge].line = WD_LINE_STYLE.SINGLE + borders[edge].size = Pt(1) + borders[edge].color = RGBColor(0x33, 0x33, 0x33) +``` + +Each edge exposes `line`, `size`, `color` and `space`, and `borders.clear()` removes the +lot. Three settings apply to the frame as a whole rather than to one edge: + +```python +borders.display = "firstPage" # -- which pages get one +borders.offset_from = "text" # -- measured from the text, not the page edge +borders.z_order = "back" # -- drawn behind the page content +``` + +| Property | Values | Word's default when absent | +| --- | --- | --- | +| `display` | `"allPages"`, `"firstPage"`, `"notFirstPage"` | all pages | +| `offset_from` | `"page"`, `"text"` | page | +| `z_order` | `"front"`, `"back"` | front | + +These three take the attribute value itself rather than an enumeration member, because +each is a two- or three-value set with no `WdEnumeration` counterpart to mirror. A value +outside the set raises `ValueError` rather than writing a document Word will reject. + +`offset_from` is the one that catches people out. Measured from the page edge — the +default — the `space` on each edge is the distance in from the paper; measured from the +text it is the distance out from the text block, which is what Word's "Measure from: Text" +setting does and what a border that has to clear a header needs. + +## Right-to-left and vertical text + +[`Section.bidi`][docx.section.Section.bidi] sets the default base direction for the +section, and [`Section.text_direction`][docx.section.Section.text_direction] the default +flow direction. Both are defaults a paragraph or a cell can override — see +[Right-to-left and vertical text](text.md#right-to-left-and-vertical-text). + +```python +section.bidi = True +``` diff --git a/docs/user/shapes.md b/docs/user/shapes.md index 931f6bbb6..b0edad2b0 100644 --- a/docs/user/shapes.md +++ b/docs/user/shapes.md @@ -9,3 +9,11 @@ Inline shapes are treated like a big text character (a *character glyph*). The l Both kinds are supported. [`Document.add_picture()`][docx.document.Document.add_picture] adds an inline picture at the end of the document in a paragraph of its own, and [`Run.add_picture()`][docx.text.run.Run.add_picture] places one mid-paragraph, so you can have text on either side of it or both. For a picture in the drawing layer — one the text flows around rather than sitting on a line — see [`Run.add_float_picture()`][docx.text.run.Run.add_float_picture] and [Floating images](images.md). + +## Reading the picture back + +A shape hands back the image it displays through +[`InlineShape.image`][docx.shape.InlineShape.image] and +[`FloatingShape.image`][docx.shape.FloatingShape.image], which is `None` for a shape that +is not a picture — a chart or a SmartArt diagram. See +[Reading the images already in a document](images.md#reading-the-images-already-in-a-document). diff --git a/docs/user/styles-using.md b/docs/user/styles-using.md index a2eeccfbf..5055742cb 100644 --- a/docs/user/styles-using.md +++ b/docs/user/styles-using.md @@ -112,6 +112,37 @@ A style can be removed from the document simply by calling its [delete][docx.sty The `Style.delete` method removes the style's definition from the document. It does not affect content in the document to which that style is applied. Content having a style not defined in the document is rendered using the default style for that content object, e.g. 'Normal' in the case of a paragraph. +## Document defaults + +Below every style in the inheritance chain sits `w:docDefaults`, the formatting that +applies to content no style has spoken for. For many documents in the wild it is the only +place the base font is set, so a style walk that stops at `Normal` reads the wrong answer: + +```python +document.styles.default_font.name # -> the document-wide default typeface +document.styles.default_font.size + +document.styles.default_paragraph_format.space_after +document.styles.default_paragraph_format.line_spacing +``` + +[`Styles.default_font`][docx.styles.styles.Styles.default_font] is a +[`Font`][docx.text.font.Font] and +[`Styles.default_paragraph_format`][docx.styles.styles.Styles.default_paragraph_format] a +[`ParagraphFormat`][docx.text.parfmt.ParagraphFormat], so everything documented for those +works here. Both are writable, and setting a document default is the broadest change you +can make to a document's appearance in one statement: + +```python +from docx.shared import Pt + +document.styles.default_font.name = "Calibri" +document.styles.default_font.size = Pt(11) +``` + +The inheritance order, most specific first: direct formatting on a run, then its +character style, then the paragraph style, then `w:docDefaults`. + ## Define character formatting Character, paragraph, and table styles can all specify character formatting to be applied to content with that style. All the character formatting that can be applied directly to text can be specified in a style. Examples include font typeface and size, bold, italic, and underline. @@ -311,3 +342,37 @@ A latent style definition can be deleted by calling its `delete` method: >>> latent_styles['Light Grid'] KeyError: no latent style with name 'Light Grid' ``` + +## Which styles are in use + +```python +document.styles.usage() # -> a full StyleUsage report +document.styles.unused # -> the styles nothing reaches +document.styles["Quote"].in_use # -> True or False +``` + +"Used" is a reachability closure over every story part in the document, not a scan of the +body — a style that is only ever the `basedOn` of another one is used. See +[Style usage and cleanup](cleanup.md), which also covers +[`Styles.remove_unused()`][docx.styles.styles.Styles.remove_unused] and +[`Document.cleanup()`][docx.document.Document.cleanup]. + +## Moving styles between documents + +[`Styles.copy_style_from()`][docx.styles.styles.Styles.copy_style_from] copies one style +with its dependency closure. Three operations build on it, for whole sets of styles at a +time: + +```python +# -- pull a house template's styles into a generated document -- +report = document.styles.import_from("house-style.dotx") + +# -- push a document's styles out into a template of their own -- +document.styles.extract("house.dotx", as_template=True) + +# -- or just the styles.xml bytes -- +xml = document.styles.extract_xml(["Heading 1", "Heading 2"]) +``` + +See [Templates and embedded files](templates.md#importing-a-templates-styles) for what +each of them does with a name collision and what `report` contains. diff --git a/docs/user/tables.md b/docs/user/tables.md index 845121f12..57104f358 100644 --- a/docs/user/tables.md +++ b/docs/user/tables.md @@ -174,6 +174,105 @@ Each edge exposes `line` (a [`WD_LINE_STYLE`][docx.enum.table.WD_LINE_STYLE] mem [`RGBColor`][docx.shared.RGBColor], not a hex string) and `space`. Setting `line` to `WD_LINE_STYLE.NONE` removes the border. +## Table width, indent and cell margins + +[`Table.width`][docx.table.Table.width] is the table's *preferred* width — Word treats it +as a request and may narrow the table to fit its container. It takes either a +[`Length`][docx.shared.Length] or a percentage: + +```python +from docx.shared import Inches, Pct + +table.width = Inches(4) +table.width = Pct(50) # -> half the container width +table.width = None # -> auto-fit, which is the default +``` + +[`Pct`][docx.shared.Pct] is deliberately *not* a `Length`. Every `Length` unit is +absolute and reduces to EMU; a percentage does not, and cannot be converted to one +without knowing what it is a percentage of. Reading `width` back gives whichever of the +two the table actually carries. + +[`Table.indent`][docx.table.Table.indent] moves the whole table in from the margin, and +[`Table.cell_margins`][docx.table.Table.cell_margins] sets the default padding inside +every cell: + +```python +table.indent = Inches(0.5) + +table.cell_margins.left = Inches(0.1) +table.cell_margins.top = Inches(0.05) +table.cell_margins.clear() # -> back to inherited +``` + +The mapping admits `top`, `bottom`, `left`, `right` and the direction-relative `start` +and `end`. These are the table-wide defaults; a cell's own `w:tcMar` overrides them where +it has one. + +## Which parts of a table style apply + +A table style can define different formatting for the first row, the last row, the first +and last columns, and alternating bands. Which of those *apply* is not part of the style — +it is a set of flags on the table, the ones Word shows as the "Table Style Options" +checkboxes: + +```python +table.look.first_row = True # -- header row formatting on +table.look.horizontal_banding = True # -- alternating row shading on +table.look.last_column = False +``` + +[`Table.look`][docx.table.Table.look] exposes `first_row`, `last_row`, `first_column`, +`last_column`, `horizontal_banding` and `vertical_banding`. Each is `True` or `False` — +never `None`, because `w:tblLook` has a defined default for each flag rather than an +inherited one. + +!!! note + + `w:tblLook` carries both modern per-flag attributes and a legacy `@w:val` bitmask, + and older versions of Word read the bitmask. Setting a flag rewrites both, as Word + does, so the table looks the same wherever it is opened. + +## Row properties + +```python +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.shared import Inches + +row = table.rows[0] + +row.repeat_as_header = True # -- "Repeat Header Rows" +row.hidden = False +row.alignment = WD_TABLE_ALIGNMENT.CENTER +row.cell_spacing = Inches(0.02) +row.width_before = Inches(0.5) +row.width_after = Inches(0.5) +``` + +[`repeat_as_header`][docx.table._Row.repeat_as_header] is the useful one: it is what makes +a header row reappear at the top of every page a long table spans. All of these are +tri-state where the XML is — `None` means the value is inherited — and +[`_Row.height`][docx.table._Row.height], `height_rule` and +[`_Row.dont_split`][docx.table._Row.dont_split] round out `w:trPr`. + +`width_before` and `width_after` are the widths of the grid positions a row leaves +unpopulated, the companions to the `grid_cols_before` and `grid_cols_after` counts +described above. + +## Text direction in a cell + +[`_Cell.text_direction`][docx.table._Cell.text_direction] rotates the text in a cell, +which is how a narrow column gets a readable heading: + +```python +from docx.enum.text import WD_TEXT_DIRECTION + +table.cell(0, 1).text_direction = WD_TEXT_DIRECTION.BT_LR # -- bottom-to-top +``` + +See [Right-to-left and vertical text](text.md#right-to-left-and-vertical-text) for the +full set of [`WD_TEXT_DIRECTION`][docx.enum.text.WD_TEXT_DIRECTION] values. + ## Alternative text A table carries the same two alt-text values Word's "Alt Text" pane writes for a @@ -191,3 +290,28 @@ Both are read/write on [`Table`][docx.table.Table] and both are `None` when unse assigning `None` removes them. They are stored as `w:tblCaption` and `w:tblDescription` and are never rendered — this is metadata read by assistive technology, not a visible caption above or below the table. + +They can also be given when the table is created, which saves the round trip and matches +the way [`add_picture()`][docx.text.run.Run.add_picture] takes them: + +```python +table = document.add_table( + rows=2, + cols=2, + style="Light Grid Accent 1", + title="Quarterly revenue", + description="Revenue by region for Q1 through Q4 2026, in thousands of euro.", +) +``` + +Both are keyword-only, both default to `None`, and omitting them writes nothing. +[`_Cell.add_table()`][docx.table._Cell.add_table] and +[`BlockItemContainer.add_table()`][docx.blkcntnr.BlockItemContainer.add_table] take them +too. + +## Captions + +[`_Cell.add_caption()`][docx.table._Cell.add_caption] puts a self-renumbering caption +inside a cell. For a caption above or below a table, use +[`Document.add_caption()`][docx.document.Document.add_caption] — see +[Captions](fields.md#captions). diff --git a/docs/user/templates.md b/docs/user/templates.md index a6c0c43bf..3e358c239 100644 --- a/docs/user/templates.md +++ b/docs/user/templates.md @@ -1,4 +1,4 @@ -# Templates, styles across documents, and embedded files +# Templates, styles across documents, embedded files and macros ## Word templates @@ -83,6 +83,153 @@ report.styles.copy_style_from(callout, on_collision="overwrite") against *this* document's theme part, so a copied style can legitimately look different in its new home. +## Importing a template's styles + +`copy_style_from()` moves one style. Two operations built on it move whole sets, which is +what a house template usually calls for: + +```python +report = Document() + +result = report.styles.import_from("house-style.dotx") +# -> {"Callout": "added", "Heading 1": "skipped", ...} +``` + +[`Styles.import_from()`][docx.styles.styles.Styles.import_from] accepts a path, a stream or +an already-open `Document` — a `.dotx` opens without special handling, since a template +holds the same main part as a document. It returns a report keyed by UI name, saying what +it did with each: `"added"`, `"replaced"` or `"skipped"`. + +```python +report.styles.import_from("house-style.dotx", ["Callout", "Sidebar"]) +report.styles.import_from("house-style.dotx", overwrite=True) +report.styles.import_from("house-style.dotx", include_latent=True) +``` + +Without `overwrite`, a name the destination already defines is skipped and reported as +such — the existing definition wins, which is what "import these styles into my document" +almost always means. `include_latent` also brings the source's `w:latentStyles` +exceptions across, and is off by default because that changes which of Word's built-ins +appear in the destination's style gallery. + +The other direction pulls styles *out* of a document into a template of their own: + +```python +added = report.styles.extract("house.dotx", as_template=True) +added = report.styles.extract("headings.docx", ["Heading 1", "Heading 2"]) + +xml = report.styles.extract_xml(["Heading 1"]) # -> just the styles.xml bytes +``` + +[`Styles.extract()`][docx.styles.styles.Styles.extract] writes an otherwise empty document +carrying the named styles and their dependency closure, and returns the names it added. +Naming nothing extracts every style the document defines. The extract starts from a +template whose own unused styles have been pruned, so what comes out is the styles you +named plus the handful the closure keeps alive — `Normal`, `Default Paragraph Font` and the +other defaults — rather than those plus the 168 the bundled template ships. + +`as_template=True` writes a `.dotx` — the same content with the template content type, so +Word treats it as a template rather than a document. + +## Embedded OLE objects + +An OLE object is a whole file carried *inside* the document and shown as an icon or a +preview image that opens the original application on double-click. This is a different +thing from an `altChunk`: an `altChunk` is dissolved into the document when Word opens the +file, whereas an embedded object stays a distinct file forever. + +The read side matters on its own. A document with attachments embedded in it previously +gave no way to discover that they exist, let alone get them out: + +```python +from pathlib import Path + +for obj in document.embedded_objects: + print(obj.prog_id, obj.content_type, obj.filename) + if obj.blob is not None: + Path(obj.filename or "attachment").write_bytes(obj.blob) +``` + +[`Document.embedded_objects`][docx.document.Document.embedded_objects] covers the body and +[`Run.embedded_objects`][docx.text.run.Run.embedded_objects] one run. Each +[`EmbeddedObject`][docx.object.EmbeddedObject] offers: + +| | | +| --- | --- | +| `prog_id` | the application Word launches, e.g. `"Excel.Sheet.12"` | +| `blob` | the bytes of the embedded file, or `None` | +| `content_type` | the content type of the embedded part | +| `filename` | the basename of the part it landed in, e.g. `"oleObject1.bin"` | +| `is_linked` | `True` when the object *links* to an external file instead of embedding it, in which case there are no bytes in the package | +| `shows_icon` | `True` when Word shows an icon rather than a preview | +| `image` | the icon or preview image Word displays | +| `embedded_part` | the package part itself | + +!!! note + + OOXML does not record the original file name of an embedded object. `filename` is the + name of the part it was stored in, which is what a caller extracting it has to work + with. + +Writing one takes an icon, and the icon is required: + +```python +run = document.add_paragraph().add_run() +run.add_embedded_object( + "budget.xlsx", + icon="excel-icon.png", + prog_id="Excel.Sheet.12", +) +``` + +Word cannot render the embedded file itself, so without an image there is nothing to draw +where the object sits. `prog_id` defaults to `"Package"`, the generic value Word uses for a +file it has no better name for — an object whose `ProgID` names no installed application is +one Word displays but cannot open, so pass the right one when you know it. `width` and +`height` default to the icon's own size. + +The visual is VML, not DrawingML, because that is what Word writes for an OLE object. + +## Macros + +A macro-enabled document keeps its VBA project as a single opaque blob, +`word/vbaProject.bin`: + +```python +document = Document("macros.docm") + +document.has_macros # -> True +len(document.vba_project) # -> the project bytes +``` + +Reading, transplanting and stripping one are all expressible: + +```python +# -- move a project into a generated document -- +generated = Document() +generated.vba_project = Document("macros.docm").vba_project +generated.save("generated.docm") + +# -- strip the macros out of a document you received -- +received = Document("received.docm") +del received.vba_project # -- or: received.vba_project = None +received.save("safe.docx") + +received.remove_vba_project() # -> how many were removed, 0 or 1 +``` + +Assigning or removing a project **switches the main part's content type with it**, which is +the part that is easy to get wrong by hand: Word ignores macros in a document that does not +claim to be macro-enabled, and warns about macros in a document that claims to be +macro-enabled but is not. A template switches to the macro-enabled *template* type rather +than the document one. + +!!! note + + The blob is not parsed. The project is an OLE compound file with compressed module + streams inside it; reading the source of a macro is a separate matter and is not + supported. + ## Embedding another document An `altChunk` embeds a whole file — HTML, RTF, another `.docx` — and lets Word import it diff --git a/docs/user/text.md b/docs/user/text.md index 871653b02..d947572ad 100644 --- a/docs/user/text.md +++ b/docs/user/text.md @@ -80,6 +80,28 @@ None >>> paragraph_format.first_line_indent.inches -0.25 ``` +### Indentation in character units + +Word can express an indent as a number of characters rather than an absolute distance, +which is what East Asian typesetting conventions expect and what Word's own dialogue +offers when the document language calls for it. Those values are a separate set of +properties, because they are a different unit and the two cannot both apply: + +```pycon +>>> paragraph_format.first_line_indent_chars = 200 # -> two characters +>>> paragraph_format.left_indent_chars = 100 # -> one character +>>> paragraph_format.right_indent_chars +None +``` + +Values are in **hundredths of a character**, matching the XML — `200` is two characters. +Setting a character-unit property clears its twips counterpart and vice versa, so the two +can never disagree about the same edge. + +The absolute properties also read the `w:start` and `w:end` spellings Word writes in +recent files, not only the older `w:left` and `w:right`, so a document produced by a +current version of Word reports the indents it actually has. + ### Tab stops A tab stop determines the rendering of a tab character in the text of a paragraph. In particular, it specifies the position where the text following the tab character will start, how it will be aligned to that position, and an optional leader character that will fill the horizontal space spanned by the tab. @@ -161,6 +183,65 @@ EXACTLY (4) >>> paragraph_format.line_spacing_rule MULTIPLE (5) ``` +### Spacing in line units + +As with indentation, Word can express paragraph spacing as a number of lines rather than +an absolute distance: + +```pycon +>>> paragraph_format.space_before_lines = 100 # -> one line +>>> paragraph_format.space_after_lines = 50 # -> half a line +``` + +Values are in **hundredths of a line**. These are `w:spacing/@w:beforeLines` and +`@w:afterLines`, and Word applies them in preference to the absolute values when both are +present — so as with the character units, setting one clears the other. + +### Paragraph borders + +A paragraph's borders are spelled the same way as a table's or a cell's — a mapping keyed +by edge name: + +```python +from docx.enum.table import WD_LINE_STYLE +from docx.shared import Pt, RGBColor + +paragraph_format.borders["bottom"].line = WD_LINE_STYLE.SINGLE +paragraph_format.borders["bottom"].size = Pt(1) +paragraph_format.borders["bottom"].color = RGBColor(0x99, 0x99, 0x99) +``` + +A paragraph with only a bottom border is how Word draws a horizontal rule, which is the +common reason to want this. + +The edges are `top`, `bottom`, `left`, `right`, plus two a table does not have: + +- `between` — drawn between *consecutive* paragraphs that carry the same border setting, + not around each one. +- `bar` — a vertical line at the outer edge, used to mark changed text. + +Each edge exposes `line`, `size`, `color` and `space`, and setting `line` to +`WD_LINE_STYLE.NONE` removes the border. For borders around a whole page, see +[Page borders](sections.md#page-borders). + +### The paragraph mark + +A paragraph ends with a mark — the ¶ Word shows with formatting marks turned on — and that +mark has run properties of its own, stored in `w:pPr/w:rPr`. They are what an *empty* +paragraph is formatted with, because there is no run in it to carry formatting: + +```python +paragraph = document.add_paragraph() # -- empty +paragraph.paragraph_format.mark_font.size = Pt(4) +``` + +That is the way to make a blank spacer paragraph small, and it is the only place the +formatting of an empty paragraph lives. +[`mark_font`][docx.text.parfmt.ParagraphFormat.mark_font] is a full +[`Font`][docx.text.font.Font], so everything in +[Apply character formatting](#apply-character-formatting) applies to it. It also affects +the mark of a *non*-empty paragraph, which is what decides how tall the last line is. + ### Pagination properties Four paragraph properties, [keep_together][docx.text.parfmt.ParagraphFormat.keep_together], [keep_with_next][docx.text.parfmt.ParagraphFormat.keep_with_next], [page_break_before][docx.text.parfmt.ParagraphFormat.page_break_before], and [widow_control][docx.text.parfmt.ParagraphFormat.widow_control] control aspects of how the paragraph behaves near page boundaries. @@ -350,3 +431,131 @@ Setting `shading_pattern` to `None` removes the shading entirely, as does settin reading `w:shd`. It no longer does: it is strictly a [`WD_COLOR_INDEX`][docx.enum.text.WD_COLOR_INDEX] member — Word's highlighter pen, which has a fixed palette — and `shading_fill` is the arbitrary-colour fill. + +## Theme fonts + +A run's typeface can be set to a *theme token* rather than a font name — `minorHAnsi` for +body text, `majorHAnsi` for headings — in which case the concrete font comes from the +document's theme. [`Font.name`][docx.text.font.Font.name] reports `None` for such a run, +because there is no font name in the run to report: + +```python +run.font.theme = "minorHAnsi" + +run.font.name # -> None +run.font.theme # -> "minorHAnsi" +run.font.theme_typeface # -> "Cambria" +``` + +[`Font.theme_typeface`][docx.text.font.Font.theme_typeface] resolves the token through +the theme part, and for a document whose fonts come only from its theme this is the only +way to find out what the text is actually rendered in. It is `None` when the run carries +no token, when the theme leaves that slot empty, or when the `Font` was built over a bare +element with no part behind it. + +The theme itself is [`Document.theme`][docx.document.Document.theme]: + +```python +theme = document.theme + +theme.name # -> "Office Theme" +theme.minor_font.latin # -> "Cambria" +theme.major_font.latin # -> "Calibri" +theme.minor_font.east_asian # -> None, the default theme leaves it empty +theme.minor_font.complex_script # -> None + +theme.typeface("minorHAnsi") # -> "Cambria" +``` + +The twelve theme colours are there too, keyed by the slot names as they appear in the XML +— `dk1`, `lt1`, `dk2`, `lt2`, `accent1` through `accent6`, `hlink` and `folHlink`: + +```python +theme.color("accent1") # -> RGBColor(0x4F, 0x81, 0xBD) +theme.colors # -> the twelve, as a dict, in schema order +``` + +A slot name that does not exist raises `ValueError`; a slot the theme omits gives `None`. +A system colour such as `dk1` reports the value the producing application last resolved it +to, which is the only concrete value available off that operating system. + +!!! note + + [`Document.theme`][docx.document.Document.theme] is `None` for a document with no + theme part, and — unlike the styles and settings parts — one is never created on + demand. A theme is a design the document was authored against; an empty one + synthesised on the spot would answer the typeface question with a fiction. + +## Right-to-left and vertical text + +Two separate things, often confused: + +**Base direction** is whether a paragraph reads right-to-left. It decides where the first +character goes, which way punctuation faces, and which edge `start` and `end` mean: + +```python +paragraph.paragraph_format.bidi = True +``` + +**Flow direction** is which way the lines themselves run, and it rotates the text: + +```python +from docx.enum.text import WD_TEXT_DIRECTION + +paragraph.paragraph_format.text_direction = WD_TEXT_DIRECTION.TB_RL +``` + +| [`WD_TEXT_DIRECTION`][docx.enum.text.WD_TEXT_DIRECTION] | Flow | +| --- | --- | +| `LR_TB` | left to right, then top to bottom — the default | +| `TB_RL` | top to bottom, then right to left — rotates the text 90° clockwise | +| `BT_LR` | bottom to top, then left to right — rotates it 90° anticlockwise | +| `LR_TB_V` | left to right, then top to bottom, rotating each East Asian character | +| `TB_RL_V` | top to bottom, then right to left, with each character upright | +| `TB_LR_V` | top to bottom, then left to right, Mongolian vertical layout | + +Both exist at three levels, and the more specific wins: + +```python +document.sections[0].bidi = True # -- the section default +document.sections[0].text_direction = WD_TEXT_DIRECTION.TB_RL +paragraph.paragraph_format.text_direction = ... # -- one paragraph +table.cell(0, 0).text_direction = ... # -- one cell +``` + +All of them are `None` when the value is inherited. + +## Equations + +An equation is OMML — `m:oMath` — a markup language of its own that shares nothing with +WordprocessingML but the file it lives in. Reading is supported; there is no builder. + +```python +for equation in document.math: + print(equation.text, equation.is_display) +``` + +[`Document.math`][docx.document.Document.math], +[`BlockItemContainer.math`][docx.blkcntnr.BlockItemContainer.math] and +[`Paragraph.math`][docx.text.paragraph.Paragraph.math] each return the +[`Math`][docx.math.Math] objects in document order: + +- `.text` — the characters of the equation, in reading order, with the structure flattened + away. A fraction reads as its numerator then its denominator; there is no LaTeX here. +- `.xml` — the OMML itself, which is what to use if you need the structure. +- `.is_display` — `True` for a display equation in a `m:oMathPara` of its own, `False` for + one inline in a sentence. + +!!! note + + **Equation text is deliberately not part of + [`Paragraph.text`][docx.text.paragraph.Paragraph.text].** + + Including it would describe the document more truthfully. But + [`replace_text()`][docx.document.Document.replace_text] and the run-isolating + machinery under it measure offsets against `Paragraph.text` and can only cut at run + boundaries. Text they cannot reach would silently mis-target every replacement after + the first equation in a paragraph, and a wrong edit is worse than a missing character. + + Read `paragraph.math` when you want the equations, and `paragraph.text` when you want + what is safely editable. diff --git a/mkdocs.yml b/mkdocs.yml index c9a622024..177b88c0e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -127,15 +127,18 @@ nav: - Working with styles: user/styles-using.md - How-to: - Finding and replacing text: user/search-replace.md + - Copying content: user/copying.md - Numbered and bulleted lists: user/numbering.md - - Fields and tables of contents: user/fields.md + - Fields, captions and tables of contents: user/fields.md - Bookmarks: user/bookmarks.md - Tracked changes: user/revisions.md - Images: user/images.md - Watermarks: user/watermarks.md - - Footnotes: user/footnotes.md + - Footnotes and endnotes: user/footnotes.md - Form fields and content controls: user/form-fields.md - Document properties: user/document-properties.md - - Templates and embedded files: user/templates.md + - Templates, embedded files and macros: user/templates.md + - Style usage and cleanup: user/cleanup.md + - Command line: user/cli.md - Migrating from 0.9.x: user/migrating-from-0-9.md - API reference: api/ diff --git a/navbar.png b/navbar.png deleted file mode 100644 index 202605156..000000000 Binary files a/navbar.png and /dev/null differ diff --git a/src/docx/__init__.py b/src/docx/__init__.py index d1197e0e5..9f2f2bb9d 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from docx.opc.part import Part -__version__ = "2.0.0" +__version__ = "2.1.0" __all__ = ["Document"] diff --git a/src/docx/cli.py b/src/docx/cli.py index efbb50692..deabb9a95 100644 --- a/src/docx/cli.py +++ b/src/docx/cli.py @@ -29,6 +29,7 @@ from typing import IO, Any, Dict, List, Sequence import docx +from docx.opc.exceptions import EncryptedPackageError, PackageNotFoundError #: Exit code for a document that cannot be opened. EXIT_CANNOT_OPEN = 2 @@ -54,9 +55,18 @@ def main(argv: Sequence[str] | None = None, stdout: IO[str] | None = None) -> in except FileNotFoundError as e: print("error: no such file: %s" % e.filename, file=sys.stderr) return EXIT_CANNOT_OPEN - except (zipfile.BadZipFile, ValueError, KeyError) as e: + except EncryptedPackageError: + print("error: document is password-protected", file=sys.stderr) + return EXIT_CANNOT_OPEN + except (PackageNotFoundError, zipfile.BadZipFile) as e: print("error: cannot open document: %s" % e, file=sys.stderr) return EXIT_CANNOT_OPEN + except KeyError as e: + # -- `styles extract --names` and `cleanup --keep` name styles, and a name the + # -- document does not define surfaces here rather than as a traceback -- + message = e.args[0] if e.args else str(e) + print("error: %s" % message, file=sys.stderr) + return EXIT_CANNOT_OPEN def _build_parser() -> argparse.ArgumentParser: @@ -91,8 +101,10 @@ def _build_parser() -> argparse.ArgumentParser: listing = styles_sub.add_parser("list", help="list style names") listing.add_argument("path") - listing.add_argument("--unused", action="store_true", help="only the unused ones") - listing.add_argument("--used", action="store_true", help="only the ones in use") + # -- asking for both would silently print nothing, so argparse refuses the pair -- + which = listing.add_mutually_exclusive_group() + which.add_argument("--unused", action="store_true", help="only the unused ones") + which.add_argument("--used", action="store_true", help="only the ones in use") listing.add_argument("--json", action="store_true") listing.set_defaults(func=_cmd_styles_list) diff --git a/src/docx/copy.py b/src/docx/copy.py index 139c38746..2cfddaa04 100644 --- a/src/docx/copy.py +++ b/src/docx/copy.py @@ -114,6 +114,11 @@ def _reassign_drawing_ids(element: BaseOxmlElement, dest_part: StoryPart) -> Non The id must be unique document-wide, and a deep copy duplicates whatever the source had. Ids are allocated one at a time rather than in a batch because `next_id` reads the destination's XML, and the copy is not in it yet. + + `@name` is left alone. Only the id has to be unique, and the name is the shape's own + — the source file name for a picture, "Chart 1" for a chart — so overwriting it + would discard information and mislabel anything that is not a picture. A drawing + with no name at all gets one, since Word shows the field in its selection pane. """ docPrs = element.xpath(".//wp:docPr") if not docPrs: @@ -121,7 +126,8 @@ def _reassign_drawing_ids(element: BaseOxmlElement, dest_part: StoryPart) -> Non next_id = dest_part.next_id for offset, docPr in enumerate(docPrs): docPr.id = next_id + offset - docPr.name = "Picture %d" % (next_id + offset) + if not docPr.name: + docPr.name = "Picture %d" % (next_id + offset) def _strip_bookmarks(element: BaseOxmlElement) -> None: diff --git a/src/docx/document.py b/src/docx/document.py index 67a9cb971..cdf4b980e 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -59,12 +59,14 @@ def __init__(self, element: CT_Document, part: DocumentPart): self._part = part self.__body = None - def add_alt_chunk(self, chunk: bytes | str | IO[bytes], content_type: str) -> AltChunk: + def add_alt_chunk( + self, chunk: bytes | str | os.PathLike[str] | IO[bytes], content_type: str + ) -> AltChunk: """Return an |AltChunk| newly added at the end of the document body. - `chunk` is the embedded document, given as bytes, as a path to a file, or as a - file-like object open for binary read. `content_type` states its format, e.g. - `"text/html"`, `"application/rtf"` or + `chunk` is the embedded document, given as bytes, as a path to a file (a string + or ``os.PathLike``), or as a file-like object open for binary read. + `content_type` states its format, e.g. `"text/html"`, `"application/rtf"` or `"application/vnd.openxmlformats-officedocument.wordprocessingml.document"`; Word chooses an importer from it, so it must be right. @@ -209,12 +211,12 @@ def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = Non def add_picture( self, - image_path_or_stream: str | IO[bytes], + image_path_or_stream: str | os.PathLike[str] | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, description: str | None = None, title: str | None = None, - svg_fallback: str | IO[bytes] | None = None, + svg_fallback: str | os.PathLike[str] | IO[bytes] | None = None, honor_exif_orientation: bool = True, ): """Return new picture shape added in its own paragraph at end of the document. @@ -378,7 +380,11 @@ def footnotes(self) -> Footnotes: return self._part.footnotes def add_custom_xml_part( - self, xml: str | bytes, schema_refs: Tuple[str, ...] = () + self, + xml: str | bytes, + schema_refs: Tuple[str, ...] = (), + *, + item_id: str | None = None, ) -> CustomXmlPart: """Add an item to the custom XML data store and return its part. @@ -396,10 +402,16 @@ def add_custom_xml_part( of named scalars in `docProps/custom.xml`. A `customXml/itemN.xml` part is created for `xml`, along with the - `itemPropsN.xml` sidecar Word identifies it by, carrying a freshly generated - GUID and the namespaces named in `schema_refs`. + `itemPropsN.xml` sidecar Word identifies it by, carrying the namespaces named in + `schema_refs` and a GUID. + + `item_id` is that GUID, in Word's `"{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}"` + shape. One is generated at random when it is omitted, which is what Word does — + but a random value is the one thing in this library's output that is not a + function of its input, so pass an `item_id` of your own when byte-reproducible + output matters. It only has to be unique within the document. """ - return self._part.add_custom_xml_part(xml, schema_refs) + return self._part.add_custom_xml_part(xml, schema_refs, item_id=item_id) @property def custom_xml_parts(self) -> Tuple[CustomXmlPart, ...]: @@ -847,7 +859,7 @@ def add_text_watermark( def add_image_watermark( self, - image_path_or_stream: str | IO[bytes], + image_path_or_stream: str | os.PathLike[str] | IO[bytes], *, width: Length | int | None = None, height: Length | int | None = None, @@ -968,9 +980,9 @@ def _document_content_type(content_type: str, as_template: bool) -> str: return mapping.get(content_type, content_type) -def _read_blob(chunk: str | IO[bytes]) -> bytes: +def _read_blob(chunk: str | os.PathLike[str] | IO[bytes]) -> bytes: """The bytes of `chunk`, a path to a file or a file-like object open for read.""" - if isinstance(chunk, str): - with open(chunk, "rb") as f: + if isinstance(chunk, (str, os.PathLike)): + with open(os.fspath(chunk), "rb") as f: return f.read() return chunk.read() diff --git a/src/docx/image/image.py b/src/docx/image/image.py index 82996cb93..9451e2b47 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -52,11 +52,12 @@ def from_blob(cls, blob: bytes) -> Image: return cls._from_stream(stream, blob) @classmethod - def from_file(cls, image_descriptor: str | IO[bytes]): + def from_file(cls, image_descriptor: str | os.PathLike[str] | IO[bytes]): """Return a new |Image| subclass instance loaded from the image file identified - by `image_descriptor`, a path or file-like object.""" - if isinstance(image_descriptor, str): - path = image_descriptor + by `image_descriptor`, a path (a string or ``os.PathLike``) or file-like + object.""" + if isinstance(image_descriptor, (str, os.PathLike)): + path = os.fspath(image_descriptor) with open(path, "rb") as f: blob = f.read() stream = io.BytesIO(blob) diff --git a/src/docx/object.py b/src/docx/object.py index 54532080a..54676cc31 100644 --- a/src/docx/object.py +++ b/src/docx/object.py @@ -138,9 +138,9 @@ def image(self) -> Image | None: def add_embedded_object( run: object, - path_or_stream: str | IO[bytes], + path_or_stream: str | os.PathLike[str] | IO[bytes], *, - icon: str | IO[bytes], + icon: str | os.PathLike[str] | IO[bytes], prog_id: str | None = None, width: Length | None = None, height: Length | None = None, @@ -209,10 +209,10 @@ def _next_shape_ordinal(part: object) -> int: return max(used, default=0) + 1 -def _read_blob(path_or_stream: str | IO[bytes]) -> bytes: +def _read_blob(path_or_stream: str | os.PathLike[str] | IO[bytes]) -> bytes: """The bytes of `path_or_stream`, a path or a file-like object open for read.""" - if isinstance(path_or_stream, str): - with open(path_or_stream, "rb") as f: + if isinstance(path_or_stream, (str, os.PathLike)): + with open(os.fspath(path_or_stream), "rb") as f: return f.read() path_or_stream.seek(0) return path_or_stream.read() diff --git a/src/docx/package.py b/src/docx/package.py index 2a6c46692..199d41ae5 100644 --- a/src/docx/package.py +++ b/src/docx/package.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, cast from docx.image.image import Image @@ -22,7 +23,9 @@ def after_unmarshal(self): """ self._gather_image_parts() - def get_or_add_image_part(self, image_descriptor: str | IO[bytes]) -> ImagePart: + def get_or_add_image_part( + self, image_descriptor: str | os.PathLike[str] | IO[bytes] + ) -> ImagePart: """Return |ImagePart| containing image specified by `image_descriptor`. The image-part is newly created if a matching one is not already present in the @@ -69,7 +72,9 @@ def __len__(self): def append(self, item: ImagePart): self._image_parts.append(item) - def get_or_add_image_part(self, image_descriptor: str | IO[bytes]) -> ImagePart: + def get_or_add_image_part( + self, image_descriptor: str | os.PathLike[str] | IO[bytes] + ) -> ImagePart: """Return |ImagePart| object containing image identified by `image_descriptor`. The image-part is newly created if a matching one is not present in the diff --git a/src/docx/parts/altchunk.py b/src/docx/parts/altchunk.py index 7a6c82fb9..f51d7f36a 100644 --- a/src/docx/parts/altchunk.py +++ b/src/docx/parts/altchunk.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING from docx.opc.constants import CONTENT_TYPE as CT @@ -43,14 +44,18 @@ def new(cls, package: OpcPackage, blob: bytes, content_type: str) -> AltChunkPar @classmethod def new_from_stream( - cls, package: OpcPackage, chunk: str | IO[bytes], content_type: str + cls, + package: OpcPackage, + chunk: str | os.PathLike[str] | IO[bytes], + content_type: str, ) -> AltChunkPart: """An |AltChunkPart| newly created from `chunk` and added to `package`. - `chunk` is either a path to a file or a file-like object open for binary read. + `chunk` is either a path to a file (a string or ``os.PathLike``) or a file-like + object open for binary read. """ - if isinstance(chunk, str): - with open(chunk, "rb") as f: + if isinstance(chunk, (str, os.PathLike)): + with open(os.fspath(chunk), "rb") as f: blob = f.read() else: blob = chunk.read() diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 4b9a8b1fe..1060e4664 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -59,12 +59,17 @@ def add_alt_chunk_part(self, blob: bytes, content_type: str) -> str: return self.relate_to(alt_chunk_part, RT.A_F_CHUNK) def add_custom_xml_part( - self, xml: str | bytes, schema_refs: tuple[str, ...] = () + self, + xml: str | bytes, + schema_refs: tuple[str, ...] = (), + *, + item_id: str | None = None, ) -> CustomXmlPart: """Add a custom XML data store item holding `xml` and return its part. - Creates the `customXml/itemN.xml` part, its `itemPropsN.xml` sidecar carrying a - freshly generated item GUID, and both relationships. + Creates the `customXml/itemN.xml` part, its `itemPropsN.xml` sidecar carrying the + item GUID, and both relationships. `item_id` is that GUID; one is generated at + random when it is omitted. See :meth:`.Document.add_custom_xml_part`. """ package = self.package assert package is not None @@ -77,7 +82,10 @@ def add_custom_xml_part( item_part = CustomXmlPart.new(package, item_partname, parse_xml(blob)) props_part = CustomXmlPropertiesPart.new( - package, props_partname, "{%s}" % str(uuid.uuid4()).upper(), schema_refs + package, + props_partname, + item_id if item_id is not None else "{%s}" % str(uuid.uuid4()).upper(), + schema_refs, ) item_part.relate_to(props_part, RT.CUSTOM_XML_PROPS) self.relate_to(item_part, RT.CUSTOM_XML) diff --git a/src/docx/parts/story.py b/src/docx/parts/story.py index 72fbfe294..c40ddb33b 100644 --- a/src/docx/parts/story.py +++ b/src/docx/parts/story.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Tuple, cast from docx.image.constants import MIME_TYPE @@ -25,7 +26,9 @@ class StoryPart(XmlPart): `.add_paragraph()`, `.add_table()` etc. """ - def get_or_add_image(self, image_descriptor: str | IO[bytes]) -> Tuple[str, Image]: + def get_or_add_image( + self, image_descriptor: str | os.PathLike[str] | IO[bytes] + ) -> Tuple[str, Image]: """Return (rId, image) pair for image identified by `image_descriptor`. `rId` is the str key (often like "rId7") for the relationship between this story @@ -72,12 +75,12 @@ def next_bookmark_id(self) -> int: def new_pic_inline( self, - image_descriptor: str | IO[bytes], + image_descriptor: str | os.PathLike[str] | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, description: str | None = None, title: str | None = None, - svg_fallback: str | IO[bytes] | None = None, + svg_fallback: str | os.PathLike[str] | IO[bytes] | None = None, honor_exif_orientation: bool = True, ) -> CT_Inline: """Return a newly-created `w:inline` element. @@ -110,14 +113,14 @@ def new_pic_inline( def new_pic_anchor( self, - image_descriptor: str | IO[bytes], + image_descriptor: str | os.PathLike[str] | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, pos_x: Length | int = 0, pos_y: Length | int = 0, description: str | None = None, title: str | None = None, - svg_fallback: str | IO[bytes] | None = None, + svg_fallback: str | os.PathLike[str] | IO[bytes] | None = None, honor_exif_orientation: bool = True, ) -> CT_Anchor: """Return a newly-created `wp:anchor` element for a floating picture. @@ -145,7 +148,9 @@ def new_pic_anchor( ) def _image_rIds( - self, image_descriptor: str | IO[bytes], svg_fallback: str | IO[bytes] | None + self, + image_descriptor: str | os.PathLike[str] | IO[bytes], + svg_fallback: str | os.PathLike[str] | IO[bytes] | None, ) -> Tuple[str, Image, str | None]: """`(rId, image, svg_rId)` for `image_descriptor`. diff --git a/src/docx/section.py b/src/docx/section.py index c64ac88e3..9ec9a0c00 100644 --- a/src/docx/section.py +++ b/src/docx/section.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Iterator, List, Sequence, overload from docx.blkcntnr import BlockItemContainer @@ -340,7 +341,7 @@ def add_text_watermark( def add_image_watermark( self, - image_path_or_stream: str | IO[bytes], + image_path_or_stream: str | os.PathLike[str] | IO[bytes], *, width: Length | int | None = None, height: Length | int | None = None, diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 9c7bc36e3..e51442e27 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Iterator, List, cast from docx.drawing import Drawing @@ -68,12 +69,12 @@ def add_break(self, break_type: WD_BREAK = WD_BREAK.LINE): def add_picture( self, - image_path_or_stream: str | IO[bytes], + image_path_or_stream: str | os.PathLike[str] | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, description: str | None = None, title: str | None = None, - svg_fallback: str | IO[bytes] | None = None, + svg_fallback: str | os.PathLike[str] | IO[bytes] | None = None, honor_exif_orientation: bool = True, ) -> InlineShape: """Return |InlineShape| containing image identified by `image_path_or_stream`. @@ -127,7 +128,7 @@ def add_picture( def add_float_picture( self, - image_path_or_stream: str | IO[bytes], + image_path_or_stream: str | os.PathLike[str] | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, left: Length | int = 0, @@ -138,7 +139,7 @@ def add_float_picture( relative_from_v: WD_ANCHOR_RELATIVE_FROM_V = WD_ANCHOR_RELATIVE_FROM_V.PARAGRAPH, description: str | None = None, title: str | None = None, - svg_fallback: str | IO[bytes] | None = None, + svg_fallback: str | os.PathLike[str] | IO[bytes] | None = None, honor_exif_orientation: bool = True, ) -> FloatingShape: """Return a |FloatingShape| for a picture that text flows around. @@ -226,9 +227,9 @@ def bold(self, value: bool | None): def add_embedded_object( self, - path_or_stream: str | IO[bytes], + path_or_stream: str | os.PathLike[str] | IO[bytes], *, - icon: str | IO[bytes], + icon: str | os.PathLike[str] | IO[bytes], prog_id: str | None = None, width: Length | None = None, height: Length | None = None, diff --git a/src/docx/watermark.py b/src/docx/watermark.py index dd010ef24..6bec59813 100644 --- a/src/docx/watermark.py +++ b/src/docx/watermark.py @@ -23,6 +23,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Iterable, Iterator, List from docx.oxml.ns import nsdecls, qn @@ -195,7 +196,7 @@ def add_text_watermark( def add_image_watermark( headers: Iterable[_BaseHeaderFooter], - image_descriptor: str | IO[bytes], + image_descriptor: str | os.PathLike[str] | IO[bytes], width: Length | int | None = None, height: Length | int | None = None, washout: bool = True, diff --git a/tests/test_cli.py b/tests/test_cli.py index ab1e1f1de..20c91d6ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,8 @@ import io import json +import pytest + import docx from docx.cli import EXIT_CANNOT_OPEN, EXIT_WOULD_CHANGE, main @@ -250,3 +252,55 @@ def it_prints_help_with_no_arguments(self): assert code == 0 assert "Inspect and clean up Word documents" in out + + +class DescribeErrorReporting: + """A CLI that ends in a traceback has not reported an error, it has crashed.""" + + @pytest.mark.parametrize( + "argv", + [ + ("styles", "report", "MISSING"), + ("styles", "list", "MISSING"), + ("styles", "extract", "MISSING", "-o", "OUT"), + ("cleanup", "MISSING", "-o", "OUT"), + ], + ) + def it_reports_a_document_it_cannot_open_rather_than_raising( + self, argv: tuple[str, ...], tmp_path, capsys: pytest.CaptureFixture[str] + ): + """`PackageNotFoundError` is what a missing or non-package file raises, and it + is neither a `FileNotFoundError` nor a `BadZipFile`.""" + filled = tuple( + str(tmp_path / "nope.docx") + if arg == "MISSING" + else str(tmp_path / "out.docx") + if arg == "OUT" + else arg + for arg in argv + ) + + code, _ = _run(*filled) + + assert code == EXIT_CANNOT_OPEN + assert "cannot open document" in capsys.readouterr().err + + def and_names_a_style_the_document_does_not_define( + self, tmp_path, capsys: pytest.CaptureFixture[str] + ): + path = _document_path(tmp_path) + + code, _ = _run( + "styles", "extract", path, "-o", str(tmp_path / "out.docx"), "--names", "Nope" + ) + + assert code == EXIT_CANNOT_OPEN + assert "Nope" in capsys.readouterr().err + + def it_refuses_used_and_unused_together(self, tmp_path): + """Both filters at once matches nothing, so an empty list would read as an + answer rather than as the contradiction it is.""" + path = _document_path(tmp_path) + + with pytest.raises(SystemExit): + _run("styles", "list", path, "--used", "--unused") diff --git a/tests/test_copy.py b/tests/test_copy.py index f76cfadeb..5edf84955 100644 --- a/tests/test_copy.py +++ b/tests/test_copy.py @@ -149,6 +149,18 @@ def it_reassigns_the_drawing_id(self): copy_id = copy._p.xpath(".//wp:docPr/@id")[0] assert original_id != copy_id + def but_it_keeps_the_drawing_name(self): + """Only `@id` has to be unique. `@name` is the shape's own — Word shows it in + the selection pane — and for a picture it is the source file name.""" + document = docx.Document() + paragraph = document.add_paragraph() + paragraph.add_run().add_picture(_IMAGE) + + copy = paragraph.copy_to(document) + + original_name = paragraph._p.xpath(".//wp:docPr/@name")[0] + assert copy._p.xpath(".//wp:docPr/@name") == [original_name] + def it_remaps_a_hyperlink_across_documents(self): source = docx.Document() paragraph = source.add_paragraph() diff --git a/tests/test_custom_xml.py b/tests/test_custom_xml.py index 8be76fb05..350786942 100644 --- a/tests/test_custom_xml.py +++ b/tests/test_custom_xml.py @@ -137,6 +137,28 @@ def it_numbers_a_second_item_after_the_first(self): assert str(second.partname) == "/customXml/item2.xml" assert first.item_id != second.item_id + def but_a_caller_can_supply_the_item_id(self): + """A generated GUID is the one thing in this library's output that is not a + function of its input, so a caller who needs reproducible bytes can pass one.""" + item_id = "{00000000-0000-0000-0000-00000000002A}" + document = docx.Document() + + part = document.add_custom_xml_part(_INVOICE, item_id=item_id) + + assert part.item_id == item_id + + def and_that_makes_the_output_byte_reproducible(self): + item_id = "{00000000-0000-0000-0000-00000000002A}" + + def build() -> bytes: + document = docx.Document() + document.add_custom_xml_part(_INVOICE, ("urn:example:invoice",), item_id=item_id) + stream = io.BytesIO() + document.save(stream) + return stream.getvalue() + + assert build() == build() + class DescribeCT_DatastoreItem: """Unit-test suite for the `ds:datastoreItem` element class.""" diff --git a/tests/test_pathlike.py b/tests/test_pathlike.py new file mode 100644 index 000000000..e8ac6fca3 --- /dev/null +++ b/tests/test_pathlike.py @@ -0,0 +1,83 @@ +"""Every entry point that takes a path takes an `os.PathLike` as well as a `str`. + +The README states this without qualification, so it is a claim the tests have to hold +up. Opening and saving already accepted one; the image, alt-chunk and embedded-object +paths reached `open()` through helpers that tested `isinstance(x, str)` and fell through +to the stream branch, where a `Path` fails with `AttributeError: no attribute 'seek'` — +a message that says nothing about what was wrong. +""" + +from __future__ import annotations + +import pathlib + +import pytest + +import docx +from docx.image.image import Image + +from .unitutil.file import test_file + +_IMAGE = pathlib.Path(test_file("monty-truth.png")) + + +class DescribeImagePathAcceptance: + """The image loader is the one every picture entry point funnels through.""" + + def it_loads_an_image_from_a_path_object(self): + image = Image.from_file(_IMAGE) + + assert (image.px_width, image.px_height) == (150, 214) + # -- the filename survives, which is what the extension is taken from -- + assert image.ext == "png" + + def and_a_str_path_still_works(self): + assert Image.from_file(str(_IMAGE)).ext == "png" + + +class DescribePathLikeEntryPoints: + """Each of these reached `open()` through a helper that only knew about `str`.""" + + def it_accepts_a_path_for_add_picture(self): + document = docx.Document() + + shape = document.add_picture(_IMAGE) + + assert shape.width is not None + + def and_for_add_float_picture(self): + run = docx.Document().add_paragraph().add_run() + + shape = run.add_float_picture(_IMAGE) + + assert shape.width is not None + + def and_for_add_image_watermark(self): + document = docx.Document() + + document.add_image_watermark(_IMAGE) + + assert len(document.watermarks) > 0 + + def and_for_add_alt_chunk(self): + document = docx.Document() + + chunk = document.add_alt_chunk(_IMAGE, "text/html") + + assert chunk.blob == _IMAGE.read_bytes() + + def and_for_add_embedded_object(self): + run = docx.Document().add_paragraph().add_run() + + obj = run.add_embedded_object(_IMAGE, icon=_IMAGE) + + assert obj.blob is not None + + @pytest.mark.parametrize("as_str", [True, False]) + def it_gives_the_same_result_either_way(self, as_str: bool): + """A `Path` and its `str` are the same path, so they are the same document.""" + document = docx.Document() + + document.add_picture(str(_IMAGE) if as_str else _IMAGE) + + assert len(document.images) == 1