Skip to content

Code review fixes and known gaps: values, CLI, links and images, file references, untrusted styles, diagnostics lines, none-scheme designations - #6

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

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

Conversation

@dvejsada

@dvejsada dvejsada commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes from a code review of the renderer against the LegalDown specification 0.2, followed by the known gaps the review left documented. No version bump: this is meant to go into 0.4.0, which is not released yet.

Correctness

  • Durations
    • A raw CLDR unit id ("30 duration-day") is no longer printed for pt, ja, zh, ko, it, tr, nl, …. Missing patterns fall back to another length, then the language, then English, with a render-locale-fallback Warning.
    • Every written digit is kept, so 0.0001 days no longer shows as "0 days".
    • The plural form follows the displayed value ("1.0 days", "2,5 roku").
  • Money
    • Amounts of 27 or more digits no longer crash (decimal.InvalidOperation). Long fractions are no longer rounded (§10.3).
    • Fraction digits written in the source are kept beyond the currency's minor units.
    • An alphabetic symbol is spaced from the digits: "CZK 5.00".
  • Building the tree
    • A list item or quote that holds only a comment is dropped. A truly empty item keeps its place.
    • Underscore emphasis next to a directive parses as it does in the source.
    • A dropped comment no longer leaves a double space.
    • {{term:}} shows the term as rendered, with its Markdown and any {{ref:}} inside it resolved.
  • Numbering: continental no longer labels preamble list items ".1".
  • Placeholders: an empty blank renders [TBD: id] (§13.5).
  • Defined terms that reference each other
    • A term that references itself, or a cycle of terms, no longer crashes with RecursionError. 0.3.0 crashed on these.
    • Term-text work is now linear. In 0.3.0, 16 chained terms took about 5 s.
    • Term texts are capped at 1000 characters (render-term-too-long).
    • Repeat work caused by cycles has a work budget and a depth limit (render-term-limit).
  • none scheme (§13.3): a {{ref:}} renders the target's heading text as rendered, with the refs and terms in the heading resolved. Before, the refs were dropped. Items and paragraphs under such headings follow.
    • Cycles show the heading without its references, with render-designation-cycle.
    • Depth, nesting and length caps report render-designation-limit.
    • Documents with no references in headings are unaffected.

File references and documents from others (§2.3)

  • Every §2.3 file reference is checked: {{include:}}, attachments[].file, amends.file, supersedes.file, translations values and image paths.
    • path-not-relative (Error) applies always.
    • path-outside-root (Error) applies with a document root (RenderOptions.document_root / document_path, CLI --root).
    • The check is lexical only and never reads a file.
    • An image that fails it shows its alt text, and an {{attach:}} that fails it is not a link.
    • The spec's two path fixtures are run with their requires_config.
  • Untrusted mode (RenderOptions.untrusted / --untrusted): requires a document root and loads the style as untrusted input.
    • Accepted styles: built-in names, Style objects or in-memory YAML/mappings only.
    • extends may only name a built-in.
    • Strict CSS value grammars.
    • Limits: no extra_css, no YAML aliases, YAML size, nesting depth and number size are capped, and free text is limited to 200 characters.
    • Error messages never quote the style.
  • Always, in both modes: style values that could leave the stylesheet (/*, */, </, \, control characters) are refused, and so is </style in extra_css.
    • Date patterns are checked when the style loads.
    • Error output is capped at 20 problems.

Links and images

  • Images (§2.3, §8.7): an image with a URI scheme, an absolute path or a drive path is never emitted as <img>.
  • Link schemes: links use an allow-list: http, https, mailto, tel, ftp, ftps, sftp, sip, sips, sms, urn, geo, news, and relative links. Anything else (protocol handlers such as ms-msdt:, javascript:, data:, file:, C:\x) renders as its text with a render-link-refused Warning. CONFORMANCE marks §8.7 links as a documented partial deviation.
  • Empty links: an empty href renders just the text, and empty link text shows the URL.

Diagnostics

  • Line and file (§16.9): every renderer diagnostic carries its file and line: the block, list item or heading, or the frontmatter key.
    • RenderOptions.filename, set by render_file and the CLI.
  • Repeats: a repeated finding is reported once, at its lowest line. The renderer's diagnostics are listed in line order.

Plain-text writer

  • attachments.separator is honoured.
  • Table cells escape |, and the separator row carries the column alignment.
  • One quote stays one quote. Blocks within a list item are separated, and an item whose first block is a table, code or quote starts on its own line.
  • A condition label no longer shifts the item's indentation.
  • Style-blanked labels leave no stray :.

CLI and API

  • Encoding and line endings: input is read and output written as UTF-8 with LF, whatever the console encoding. Streams are reconfigured only when run as a program.
  • Clean errors instead of tracebacks:
    • A non-UTF-8 input or style file gives a clean message. render_file raises DocumentError.
    • An unwritable output path gives a clean message.
  • Output format: -o with a document extension the renderer does not write (.docx, .pdf, .md, …) is a usage error.
  • Exceptions:
    • StyleError is now also a RenderError, and still a ValueError.
    • New OptionsError.
    • Both exceptions pickle.
  • Safe stderr: diagnostics and echoed command-line values escape control characters.

Style validation

  • Placeholders in text templates are validated:
    • An unknown {field} is an error.
    • A required field ({designation}, {condition}, and {n} in settings in use) is checked.
  • Other checks:
    • extends cycles are detected on resolved paths.
    • --print-style output is self-contained.
    • version and extends cannot be overridden.
    • A YAML error in --set is reported.

CI, docs, labels, packaging

  • CI:
    • Tests run on Windows too.
    • Checkouts use LF.
    • Publishing waits for CI.
  • Labels: party identification labels are neutral: cs "Identifikační číslo", sk "Identifikačné číslo", en "Identification No.", de "Identifikationsnummer", fr "Numéro d'identification", pl "Numer identyfikacyjny". French uses "Représenté(e) par".
  • Docs:
    • README examples use UTF-8, and its links are absolute for PyPI.
    • README has a "Rendering documents you did not write" section.
    • CONFORMANCE.md and docs/ describe the behaviour and the remaining known limitations.
  • Packaging: the sdist includes CONFORMANCE.md, and the deprecated license classifier is dropped.
  • Tests: they are organised by topic. The performance protections are tested by counted work, not wall-clock time.

Behaviour changes for callers

  • Exceptions:
    • StyleError derives from RenderError (it is still a ValueError), and OptionsError is new.
    • render_file raises DocumentError for non-UTF-8 input.
  • Styles:
    • overrides may not contain version or extends.
    • A style with an unknown {field}, or without a required one, is refused.
    • CSS values with /*, */, </, \ or control characters are refused.
    • An invalid date pattern is refused.
    • Nesting beyond 32 levels is refused.
  • CLI: -o x.docx|.pdf|.md exits 2.
  • Rendering:
    • Links outside the allow-list render as text with a Warning.
    • Non-relative file references are Errors, so --strict refuses them.
  • Diagnostics: renderer diagnostics now carry line and file, and are listed in line order.

Test plan

  • pytest -q: 2149 passed with LEGALDOWN_SPEC_DIR set to a LegalDown checkout, including 200 specification examples and fixtures.
  • ruff check . is clean.
  • CI is green on Ubuntu (Python 3.11–3.13) and Windows.
  • Every example and fixture in the specification was rendered in three styles, both formats and the decimal and none schemes, before and after. Every difference is one of the intended changes above.

- 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.
…t file reference findings are reported once

CONFORMANCE.md, docs/architecture.md and docs/style-templates.md describe the
heading as rendered, the cycle and limit Warnings and their lines, the
nesting limit for terms and its scope, and the new test module. The rule lists
(module docstring, architecture, line granularity in CONFORMANCE.md) name
render-designation-cycle and render-designation-limit.
The assembler does not know the file it was given, so its findings
(answer-unknown, answer-invalid, answer-missing) had an empty file even
when RenderOptions.filename was set. _assemble now stamps the file name
on every diagnostic that names none, on the refusal and on the result
(§16.9). Tests extend the assembly test with the warning and the refusal.
A directive in a party's legal_name, label, address, identification_number,
date_of_birth, custom field or a representative's name or title was reported
at the party's first line. Each field is now resolved at its own key (the
signature blocks too); a value the source writes elsewhere, as a << merge
does, is at the line it is written. tests/test_diagnostic_lines.py: the
party/side test now expects the address key, not the party's line.
…ne order

The resolver works out the body before the title block, so a finding seen
first in the body and again in the title was reported at the later line.
diagnostics.record now keeps one finding (rule, level, message) at the
lowest line it was seen at, in Resolver._warn and Resolver._reference, and
api.render lists the renderer's own diagnostics in the order of their lines
(none last, stable) after the validator's. CONFORMANCE.md and
docs/architecture.md say so.
filename is documented as the name without its directory, but
render(filename='a/b.lgd') kept it on every diagnostic's file. render now
keeps the last path component, split at both / and \.
… root

A path with NUL or another control character (after the URL decoding that
applies to an image's source) is path-not-relative, so '..%00/x.png' and an
include of 'a<NUL>b' no longer pass as plain names. In the walk of a path
against the root a segment of '..' followed by dots or spaces counts as '..'
(Windows drops trailing dots and spaces), so '... /x' and '..../x' leave the
root; 'a..b.png' and '...hidden/x' are names as before. CONFORMANCE.md and
docs/architecture.md describe both, and the limitations that a file name with
a colon before any slash reads as a URI scheme (write ./report:v2.png) and
that an attachment's file is read as a URL only where it becomes a link.
…sted mode

A style value written into the stylesheet (fonts, sizes, colours, margins, page size,
max width) containing /*, */, </, a backslash, a control character or a line break is
now a StyleError naming the setting, in every mode: rejected rather than stripped,
since stripping one token can form another. html.extra_css containing </style (any
case) is refused. writers.html._css, the last line for a hand-built Style, now also
strips backslash, @, control characters and comment delimiters (a lone / stays so
calc(100% / 3) survives).

load_style(trusted=False) is for a style from somebody else: a built-in name, a
Style, a mapping or YAML text, never a file; extends only a built-in; strict grammars
per kind of value (declared as field metadata in style/model.py, enforced in
_convert, so a Style object and override objects are held to them too); no
html.extra_css; YAML text of at most 64 KiB without aliases; messages that name the
setting and never quote the style or a path.

RenderOptions.untrusted and --untrusted require a document root (OptionsError, exit
2) and load the style untrusted. Defaults are unchanged. No existing test or golden
changed.
README section on rendering documents you did not write, the Security and style
template chapters, and the conformance notes.
Keeps both: the none-scheme designation pass and the lowest-line rule for
repeated findings. The designation pass's diagnostics go through the same
Resolver._warn, and its rollback restores the diagnostics as they were
(a repeat may have lowered the line of one). CONFORMANCE.md's line-number
paragraph carries the designation findings' lines and the repeats rule.
The control-character check ran on the path before what a browser strips at
either end was removed, so a YAML block or folded scalar (which ends in a
newline), as in 'file: >' for an attachment, was a false path-not-relative.
The path is now read as before (tabs and newlines ignored, control characters
and spaces at either end stripped) and only a control character left inside it
(NUL, ESC, ...) is path-not-relative. Tests: block and folded scalars give no
finding; interior NUL and ESC, and '..%00/x.png' as an image, are still
refused.
…in its size

PyYAML's scanner takes time quadratic in the nesting depth of a flow collection:
a 40 KB text of nested brackets took 45 seconds to refuse in untrusted mode.
Styles are now read with a SafeLoader that refuses more than 32 levels (flow and
block) as the scanner reaches them, trusted or not; a mapping or Style passed in
is held to the same depth, and RecursionError stays only as a last resort. The
nesting test counts the scanner's work instead of timing it, which also brings
the suite back from 110 s to 60 s.
…e outermost term, keep the item piece of a cut reference

A heading read from the text of a term that the heading itself uses was
memoised with the term cut short in it, so a reference to it showed a text that
differed from the heading with no Warning. Its text is not kept now, it is
worked out again where the term is not open, and render-designation-cycle
reports it.

The nesting limit counted the levels the outer block already had, so a term
nested about 58 levels in a block, whose definition used another term, showed
the inner id with render-term-limit. It now counts from where the outermost
open term began (or from the heading, while designations are worked out).

A reference to an item or paragraph under a heading longer than 1,000
characters cuts the heading part and keeps the item piece, and says in its own
message that the reference shows the first 1,000 characters.

The validator's text of a term is searched for a reference only for a term
whose definition is not in the body. The docs state when designations are
worked out, that a ring of more than 32 headings is reported as a limit, that
a heading with 59 or more nested levels can reach the nesting limit after one
hop, and that a frontmatter value shown in a heading is not designated through.

Tests: the repro, the nesting at 58 and 98 levels in both schemes, the cut
reference, and every heading that reads differently from its designation being
reported, over random tangles of headings, items and terms.
YAML numbers, booleans and timestamps are read through guarded constructors: text
PyYAML cannot convert, or a number of more than 32 characters, is a style error
(it raised ValueError or KeyError quoting the text, or a traceback on the command
line). Enormous numbers in a mapping, as keys, values or overrides, are refused
without str(). Whatever else the loader raises on an untrusted style is reported
as one generic style error; a heading level out of range no longer quotes its key.

values.date: an untrusted style names short, medium, long or full; a trusted
pattern is formatted on fixed dates when the style loads, so one Babel refuses
(GGGGGGG, QQQQQQ) is a style error instead of an exception at every render.

An untrusted style's free-text settings are at most 200 characters (a long
references.format multiplied the output by the number of references), and NUL
and lone surrogates are refused in them and, in every mode, in the settings
written into the stylesheet. A message lists 20 problems at most.

--untrusted --print-style loads the style untrusted; the --untrusted help says a
style may be a name or YAML text. stylesheet() writes any </ in extra_css as <\/.
Docs describe all of it. No existing test changed.
@dvejsada dvejsada changed the title Code review fixes: value formatting, CLI robustness, links and images, text writer, style validation, term safety Code review fixes and known gaps: values, CLI, links and images, file references, untrusted styles, diagnostics lines, none-scheme designations Oct 28, 2026
…and PDF renumbered

v0.4 is now the hardening release; DOCX moves to v0.5 and PDF to v0.6.
The upstream prerequisites table records what the renderer now reads from the
validator (source positions) and what still waits on it (link reference
definitions, the path and definition-circular rules, wide table rows).
The Node 20 action majors triggered the runner deprecation warning on
every run. Move to the latest majors, which run on node24:

- actions/checkout v4 -> v7
- actions/setup-python v5 -> v7
- actions/upload-artifact v4 -> v7
- actions/download-artifact v4 -> v8

Inputs used here (checkout repository/ref/path, setup-python
python-version and cache, artifact name/path) are unchanged. The
download-artifact v8 digest check defaults to error; the artifacts are
produced and consumed by the same workflow run, so it is not affected.

pypa/gh-action-pypi-publish is a composite action and is left as is.
Tests now run on Ubuntu and Windows with Python 3.13. Lint, conformance and
validator-main also use 3.13. Update the PUBLISHING.md note that describes
the CI matrix to match.
@dvejsada
dvejsada merged commit 788cc6a into main Oct 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant