Skip to content

DOCX writer: Word-native numbering, cross-references and table of contents - #8

Merged
dvejsada merged 163 commits into
mainfrom
claude/tender-curie-iv97m6
Oct 28, 2026
Merged

dvejsada merged 163 commits into
mainfrom
claude/tender-curie-iv97m6

Conversation

@dvejsada

@dvejsada dvejsada commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Draft until the manual Word check (.github/PUBLISHING.md, "Checking the DOCX in Word") has been run. Merge with squash: the branch history contains merge commits whose subjects name the working branch, and one early merge commit has a non-maintainer author.

What it adds

render(format="docx") / legaldown-render -o file.docx writes a Word document in which everything Word can compute is live (ADR 0008):

  • Numbering: headings, list items and numbered paragraphs use Word numbering definitions (multilevel lists linked to the heading styles, one list per LegalDown list, startOverride restarts; lists past nine levels continue on further definitions up to 36). Continental {section} clauses ride the heading list, with nested lists on its reserved levels, so they restart by structure.
  • Cross-references: REF fields to bookmarks, composed from per-level fields plus delimiters (or one field for the whole reference where the format repeats the label's words), so every reference follows inserted or deleted clauses. Fixed text only where Word cannot express the style's reference form, always with a render-docx-reference warning; the built-in styles have none.
  • Table of contents: a TOC field with cached entries and PAGEREF page numbers; PAGE/NUMPAGES in the footer; w:updateFields on open (docx.update_fields_on_open).
  • Front and back matter: title block, parties, attachments (with bookmarks for {{attach:}}), signature blocks.
  • Planner (resolve/docx_plan.py): maps the render tree's numbering structure to Word and reports what Word cannot keep live (12 render-docx-* rules, listed in CONFORMANCE.md); the writer only serialises the plan.
  • Settings: page.header, page.footer, docx.update_fields_on_open, docx.reference_text_switch.
  • API/CLI: RenderResult.binary; -o x.docx writes DOCX; -o - now means standard output for every format (see the roadmap's v0.5 behaviour changes).

Verification

  • OOXML schema validation of every part (ISO/IEC 29500-4 schemas fetched by tests/schemas/fetch.py).
  • LibreOffice in CI (docx job): numbers, references, contents, attachments and signatures after a field update equal the plain-text output over the spec corpus and the test documents in three styles; edit-then-update tests insert and delete headings, items and paragraphs through UNO and compare with the render of the edited source.
  • Manual Word checklist in .github/PUBLISHING.md for what only Word can confirm. Until it has been run, CONFORMANCE.md marks DOCX "not yet checked in Word".

The render tree now also exposes the numbering structure (RenderTree.formats, unit ids, counters); HTML and text output are unchanged. The "Against legaldown-validator main" job is informational and fails on main too (see the comment below).

- S1: main() reconfigures stdout to UTF-8 and stderr to backslashreplace
  (guarded by hasattr(..., "reconfigure")); stdin is read as bytes and
  decoded as UTF-8. Czech text no longer crashes or is corrupted on cp1252.
- S4: a non-UTF-8 input file or stdin is "cannot read NAME: not valid UTF-8
  (byte 0x.. at offset N)", exit 1 (offset is in the file, not a buffer).
  A non-UTF-8 style file is a StyleError (style/loader.py _read only).
- S5: an OSError writing -o is "cannot write PATH: ...", exit 1.
- S6: -o with an extension no writer knows (x.docx, .pdf, .md) and no -f is
  a usage error (exit 2); no extension keeps the html default; -f wins.
- S2: StyleError is defined in errors.py as StyleError(RenderError,
  ValueError) and re-exported from style/loader.py, legaldown_render.style
  and the package. An unknown format raises the new OptionsError(RenderError,
  ValueError), exported from the package.
- S3: RenderRefused (now storing .reason) and StyleError define __reduce__ so
  they round-trip through pickle.
- S10 (CLI side): the argparse type _setting turns StyleError into
  ArgumentTypeError so its message reaches the user.

Docs: README (exit codes, encoding, -o extension, error classes),
docs/architecture.md (errors). Tests: tests/test_wp2_cli.py (new); no
existing test or golden changed.
…ed symbols (R1-R4)

R2: Babel calls for number/money/duration run in a decimal context whose
precision is max(28, len(value) + 8). Amounts of 27+ digits no longer raise
InvalidOperation and fractions over 28 digits are no longer rounded (§10.3).

R3: durations are formatted like number() (every written digit, locale
separators) and the plural category is taken from the displayed value
(Locale.plural_form on the Decimal: "1.0 days", cs "2,5 roku"). Babel's
format_unit, which rounds to 3 decimals, is no longer used.

R1: the CLDR pattern is looked up through one helper (Locale._data is the only
access; Babel 2.x has no public API): requested length, other lengths, the
language-only locale, then English. A raw "duration-day" is never printed.
Formatter.format_duration returns DurationText(text, english_fallback); the
resolver emits one render-locale-fallback Warning per (locale, unit) when
English stands in. Formatter.duration keeps returning str.

R4: an alphabetic currency symbol or code touching a digit on either side gets
U+00A0 in symbol and code mode ("CZK 5.00", "CHF 1.00"); "$5.00", "R$ 5,00",
"CA$5.00" and cs "5,00 Kč" are unchanged.

New: tests/test_wp1_values.py. No existing test or golden changed. Docs: ADR 0005
and docs/architecture.md.
…tives, dropped-comment spacing, {{term:}} text (B2, B3, B4, B8)

- B2: a list item whose model blocks are all comments/raw HTML (kind "html")
  is left out, after the whole list is traversed so item numbering and
  PlacedMarker.item stay aligned; a list left with no items and an ordinary
  quote with no built blocks are left out. A truly empty item and an empty
  drafting note keep their place. (build.py _list, list, quote)
- B3: every directive sentinel is wrapped in U+2E31 (category Po, accepted by
  markdown-it's isPunctChar) on both sides, as the braces it replaces are
  punctuation, so `_text_{{ref: a}}`, `{{ref: a}}_text_`, `__b__{{ref: a}}`
  and `*t*{{ref: a}}` parse as with `{x}`. sentinel_re, encoded_sentinel_re
  and _restore consume the wrappers; the hidden form's "h" tag is no longer
  needed. (build.py)
- B4: _merge_text strips the spaces before a dropped inline when a line break
  follows, so no double space is left.
- B8: Resolver._survey collects definition id -> plain rendered term text
  (first definition wins, via _title_text); {{term:}} uses it, falling back
  to the validator's definition_lookup.

Existing test changed: test_empty_list_items_keep_their_place is extended
(an empty item still keeps its place; a comment-only one goes). Goldens are
unchanged. Docs: CONFORMANCE.md, docs/architecture.md.
render_file() read the file with read_text, so a non-UTF-8 file raised a
bare UnicodeDecodeError. It now reads bytes and raises DocumentError with
the CLI's wording ("not valid UTF-8 (byte 0x.. at offset N)").

Behaviour change: DocumentError is not a ValueError, so code that caught
UnicodeDecodeError/ValueError from render_file for this case must catch
DocumentError (or RenderError). OSError for unreadable files is unchanged.

Test: tests/test_wp2_cli.py::test_render_file_raises_document_error_for_non_utf8.
money with a currency showed the larger of the currency's minor units and the
written digits only when the last written digit was non-zero: "1.500" EUR gave
"€1.50" and "1.1230" gave "€1.123". The fraction digit count is now fixed in the
pattern as max(minor units, written decimals) with currency_digits off, in
symbol, code and name mode, under _exact. "5" EUR is still "€5.00", JPY "5"
is "¥5", JPY "0.125" is "¥0.125". Tests added to tests/test_wp1_values.py; no
existing test or golden changed.
`"Self {{term: s}}" {{def: s}}`, and cycles through several definitions,
raised RecursionError in Resolver._term (already so before WP4, through
_plain_value of the validator's raw term). The term-text computation now
tracks the definitions it has open (the _survey pre-pass and the
_plain_value fallback, _plain_open): a {{term:}} to an id already open shows
its label=, else the definition id, and goes no further. The validator 0.4.0
reports nothing for such documents; the spec's definition-circular Warning
is not emitted by it, and no diagnostic is added here (CONFORMANCE.md notes
it).

Tests: direct and indirect cycles, at the definition site and at reference
sites, in the text and HTML writers.
A comment-only list item is now dropped only when the markers give it no
anchor identifier and no condition. The validator does not place a marker
on such an item, so this is defensive; the test drives the builder with a
made-up marker.
…es, template fields (R6, S7-S11)

- R6: Resolver._placeholder renders [TBD: id] when the effective
  placeholders.blank is empty or whitespace (spec §13.5 rule 6); no StyleError.
- S7: extends cycles are detected on resolved paths (Path.resolve), so
  ../dir/a.yaml, a second spelling, or a symlink no longer recurses until
  "File name too long".
- S8: dump_style drops `extends`, so a --print-style dump is self-contained and
  reloads from any directory.
- S9: overriding `version` or `extends` is a StyleError (it was silently
  ignored, and --print-style could print an unloadable style).
- S10 (loader side): a YAML error in a --set value raises StyleError naming
  the key and the YAML problem (with line/column) instead of resurfacing as a
  misleading type error; reaches stderr through WP2's argparse wrapper.
- S11: model.TemplateFields / template_settings inventory the {field} names of
  every text template setting (verified against numbering.fill,
  Resolver._ref, fill_condition); the loader reports unknown
  identifier-shaped fields and missing required ones, naming the setting
  (labels.condition must contain {condition}, §15.8). Other braces stay
  literal. Built-in styles and labels pass.

Existing test changed: tests/test_style.py::test_dump_round_trips now compares
with extends=None, because the flattened dump no longer records what the
style extended (S8). Docs: style-templates.md (field table, flat dump,
overrides), CONFORMANCE.md, architecture.md.
…back (S11 follow-up)

The required-field check no longer refuses an empty template for a setting
the resolver already replaces when empty: `labels.condition or "{condition}"`
(Resolver._condition) and `labels.attachment_file or "{file}"`. An empty
labels.condition shows the bare condition, which meets §15.8, and refusing it
would break existing styles for no gain. TemplateFields.empty_falls_back marks
exactly these two settings. Settings with no fallback (references.format and
the numbering/enumeration/paragraph refs) still require {designation}/{n};
unknown fields are checked in every non-empty template.

Tests: new tests for the empty condition label (renders the bare condition)
and for empty templates without a fallback. docs/style-templates.md updated.
…ty links (B6, W3, B9/W8)

B6 (§2.3): an image whose path has a URI scheme (data: included), starts with
/ or \ (//host too) or is a Windows drive path is replaced by its alt text in
the resolver (Resolver._resolve_inline) and reported as the Core Error
path-not-relative, which --strict refuses. The validator 0.4 does not check
image paths; the renderer's report is skipped when the validator names the same
path. ../ stays allowed (no default document root). The builder now keeps
javascript:/file:/data: links and images as nodes (markdown-it validateLink off)
so they can be reported and shown as text instead of as literal Markdown.

W3 (§8.7): the HTML allow-list becomes a deny-list (javascript, vbscript, file,
data) after the WHATWG normalisation; new urls.py shared by resolver and both
writers so they agree. A refused link is its text only in both writers.

B9/W8: an empty href is just the link text; a link with no text shows its URL;
the text writer omits <url> when it equals the text minus mailto:/tel:.

Changed goldens: features.{default,continental,outline}.txt and
features.default.html: the "[an unsafe one](javascript:alert(1))" source text is
now "an unsafe one" (the link is refused, its text kept).
… gate

An image with an empty or whitespace-only path is replaced by its alt text in
the resolver (no <img src="">, which requests the page itself) and reports
nothing: §2.3 has no rule for an empty path. Tests for both writers.

urls.py, html.py and docs/architecture.md no longer say markdown-it refuses
unsafe schemes while parsing: its validateLink is off in the builder, and the
deny-list is the gate for every href/src the HTML writer emits (Link, Image,
Value).
…ms, blank labels (W1, W4-W7)

W1: TextWriter takes the style's attachments.separator (api._writer hands
it over; TextWriter() keeps the default style's "page-break"). "none" prints
no separator; "rule" and "page-break" both print "---" before EACH
attachment (plain text has no pages). The "---" that used to precede the
"Attachments" heading is gone; the heading now stands directly after the body.
W4: "|" in a table cell is escaped as "\|"; the separator row carries
alignment colons from Table.align (":---", ":---:", "---:"), unpadded.
W5: _prefix gives a blank line the right-trimmed prefix, so one quote stays
one quote (">" and "> >" on blank lines). Blocks of a list item are separated
by a blank line; a nested list directly after a paragraph (or another list)
stays tight as before.
W6: an item's content indent is len(label or bullet) + 1, whatever its
condition label.
W7 (text only): a style-blanked label drops the "label: " prefix in the title
block and party details. HTML keeps its empty <dt>.

Existing tests changed because of W5:
- test_nested_lists_line_up_with_their_item_s_text: blank line between the
  item's paragraphs.
- test_a_later_paragraph_in_a_list_item_is_the_item_s: blank line before the
  item's second paragraph.
- test_a_heading_in_a_quote_or_item_is_a_bold_line_not_a_section: the
  two-block quotes printed as two quotes; now one quote with a ">" line.

Goldens changed (features.default/continental/outline.txt, each hunk read):
- W1: "---" moved from before the "Attachments" heading to before each of the
  two attachments.
- W4: the table separator row is now "|:---|---:|:---:|" (the document's
  table is left/right/center aligned).
template.default.txt is unchanged.
…ceholder inside it is kept

The term text of a {{term:}} was collected in Resolver._survey with a text-only
pass, before sections and anchors were registered and before inconsistent
placeholder types were known: a {{ref:}} in the term was dropped ("Fee per")
and a placeholder used with conflicting types showed a blank. _survey now
records only the first DefinitionSource per id; Resolver._term_text resolves its
children during inline resolution (all targets known), takes plain text and
caches it, under the existing _terms_open cycle guard. It restores used_anchors,
so a definition nested in a term keeps its document-order anchor; before targets
are known (a none-scheme heading) the text-only pass stands in, uncached.
_warn and _not_relative deduplicate, so no repeated diagnostics. Docs updated.
No existing test or golden changed.
R5: a list item in the preamble has no section, so `{section}` was filled
with "" and the continental label read ".1" while its designation was "1".
`numbering.fill` now removes `{section}` and one following "." when the
section is given as the empty string, so the label is "1". Items in a
section keep "1.1". The text and HTML output of a preamble list change
(".1 first pre" becomes "1 first pre"); no golden contains one.

W2: the print stylesheet broke before each `.ld-attachment`, so the
"Attachments" heading was stranded at the foot of the previous page. The
break now goes before the `.ld-attachments` container (its heading goes
with the first attachment), before each later attachment, and the heading
has `break-after: avoid`. Goldens features.default.html and
template.default.html changed only in those three CSS lines
(`--update-golden`, hunks inspected).

Tests: tests/test_wp7_small.py. Docs: CONFORMANCE.md (preamble label) and
docs/style-templates.md (`{section}` in the preamble).
…ck to 'other' (review: values)

Babel's plural operands convert the fraction digits with int(), which Python
limits to 4300 digits; {{duration:}} and name-mode {{money:}} with a longer
fraction raised a raw ValueError. values._plural_category catches it and uses
'other' (the CLDR category of a non-integer in nearly every locale); name-mode
money puts Babel's long-name pattern together with 'other' when its own lookup
would fail. sys.set_int_max_str_digits is process-wide and is not touched.
…ature labels (W1, W7 follow-up)

W1: with rule/page-break the text writer prints "---" before the
attachments heading (as it did originally) and between consecutive
attachments, not between the heading and the first attachment, so the
heading stays with what it heads. With none, no "---" at all. Updates the
TextWriter docstring, CONFORMANCE row and docs.
W7: the blank-label rule also covers the signature blocks (name, title, date,
place): a blanked label prints the value alone, and a row with neither label
nor value is omitted instead of a lone ":".

Goldens (features.default/continental/outline.txt): the "---" moves back
before the "Attachments" heading, relative to the previous commit; a "---"
now also separates the two attachments. Tests in tests/test_wp5_text.py
updated and extended for both changes.
…sage error (review: cli)

contract.v2, report.2025-10-27 and .out failed with exit 2 since the S6 fix. Now
only .docx .doc .odt .rtf .pdf .md .markdown .epub .tex (any case) need --format;
any other extension, or none, keeps the html default. -o and -f --help and the
README state the exact rule. tests/test_wp2_cli.py: the S6 parametrization grows
to the full list, and tests for the kept default, known extensions and help.
…LF everywhere (review: cli)

main() reconfigured sys.stdout/sys.stderr on every call, so an in-process
main([...]) changed the caller's streams for good. It now does so only when
argv is None (the console script and python -m call it so). Standard output is
reconfigured with newline="\n" and -o files are written with newline="\n", so
no platform translates LF to CRLF. README states both. tests/test_wp2_cli.py:
comments on the exact-byte comparisons (LF on every platform). New tests in
tests/test_wp9_review_fixes.py. No golden changed.
…ations (S13, S14, S15, B1, B5, B7, B10, R7/B11, W9/S12)

S13 labels (style/labels.py): the party identifier label is shown for §3.4
identification_number, which natural persons may have too. cs "IČO" ->
"Identifikační číslo", sk "IČO" -> "Identifikačné číslo", fr "Représentée par"
-> "Représenté(e) par"; pl "Reprezentacja" kept (no gendered form). Existing
test changed: tests/test_render.py French label assertion. No golden shows
these labels. New tests/test_wp8_labels.py.

S14: README and __init__ docstring examples open files with encoding="utf-8"
(and write with newline="\n"); the --locale example says it formats values and
README states labels follow the document language; README links are absolute
GitHub URLs for PyPI; CONFORMANCE.md added to the sdist (checked with
python -m build --sdist). docs: locale vs language wording in concepts.md and
style-templates.md.

S15: dropped the deprecated License classifier (license = "MIT" is SPDX);
.github/PUBLISHING.md recommends gating the publish workflow on the tests
(workflow file not changed).

DOC rows, each verified by running an example: CONFORMANCE.md Known
limitations for text after a comment (B1), directives in link/image
destinations (B5), the exact nesting-limit rule (B7), table cells beyond the
header (B10), parties block vs {{party:}} (R7/B11); architecture.md Security
rewritten for trusted style input (W9/S12), link-gate sentences de-duplicated.
…pe controls (review wave 2)

- urls.LINK_SCHEMES (http, https, mailto, tel, ftp, ftps, sftp, sip, sips, sms,
  urn, geo, news, plus scheme-less URLs) replaces the DENIED_SCHEMES deny-list:
  protocol handlers (ms-msdt:, vscode:, ...) and pseudo-schemes (C:\x,
  localhost:8080) render as their text. One predicate, is_link_target.
- resolver: a refused non-empty link is the Warning render-link-refused.
- urls.printable escapes C0/C1, DEL, Cf, U+2028/9 in quoted paths/URLs; used in
  path-not-relative and the new warning (no ESC/BEL/newline forged on stderr).
- path-not-relative dedupe matches the quoted '<path>'; message is generic.
- A link with no text shows the URL as written (unquoted), in both writers.
- tests/test_wp3_links.py: magnet: is no longer a link; 'a:b' is no longer
  a safe href (allow-list).
- template_settings: a ref is required to contain {n} only where it is read
  (numbering unless scheme none; enumeration when enabled or ordered:
  enumerate; paragraphs when numbered). Unknown fields are still flagged.
- Plain labels (labels.* but condition and attachment_file) substitute
  nothing, so braces in them are text and they are no longer checked.
- tests/test_wp6_style.py: labels.drafting_note/colon with fields are valid;
  paragraph format cases set paragraphs.numbered.
The contents now list every later alternative with the number it shares, and the
template document's numbers are compared in full.
…nts of nested lists, Word checklist

The render reports one information when alternatives share a number as fields of the
first one, which breaks if it is deleted in Word. The message about a restarted unit
says what an edit does to it, and the round trip pins the drift. A list nested in an
item with an indent of its own is indented one step in. New tests cover four reserved
levels and a level-5 section after a nested list; the property tests exempt only
documents that skip a heading level. A paragraph deleted through UNO must match its
text exactly.
…the words of references.format

A reference is now covered as the text the renderer shows (the designation inside references.format),
so a plain REF \n that returns the same words as the format ('Article I' for 'Article {designation}',
'§ 3' for '§ {designation}', 'Section 1.1' for 'Section {designation}') is the whole reference: no
\t switch, nothing for LibreOffice to read differently, and no fixed text where the switch could not
drop the label's words. The test helper that reads the literal text of a cover accepts a field that
stands for the format's words.
…he path in them, detached levels as the fallback

A list nested in an item on the heading list now takes the reserved level whose text is the label as
the resolver writes it, with the section's counters and those of the items on the way as levels of the
heading list (%1.%2(%6)): the label and every reference to it are live, and the list restarts by
structure under a new item. Where that level is taken by another text, the nesting is deeper than four,
or the chain leaves the heading list (an ordered item), the list takes a detached abstractNum that shows
each level's own counter alone, only when the enumerated level would have to show the counter of a unit
that is not on it; it restarts at a fixed value under a new parent, and the label that Word draws
shorter is reported with the units whose counters it leaves out. References to such items are fields
of their own counters. The edit helper can put an inserted paragraph on another item's list and level,
as pasting a copy does.
… clamping

A list nested deeper than nine levels now takes the next abstractNum of its family (enumerated+1 to
enumerated+3, renumber+1 to renumber+3; 36 levels in all), whose level j is the family's level of
depth 9k+j with its own counter alone. The items keep their numbers and labels, restart by structure
inside each abstractNum, and the list that begins a chunk restarts at a fixed value like any list nested
in a list of another kind (reported as such). Only a nesting past 36 levels is still clamped and
reported with render-docx-list-depth. Items stop stepping in at thirteen centimetres so that very deep
lists stay on the page.
…Word's \t switch

off never uses the switch; documented (the default) uses it where Microsoft's documentation settles the
result (the words before the first counter and after the last go, a delimiter between counters stays:
'Section 1.01' gives '1.01'); guess also drops a period after the letters that lead a number ('No. 1',
'Čl. 1'). A reference that needs the switch where the setting does not allow it is fixed text, reported.
The setting is validated like the other enumerations, reaches the planner through the DOCX settings, and
is documented with the style template.
…reference stays live

A document that skips a heading level (heading-skip, §4.1, a Core error the renderer still numbers) has
a part of a number that no unit holds, so no field returns it. The cover now writes that part (and only
that: the counter of a Ghost, never a stale counter of another level) as literal text between the fields
of the units that do hold their counters, instead of making the whole reference fixed text. The reference
is reported once per run of such references and says which part is text. The property test of the
references allows letters and digits as literal text only for those parts, in documents that skip a level.
… classes; ship the Word check document generator

A reference that is still fixed text says which class it falls in: delimiters around the counter that the
reference lacks (with what the style's docx.reference_text_switch could change), or the counters of other
units that the reference writes itself (one finding for all of them, since the cure is the label). References
that fail alike are one finding, with the right plural. Over every test variant a reference is fixed text
only in a document that skips a heading level, and over label forms the built-in styles do not have only for
those two reasons.

The architecture notes, ADR 0008, CONFORMANCE.md and the README describe the whole-text cover, the reserved
levels with the path in them, the detached levels, the lists past nine levels, the switch setting and the
skipped level's text. The Word checklist gains the rows for the switch on each label form, the reserved level
%1.%2(%6) with unused levels around it, detached lists, deep lists and a skipped level, and a generator
(tests/word_check.py) makes the document for them with the planner's assumption beside each row.
…age-derived indents, words between counters under guess

A reference below a skipped heading level whose number Word draws with another unit's counter now says so
(the skipped level's own reason, not a remark on the style's labels), and the label warning no longer claims
Word draws something else when it draws the same text from another counter. The warnings about labels that
lose counters are one finding for a chain, with how many more places do the same.

Bullets go on in bullet+1 to bullet+3 past nine levels like the other families, and a nesting past 36 levels
is reported for every family. The indent that nested lists stop stepping in at is 60% of the page's text
width (passed to the planner), not a fixed thirteen centimetres, so a deep list on A5 stays readable.

docx.reference_text_switch: documented drops only the words before the first counter and after the last;
words between counters are dropped only under guess (the documentation does not say what Word does with
them). The Word check document has a row for them, no longer links its lists to the heading styles (Word
would renumber the document's own titles), and word-check/ is ignored. The ghost carve-out of the property
test compares the letters and digits written as text with the text the plan says it wrote. The c2 message
says where the delimiters would go.
…per item; one LibreOffice session for the new edit tests

An item that is the first of its level in its list after a deeper item of that list (the level above is held
by a unit of another list) is 1 in Word and 2 in LibreOffice, which counts the unused level as its start and
goes on from it. The simulator now also gives LibreOffice's count (Step.lo_count), and the plan says once per
document that such items read higher there (render-docx-libreoffice-count, information); a test shows
LibreOffice's reading. The four new edit fixtures run in one libreoffice_edit job, and the label-form test
samples ten documents.

Copy link
Copy Markdown
Contributor Author

Two red checks on dace64d:

  • DOCX (schemas and LibreOffice): this PR's. The runner's LibreOffice lays tables out differently in its plain-text export (a table row on one tab-separated line instead of one line per cell), and the tests read the document through that export. A fix that reads the document through UNO instead (paragraphs with their list labels, table cells in order), and prints the LibreOffice version in the job, is in progress and will be pushed here.
  • Against legaldown-validator main (informational job): not this PR's. legaldown-validator's main branch now reports path-not-relative itself with new wording, so 22 renderer tests about de-duplicating that report fail. main fails the same 22 tests against validator main (checked locally). No fix exists yet; the renderer's own check and its de-duplication will be adapted when that validator change is released and the dependency moves to the next validator minor version.

Generated by Claude Code

An attachment whose id is a heading's is now one attachment, not a second copy of the duplicate-id case. The page tests compare
page numbers and counts with each other and with page breaks instead of with what a particular font gives. Two comments that
said the table of contents was not written yet are corrected.
LibreOffice's text of every corpus document, with fields and indexes updated, equals the plain-text writer's under the default,
outline and continental styles, modulo the differences the formats have and the gaps the plan reports; the documents follow a
heading, an item, a nested item, a numbered paragraph inserted and a clause deleted in the editor; every part validates and a
second render is the same bytes. The module is left out of a plain pytest run (marker acceptance) and runs in the DOCX job.
pytest-xdist joins the dev extra: pytest -n auto runs the suite in a third of the time.
…ck that its snippet runs

The snippet skips the attachment variants for the template (it has no attachments) and makes the document of the
render-docx-libreoffice-count case. A test runs the snippet and checks what the checklist names.
…cture, styles, roadmap, ADR 0008

The conformance notes list every render-docx-* rule with its level and meaning; the roadmap entry says what is done and what
behaviour changes (-o - is standard output for every format, RenderResult.binary, .docx is written).
…t export filter

The plain-text export lays a table row out as one tab-separated line in some LibreOffice versions
(the CI's) and as one line per cell paragraph in others (24.2), so the tests passed locally and failed
in CI. The UNO scripts now walk the body (paragraphs, and tables by row and cell, in order) and write
one line per paragraph, led by the list label LibreOffice draws, which gives the same lines under every
version. The nested items no longer carry the filter's indent. The CI job prints the Ubuntu and
LibreOffice versions before the tests.
…nts them; pin the reported gap lines

The edit-then-update test no longer excuses numeric differences (none occur in the subset), the gap documents are compared
line by line against the lines pinned for them, and the determinism test needs no schemas. The test hook matches the
acceptance file by name; a CI step runs the acceptance tests that carry no other marker.
…, tree fields, DOCX is not yet checked in Word

PUBLISHING gains the step that follows the Word check (record the Word version and platform, update the places that say
the DOCX is unchecked, decide the §13.6 claim) and lists the docx job among those that gate a release.
@dvejsada
dvejsada marked this pull request as ready for review October 28, 2026 19:03
@dvejsada
dvejsada merged commit 3f6eca4 into main Oct 28, 2026
5 of 6 checks passed
dvejsada added a commit that referenced this pull request Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants