From 5ac6eeb075f1510151ebcd6fcd0e2a828197eb7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:11:53 +0000 Subject: [PATCH 01/22] Add design docs: concepts, architecture, style templates, roadmap, decisions Design phase only, no code. Documents the pipeline (parse/validate via legaldown-validator, build, resolve, write), the format-neutral resolved document, the planned package layout, the style template draft, the milestones, and the upstream changes the renderer needs first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- .gitignore | 8 + README.md | 86 +++++++ docs/architecture.md | 286 +++++++++++++++++++++++ docs/concepts.md | 106 +++++++++ docs/decisions/0001-separate-package.md | 32 +++ docs/decisions/0002-one-parser.md | 50 ++++ docs/decisions/0003-resolved-document.md | 33 +++ docs/decisions/0004-html-first.md | 40 ++++ docs/decisions/0005-locale-formatting.md | 39 ++++ docs/decisions/README.md | 25 ++ docs/roadmap.md | 70 ++++++ docs/style-templates.md | 107 +++++++++ 12 files changed, 882 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/architecture.md create mode 100644 docs/concepts.md create mode 100644 docs/decisions/0001-separate-package.md create mode 100644 docs/decisions/0002-one-parser.md create mode 100644 docs/decisions/0003-resolved-document.md create mode 100644 docs/decisions/0004-html-first.md create mode 100644 docs/decisions/0005-locale-formatting.md create mode 100644 docs/decisions/README.md create mode 100644 docs/roadmap.md create mode 100644 docs/style-templates.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2225b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..355a1ee --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +
+ +# legaldown-render 🖨️ + +### The reference renderer for [LegalDown](https://github.com/ForLegalAI/LegalDown) + +**Turn a LegalDown document into HTML now, with DOCX and PDF planned. Section numbers, +cross-references, defined terms, and formatted values are all generated at render time.** + +Status: **design phase**. There is no code yet. Start with [the concepts](docs/concepts.md). + +
+ +--- + +## What it will do + +[LegalDown](https://github.com/ForLegalAI/LegalDown) documents never hardcode section numbers, +reference text, or formatted values. The source says `{{ref: payment-terms}}`, and the renderer +turns it into "Section 4.2" with a link to that section. This package is that renderer: + +🔢 **Numbers** sections and list items under a configurable scheme (§13.1, §13.2). + +🔗 **Resolves** cross-references, defined terms, parties, sides, and attachment references into +display text with links (§13.3, §13.4, §13.5). + +🌍 **Formats** dates, money, and durations for a locale chosen at render time, not stored in the +document (§10). + +🎨 **Applies a style template**, so the same source can render as a US-style contract or a +continental one without editing the text (§13.7). + +🚩 **Never hides a problem.** Anything unresolved renders as a visible marker, such as +`[BROKEN REF: id]`, and is reported with the specification's stable rule id. Nothing is ever +dropped silently (§17.5). + +📄 **Writes** HTML first, then DOCX, then PDF. Every output format comes from the same resolved +document, so a clause gets the same number in all of them. + +It targets conformance **Level 2 — Rendering** of the specification (§17.3). Parsing and Core +validation come from [`legaldown-validator`](https://github.com/ForLegalAI/legaldown-validator), +the reference parser. This package does not reimplement the LegalDown grammar. + +## Planned usage + +> Everything in this section is illustrative. The API is not implemented yet and may change. + +```bash +pip install legaldown-render # HTML +pip install "legaldown-render[docx,pdf]" # later: DOCX and PDF + +legaldown-render contract.lgd -o contract.html +legaldown-render contract.lgd -o contract.html --style continental --locale cs-CZ +legaldown-render contract.lgd -o contract.html --strict # refuse to render on any error +``` + +```python +from legaldown_render import render + +result = render(open("contract.lgd").read(), to="html", style="default") +for diagnostic in result.diagnostics: + print(diagnostic.level, diagnostic.rule, diagnostic.message) +open("contract.html", "w").write(result.output) +``` + +## Documentation + +| Document | What it covers | +|---|---| +| [Concepts](docs/concepts.md) | The ideas and vocabulary: what a renderer is responsible for, the principles, the terms used in the rest of the docs | +| [Architecture](docs/architecture.md) | The pipeline, the render tree, package layout, dependencies, error handling, testing | +| [Style templates](docs/style-templates.md) | Draft of the style template format | +| [Roadmap](docs/roadmap.md) | Milestones, and the changes needed in `legaldown-validator` and the specification first | +| [Decision records](docs/decisions/) | Why the main choices were made | + +## Related repositories + +| Repository | Role | +|---|---| +| [LegalDown](https://github.com/ForLegalAI/LegalDown) | The specification and its conformance fixtures | +| [legaldown-validator](https://github.com/ForLegalAI/legaldown-validator) | Reference parser, document model, serializer, and Core validator; this package depends on it | +| **legaldown-render** | Rendering (this repository) | + +## License + +[MIT](LICENSE) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..28831e1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,286 @@ +# Architecture + +> Status: **draft, not implemented.** This describes the intended design. The decisions behind it +> are in [decision records](decisions/). Where this page and an accepted decision record disagree, +> the decision record wins, and this page should be fixed. + +Read [Concepts](concepts.md) first for the vocabulary. + +--- + +## The pipeline + +``` + source .lgd ─┐ + answers ─────┤ (templates only) + ▼ + ┌──────────────────────┐ + │ 1. Parse & validate │ legaldown-validator: Document model + Core diagnostics + └──────────┬───────────┘ + ▼ + ┌──────────────────────┐ + │ 2. Assemble │ only with an answers set (§15.7); otherwise template view + └──────────┬───────────┘ + ▼ + ┌──────────────────────┐ + │ 3. Build │ source + Document → render tree (blocks, inlines, directive nodes) + └──────────┬───────────┘ + ▼ + ┌──────────────────────┐ style template ─┐ + │ 4. Resolve │ ◄────────────────┘ numbering, enumeration, anchors, references, + └──────────┬───────────┘ terms, fields, parties → resolved document + ▼ + Rendering-level diagnostics + ┌──────────────────────┐ + │ 5. Write │ html │ text │ docx (later) │ pdf (later) + └──────────┬───────────┘ + ▼ + RenderResult(output, diagnostics) +``` + +Each stage has a single job and hands an immutable value to the next one. Only stage 4 knows +about the style template's legal settings. Only stage 5 knows about file formats. + +### 1. Parse and validate + +This stage calls `legaldown.parse_document` and `legaldown.validate_document` from +`legaldown-validator`. The result is: + +- the **metadata**: title, sides, parties, attachments, `field_types`, language +- the **section outline** and identifiers, including auto-generated ones (§5.3) and duplicate + handling (§5.5) +- the **definitions** table (§7) +- the **Core diagnostics** + +The renderer never reimplements any of this ([ADR 0002](decisions/0002-one-parser.md)). + +### 2. Assemble (templates only) + +Given an answers set, the template is assembled into a plain document first (§15.7), and that +document is rendered. Assembly is a text-to-text transformation that the specification places at +Core (the fixtures' `assembly/` cases), so it belongs in the core package. The renderer calls it +and does not own it. Without answers, stages 3 and 4 produce the template view (§15.8) instead. + +Assembly depends on the core package supporting specification 0.2 first. See the +[roadmap](roadmap.md). + +### 3. Build the render tree + +This stage turns the document into a **render tree**: a format-neutral tree that is structurally +complete. Nested lists stay nested, inline formatting becomes nodes, and every directive is a +typed node that has not been resolved yet. + +The current `legaldown.Document` model is designed for validation and round-trip editing, and it +loses some things a renderer needs: + +- Nested list items are flattened into siblings. +- Continuation lines are joined. +- Inline text is kept as raw strings. + +So the tree is built from two sources: + +| From | What | +|---|---| +| `legaldown-validator` | Metadata, section identifiers and titles, definitions, directive lexing (`legaldown.directives`), and the §11.2 grammar | +| A CommonMark parser ([markdown-it-py](https://github.com/executablebooks/markdown-it-py)) | Block structure inside sections (nested lists, quotes, tables, code) and inline structure (emphasis, links, code spans) | + +A small markdown-it plugin recognises `{{…}}` and `{#…}` and hands them to the validator's +lexer, so the directive grammar is defined in one place only. + +The two parsers must agree on the outline. After building, the builder checks that the headings +it found match the validator's sections one-to-one (level, title, identifier). A mismatch is an +**internal error**, reported as a bug. It is never silently papered over. + +This hybrid is a stopgap. The long-term fix is for the core package to expose what the renderer +needs; [ADR 0002](decisions/0002-one-parser.md) and the [roadmap](roadmap.md) list those +upstream changes. + +### 4. Resolve + +Resolution computes everything the specification leaves to render time. It runs as an ordered +set of small resolvers. Each one reads the tree plus the style template and records its results +for the next: + +| Resolver | Spec | Produces | +|---|---|---| +| `scopes` | §13.8 | The main body and each attachment as numbering scopes, depending on the template's restart setting | +| `numbering` | §13.1 | A number for every heading under the active scheme (`decimal`, `legal-outline`, `mixed`, `none`) | +| `enumeration` | §13.2 | A label for every list item: (a) / (i) / (A), section-qualified decimals, or plain bullets. Also paragraph numbers when enabled. Ordered-list numbers in the source are ignored | +| `anchors` | §5.6, §5.7 | A collision-free output anchor for every section, item, paragraph, and definition, for example `def-` prefixes for definitions | +| `references` | §6.3, §13.3 | The designation and link for each `{{ref:}}`, including the item path, the `none` scheme (heading text), and cross-scope qualification. Otherwise `[BROKEN REF: id]`. Emits `ref-not-enumerated` | +| `attachments` | §6.4 | `{{attach:}}` resolves to the declared `title` (Rendering level reads no attachment file) | +| `terms` | §7.3, §13.4 | Display text (the `label` or the defined term) and a link to the definition, or `[UNDEFINED: id]` | +| `parties` | §3.6, §13.5 | Display text for `{{party:}}` and `{{side:}}`, with the label and `legal_name` fallbacks | +| `fields` | §10, §13.5 | Dates, money, durations, custom fields, and placeholders formatted for the locale, or their failure markers | +| `template_view` | §15.8 | Conditional units marked, every `{{choose:}}` phrase shown, drafting notes flagged | +| `unprocessed` | §17.5 | `[NOT PROCESSED: …]` markers, such as for `{{include:}}` | +| `signatures` | §2.2, §3.6 | Signature block nodes from frontmatter, when the template asks for them | + +The output is the **resolved document**: the same tree, frozen, where every node carries its +final display text, number, and anchor. From here on, nothing needs the style template's legal +settings, the definitions table, or the specification. + +Resolvers are pure functions over data, so each one can be tested in isolation with a +hand-built tree. + +### 5. Write + +A writer turns the resolved document into bytes. Writers follow one rule: + +> **A writer contains no LegalDown rules.** If a writer needs to know what a `{{ref:}}` means, +> how numbering works, or what a failure marker says, the logic is in the wrong place. It +> belongs in a resolver. + +| Writer | Milestone | Notes | +|---|---|---| +| `text` | v0.1 | Plain text (§13.6, OPTIONAL). Cheap to write, easy to diff. Serves as the **test oracle** for resolution (see [Testing](#testing)) | +| `html` | v0.1 | A single self-contained file with inline CSS generated from the style template. Semantic markup (`
`, `
    `, ``). Every output anchor is a real `id` | +| `docx` | v0.3 | python-docx. Open question: static numbers vs Word-native numbering and `REF` fields. See [ADR 0004](decisions/0004-html-first.md) | +| `pdf` | v0.4 | Engine not chosen yet. The leading option is HTML plus paged-media CSS through WeasyPrint. See [ADR 0004](decisions/0004-html-first.md) | + +## The render tree + +The tree is a set of frozen dataclasses. It is sketched here so the shape can be reviewed before +code exists. Names are provisional. + +``` +ResolvedDocument +├── meta: title, subtitle, language, locale, style name +├── title_block (title, subtitle, parties per side) +├── preamble: [Block] (§4.4; unnumbered, not referenceable) +├── body: [Section] +├── attachments: [AttachmentPart] (§13.8; title + separator, or placeholder) +└── signatures: [SignatureBlock] + +Section level, number ("4.2" or None), title: [Inline], anchor, children: [Section], blocks: [Block] + +Block Paragraph(inlines, number?, anchor?) + List(ordered, items: [ListItem]) + ListItem(label "(b)", anchor?, blocks: [Block]) ← nesting kept + Quote(blocks) · DraftingNote(blocks) · Conditional(condition, label, blocks) + Table(headers, rows, alignments) · CodeBlock(text) · Rule + +Inline Text · Emphasis · Strong · Code · Link(url) · LineBreak + CrossRef(display, target_anchor) ← {{ref:}} + TermRef(display, target_anchor) ← {{term:}} + DefinedTerm(display, anchor) ← "Term" {{def:}} + Value(kind, display) ← date / money / duration / field / party / side / attach + Blank(id, display) ← placeholder + Choice(options) ← template view only + Marker(text, rule) ← every failure marker +``` + +Before resolution, the directive nodes hold the parsed `legaldown.directives.Directive` and no +display text. After resolution, they hold display text and targets. The unresolved and resolved +variants are separate types, so a writer cannot receive an unresolved node. + +## Package layout + +``` +legaldown-render/ +├── pyproject.toml +├── src/legaldown_render/ +│ ├── __init__.py public API: render(), RenderOptions, RenderResult, load_style() +│ ├── api.py pipeline orchestration +│ ├── options.py RenderOptions +│ ├── diagnostics.py Rendering-level rules; reuses the validator's Diagnostic type +│ ├── tree/ render tree node types (unresolved and resolved) +│ ├── build/ stage 3: blocks.py, inlines.py, markdown_it plugin, outline check +│ ├── resolve/ stage 4: one module per resolver (see the table above) +│ ├── locale/ date, money, and duration formatting +│ ├── style/ style template schema, loader, validation, built-in templates +│ │ └── builtin/ default.yaml, continental.yaml, … +│ ├── writers/ +│ │ ├── base.py Writer protocol +│ │ ├── text.py +│ │ ├── html/ writer.py, default.css +│ │ ├── docx/ (v0.3) +│ │ └── pdf/ (v0.4) +│ └── cli.py `legaldown-render` command +├── tests/ +│ ├── unit/ one file per resolver and writer +│ ├── golden/ source → expected output snapshots +│ └── conformance/ the specification's fixtures corpus +└── docs/ +``` + +The import name is `legaldown_render`, not `legaldown.render`. The validator's `legaldown` +package is a regular package with its own `__init__.py`, so another distribution cannot safely +add a subpackage to it. + +## Dependencies + +| Dependency | Why | Kind | +|---|---|---| +| `legaldown-validator` | The parser, model, lexer, and Core validation | runtime, pinned to a compatible minor range | +| `markdown-it-py` | CommonMark block and inline structure | runtime | +| `babel` | Formatting dates, numbers, currencies, and units from CLDR data. See [ADR 0005](decisions/0005-locale-formatting.md) | runtime (proposed) | +| `python-docx` | DOCX writer | extra: `[docx]` | +| PDF engine (not chosen yet) | PDF writer | extra: `[pdf]` | + +Everything in the base install is pure Python. Heavy or native dependencies go behind extras, so +installing the HTML renderer never needs Pango, LibreOffice, or a TeX installation. + +## Public API (sketch) + +```python +render(source: str, *, to: str = "html", style: str | Style = "default", + answers: Mapping | None = None, options: RenderOptions | None = None) -> RenderResult + +render_document(document: legaldown.Document, ...) -> RenderResult # already parsed + +RenderResult + output: str | bytes # bytes for docx and pdf + diagnostics: list[Diagnostic] # Core and Rendering, in source order + ok: bool # no errors +``` + +The public API is `render` and `render_document`. The tree and resolvers are importable for +advanced use but are not covered by a stability promise before 1.0. + +## Errors and diagnostics + +- **Document problems** (a broken reference, an invalid date): render a failure marker, add a + diagnostic, and continue. `strict=True` raises a `RenderRefused` error that carries the + diagnostics, and produces no output. +- **Configuration problems** (an invalid style template, an unknown output format): raise before + rendering starts, with a message that names the setting. +- **Internal inconsistencies** (the outline check in stage 3): raise `InternalError` and ask for a + bug report. Never produce plausible-looking wrong output. + +Diagnostics use the validator's `Diagnostic` type and stable rule ids. Rules that only a renderer +can evaluate, such as `ref-not-enumerated` (it depends on the style template), are emitted by +the resolver responsible for them. + +## Security + +- Raw HTML in the source is never emitted (§8.7). +- Every text node is escaped for its target format. +- Link URLs must use an allowed scheme (`http`, `https`, `mailto`, relative, fragment). Anything + else, such as `javascript:`, renders as plain text with a Warning. +- At Rendering level, the only files read are the document and the chosen style template. Full + level's file access will require a configured document root (§2.3). +- Style templates are trusted configuration, not document content. A template can contain CSS, + so a hosted service must not accept templates from untrusted users without sandboxing them. + +## Testing + +| Layer | What it checks | +|---|---| +| Unit | Each resolver on hand-built trees: numbering schemes, enumeration depth, designations, locale formats, and every failure marker in §13.3–§13.5 | +| Golden | Every example in the specification repository rendered to `text` and `html` and compared byte for byte with a committed snapshot. Snapshots are regenerated on purpose, never automatically | +| Conformance | The specification's fixtures corpus. Invalid fixtures must render their failure markers and diagnostics; valid fixtures must render with no errors | +| Property | Rendering never raises on a document that parses, output is deterministic, and every output link points to an anchor that exists | + +The **plain-text writer is the resolution oracle**. It prints numbers, designations, and display +text with no layout noise, so most tests assert on text output and only the HTML-specific tests +look at markup. The same idea could give the specification a format-neutral rendering fixtures +corpus. See the [roadmap](roadmap.md). + +## Versioning and compatibility + +- The package declares `SPEC_VERSION` and `CONFORMANCE_LEVEL = "rendering"`, like the validator. +- `CONFORMANCE.md` lists every Rendering requirement that is not met yet. A gap is never left + implicit. +- CI runs against the pinned `legaldown-validator` release and also against its `main` branch, + so upstream model changes surface before release. +- The style template format has its own `version` field and is validated when loaded. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..c06768d --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,106 @@ +# Concepts + +This page covers what the renderer is responsible for, the principles behind its design, and the +vocabulary the other documents use. [Architecture](architecture.md) explains how it is built. + +Section numbers such as §13.1 refer to the +[LegalDown specification](https://github.com/ForLegalAI/LegalDown/blob/main/spec/legaldown-spec.md). + +--- + +## What a renderer is + +A LegalDown source file describes structure and meaning, not appearance. It has no section +numbers, no reference text, no formatted dates, and no list letters. The renderer produces all of +these. Its inputs are: + +- **one document**: the `.lgd` source +- **a style template**: how the output looks, including the numbering scheme, list enumeration, + locale, fonts, and page layout (§13.7) +- **render options**: per-job settings such as the output format, a style override, and strictness +- **an answers set** (optional, templates only): the values used to assemble a template before + rendering (§15.7) + +Its outputs are the **rendered file** (HTML, later DOCX and PDF) and a list of **diagnostics**. + +The source never changes during rendering. Given the same source, style template, and options, +the renderer always produces the same output. + +## Scope: the Rendering conformance level + +The specification defines three cumulative conformance levels (§17): + +| Level | Adds | Owner | +|---|---|---| +| 1 — Core | Parse and validate a single document | `legaldown-validator` | +| 2 — Rendering | Numbering, resolving directives, output formats, template view | **this package** | +| 3 — Full | Anything that reads other files: includes, attachment contents, amendments, bilingual sets | later, in whichever package owns file access | + +At Rendering level (§17.3) the renderer MUST: + +- generate section numbering, configurable per render job (§13.1) +- resolve and render every directive except `{{include:}}`, including every bracketed failure + marker (§6.3, §7.3, §13.3–§13.5, §15.8) +- apply the party and side display rules (§3.6) +- produce at least one of PDF, DOCX, or HTML (§13.6) +- strip comments (§8.6) +- render the template view when a template is rendered without answers (§15.8) + +List enumeration (§13.2), style templates (§13.7), and signature blocks (§2.2) are SHOULD. We +plan to support all three, because a legal renderer without them is not usable in practice. + +A construct beyond this level, such as `{{include:}}`, is never dropped. It renders as +`[NOT PROCESSED: include schedules/pricing.lgd]` with a Warning (§17.5). + +## Principles + +**1. There is one parser.** The LegalDown grammar (frontmatter, headings and anchors, +identifiers, directives, definitions) belongs to `legaldown-validator`. This package calls it and +never reimplements it. If the validator and the renderer disagreed about what a document means, +a document could pass validation and still render wrongly. See +[ADR 0002](decisions/0002-one-parser.md). + +**2. Legal meaning is resolved once, before any format is written.** Numbering, references, +terms, and field formatting are computed a single time into a format-neutral *resolved document*. +Writers for HTML, DOCX, and PDF only lay that document out. This is why "Section 4.2(b)" is the +same in every format, and why adding a format does not mean reimplementing §13. See +[ADR 0003](decisions/0003-resolved-document.md). + +**3. Nothing is dropped silently.** Every failure renders as a visible marker, such as +`[BROKEN REF: id]` or `[UNDEFINED: id]`, and is reported as a diagnostic with a stable rule id. A +`label` never hides a failure (§11.5). By default the renderer still renders a document that has +errors, because a draft with visible markers is useful. Under `--strict` it refuses instead. + +**4. Content and presentation stay separate.** Everything visual comes from the style template, +including numbering schemes, locale, and the labels used in the template view. The same source +must render correctly with any compatible template (§13.7). Locale is a render-time setting and +is never read from the document; `language` is only a hint (§10.1). + +**5. Output is deterministic.** Identical inputs produce byte-identical output: no timestamps, +no random ids, stable anchor names, stable ordering. This makes golden-file tests possible and +lets rendered output be diffed in Git just like the source. + +**6. It is safe with documents you did not write.** Raw HTML in the source is never emitted +(§8.7). All text is escaped for the target format. Link URLs are checked against an allow-list of +schemes. At Rendering level the renderer reads no files other than the document and the chosen +style template. + +## Vocabulary + +| Term | Meaning | +|---|---| +| **Source** | The LegalDown text of one document | +| **Document model** | The typed model `legaldown-validator` parses the source into (`legaldown.Document`) | +| **Render tree** | This package's format-neutral tree of blocks and inlines, built from the source and document model. It is structurally complete: nested lists, inline formatting, and directives are all nodes | +| **Resolution** | The stage that computes everything the specification leaves to render time: numbers, designations, link targets, display text, formatted values, markers | +| **Resolved document** | The render tree after resolution. It is immutable and holds no unresolved LegalDown semantics. It is the only input to writers | +| **Writer** | A backend that turns the resolved document into a file format (`html`, `text`, later `docx` and `pdf`). A writer contains no LegalDown rules, only layout | +| **Style template** | The configuration file that controls presentation (§13.7). See [style templates](style-templates.md) | +| **Numbering scheme** | How headings are numbered: `decimal`, `legal-outline`, `mixed`, or `none` (§13.1) | +| **Enumeration** | How list items are labelled at each nesting level, for example (a) / (i) / (A) (§13.2) | +| **Designation** | The rendered name of a reference target: "4.2", "4.2(b)(ii)", "Termination (a)" under the `none` scheme, or "Schedule A: Services, Section 2" across attachment scopes (§13.3) | +| **Numbering scope** | A region that is numbered independently: the main body, or each attachment when the style template restarts numbering (§13.8) | +| **Anchor** | A link target in the output. Section, item, and paragraph anchors share one namespace in the source (§5.6). The renderer maps them, together with definition anchors, into the output's single anchor space without collisions, for example by prefixing definition anchors with `def-` | +| **Failure marker** | The visible bracketed text the specification requires in place of something that did not resolve, for example `[BROKEN REF: id]` or `[INVALID DATE: value]` | +| **Diagnostic** | A finding with a stable rule id, a severity, a message, and a source line. It uses the same type and rule ids as the validator, plus the rules only a renderer can evaluate, such as `ref-not-enumerated` | +| **Template view** | How a template is rendered without answers: conditional units marked, every `{{choose:}}` phrase shown, drafting notes styled distinctly (§15.8) | diff --git a/docs/decisions/0001-separate-package.md b/docs/decisions/0001-separate-package.md new file mode 100644 index 0000000..1d9b76a --- /dev/null +++ b/docs/decisions/0001-separate-package.md @@ -0,0 +1,32 @@ +# 0001. A separate package and repository + +- Status: Accepted +- Date: 2026-09-23 + +## Context + +`legaldown-validator` is the reference parser, model, serializer, and Core validator. Its +headline promise is a single runtime dependency, PyYAML. A renderer needs a Markdown engine and +locale data, and eventually DOCX and PDF libraries, some of which need native system libraries. + +The specification separates the concerns already. Its conformance levels are cumulative: Core, +then Rendering, then Full (§17). The validator claims Core. + +## Decision + +The renderer is its own distribution, `legaldown-render`, in its own repository, with the import +name `legaldown_render`. It depends on `legaldown-validator` with a compatible version range. It +claims the Rendering level and runs Core validation through the validator. + +## Consequences + +- Validator users (CI checks, LLM tooling, editors) never install rendering dependencies. +- Each package has a clear conformance claim: Core for the validator, Rendering for this package. +- The two packages release independently. Styling fixes do not force validator releases. +- The renderer is the first real consumer of the validator's public API, so gaps in that API show + up here and get fixed upstream ([roadmap](../roadmap.md), U1–U3). +- Cost: a model change needs coordinated releases. Mitigations: a pinned minor range, CI against + the validator's `main` branch, and both repositories testing against the specification's + fixtures. +- The import name cannot be `legaldown.render`, because the validator's `legaldown` package is a + regular package with its own `__init__.py`. diff --git a/docs/decisions/0002-one-parser.md b/docs/decisions/0002-one-parser.md new file mode 100644 index 0000000..88531d9 --- /dev/null +++ b/docs/decisions/0002-one-parser.md @@ -0,0 +1,50 @@ +# 0002. One LegalDown parser + +- Status: Accepted +- Date: 2026-09-23 + +## Context + +If the renderer parsed LegalDown itself, two implementations could disagree about what a +document means. A document could pass validation and then render a different structure, with +anchors and definitions resolved differently from how they were checked. For a legal document +that is the worst kind of bug, because the output looks correct. + +The validator's current `Document` model was built for validation and round-trip editing. It +cannot carry everything a renderer needs: + +- Nested lists are flattened into sibling items. +- List continuation lines are joined. +- Inline content is kept as raw strings. +- Item and paragraph anchors (§5.7) are not modelled. +- Specification 0.2 (templates, §15) is not supported yet. + +## Decision + +1. **The LegalDown layer comes only from `legaldown-validator`.** That means frontmatter and + metadata, heading identifiers (including §5.3 generation and §5.5 duplicates), definitions, + and the directive grammar (§11.2, through `legaldown.directives`). The renderer never + reimplements these. +2. **CommonMark structure comes from markdown-it-py for now.** It provides nested lists, quotes, + tables, code, emphasis, and links. A plugin hands `{{…}}` and `{#…}` spans to the validator's + lexer. +3. **The builder checks the two agree.** Its headings must match the validator's sections + one-to-one (level, title, identifier). A mismatch raises an internal error. It is never + ignored. +4. **Gaps are fixed upstream.** Nested lists, source positions, and public identifier and + definition helpers go into the validator ([roadmap](../roadmap.md), U1–U3), not into + workarounds here. + +## Alternatives considered + +- **Full CommonMark tree in the validator.** This is the cleanest long-term option. Today it would + add a dependency to the validator or duplicate a CommonMark parser there. It should be revisited + once U1–U3 have landed. If the core package grows a complete tree, step 2 can go away. +- **A standalone parser in the renderer.** Rejected because it creates the disagreement risk + described above. + +## Consequences + +- The renderer depends on markdown-it-py. +- The outline check turns any disagreement between the two parsers into a loud bug report. +- Some renderer features wait for upstream changes. That is deliberate. diff --git a/docs/decisions/0003-resolved-document.md b/docs/decisions/0003-resolved-document.md new file mode 100644 index 0000000..c14d8fa --- /dev/null +++ b/docs/decisions/0003-resolved-document.md @@ -0,0 +1,33 @@ +# 0003. Resolve once, into a format-neutral document + +- Status: Accepted +- Date: 2026-09-23 + +## Context + +Most of §13 is independent of the output format: numbering, enumeration, designations, reference +qualification across attachment scopes, term and party display text, locale formatting, and +failure markers. If each writer implemented it, HTML, DOCX, and PDF could each number or +designate the same clause differently, and every new format would reimplement §13. + +## Decision + +The pipeline has an explicit **resolve** stage. It turns the render tree into an immutable +**resolved document**, where every node carries its final number, designation, display text, +output anchor, and any failure marker. Writers take only the resolved document, and they contain +no LegalDown rules. + +Unresolved and resolved directive nodes are separate types, so passing an unresolved tree to a +writer is a type error, not a runtime surprise. + +## Consequences + +- "4.2(b)" is identical in every format by construction. +- A new format is purely layout work. +- Resolution is tested once, mostly through the plain-text writer, which works as its oracle. +- Anchor disambiguation for single-namespace formats (§5.6) is decided once, in the `anchors` + resolver. +- Cost: formats with their own dynamic numbering, such as Word fields or CSS counters, get static + numbers by default. If DOCX needs Word-native numbering ([0004](0004-html-first.md)), the + resolved document keeps the structure (levels, list nesting, target anchors) so a writer can + choose to emit fields, but the resolved numbers remain the reference. diff --git a/docs/decisions/0004-html-first.md b/docs/decisions/0004-html-first.md new file mode 100644 index 0000000..e5fd13e --- /dev/null +++ b/docs/decisions/0004-html-first.md @@ -0,0 +1,40 @@ +# 0004. Output formats: text and HTML, then DOCX, then PDF + +- Status: Accepted (DOCX numbering and the PDF engine are still open) +- Date: 2026-09-23 + +## Context + +§13.6 recommends PDF, DOCX, and HTML, and makes plain text optional. Rendering level requires at +least one of the three recommended formats (§17.3). The formats differ a lot in effort and in +the dependencies they bring. + +## Decision + +- **v0.1: `text` and `html`.** HTML has no native dependencies, supports links, and is easy to + inspect. Plain text costs almost nothing and is the test oracle for resolution. +- **v0.3: `docx`** with python-docx, behind the `[docx]` extra. +- **v0.4: `pdf`**, behind the `[pdf]` extra. + +## Open questions + +**DOCX numbering.** Lawyers who receive a DOCX will edit it. Static text numbers become wrong as +soon as they insert a clause. Word-native multilevel numbering, with `REF` fields for +cross-references, keeps working after edits but is much harder to produce and test. This must be +decided before v0.3 work starts. + +**PDF engine.** + +| Option | For | Against | +|---|---|---| +| WeasyPrint (HTML + paged-media CSS) | Reuses the HTML writer. CSS supports running headers, page counters, and `target-counter()` for "see page N" | Needs Pango/Cairo system libraries | +| Typst | Excellent typography and a single binary | A third writer with its own layout model | +| LibreOffice (DOCX → PDF) | Matches the DOCX output exactly | Heavy, slow, and hard to install in CI and serverless environments | + +The leading option is WeasyPrint. It will be decided with a spike when v0.4 starts. + +## Consequences + +- The first release has a small dependency footprint and fast tests. +- The HTML writer's markup should be designed with print CSS in mind from the start, so the PDF + path can reuse it. diff --git a/docs/decisions/0005-locale-formatting.md b/docs/decisions/0005-locale-formatting.md new file mode 100644 index 0000000..97381d1 --- /dev/null +++ b/docs/decisions/0005-locale-formatting.md @@ -0,0 +1,39 @@ +# 0005. Locale formatting with Babel + +- Status: Proposed +- Date: 2026-09-23 + +## Context + +Dates, money, and durations are stored canonically in the source and formatted at render time for +the active locale (§10, §13.5). Doing this correctly takes real locale data: + +- date patterns ("1 June 2026", "June 1, 2026", "1. června 2026") +- decimal and grouping separators +- currency symbols, their placement, and minor units +- plural rules for durations ("1 month", "12 months", "2 měsíce", "5 měsíců") + +## Decision (proposed) + +Use [Babel](https://babel.pocoo.org/). It is pure Python and ships CLDR data. + +- `format_date` for dates +- `format_currency` for money, padded to the currency's minor units and never rounded (§10.3). + Values are handled as `Decimal` from the source string. +- `format_unit` for durations, with CLDR plural rules +- CLDR's ISO 4217 data to check currency codes + +All formatting goes through the `locale/` module, so the dependency sits behind one interface. + +## Alternatives considered + +- **Built-in tables for a few locales.** Small, but wrong for everyone else, and plural rules are + easy to get wrong. +- **Python's `locale` module.** Depends on the host system, is process-global, and is not + deterministic across machines. Rejected. + +## Consequences + +- The base install grows by Babel's wheel. It is pure Python. +- Output depends on the CLDR version, so Babel is pinned to a minor range and golden tests catch + changes when it is upgraded. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..2698f9f --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,25 @@ +# Decision records + +Short records of the decisions that shape this package: the context, the choice, and what it +costs. A record is never rewritten after it is accepted. A later record supersedes it instead. + +| # | Decision | Status | +|---|---|---| +| [0001](0001-separate-package.md) | A separate package and repository, depending on `legaldown-validator` | Accepted | +| [0002](0002-one-parser.md) | One LegalDown parser: the core package; CommonMark structure from markdown-it-py for now | Accepted | +| [0003](0003-resolved-document.md) | Resolve all legal semantics once, into a format-neutral resolved document | Accepted | +| [0004](0004-html-first.md) | Output formats in order: text + HTML, then DOCX, then PDF | Accepted (PDF engine open) | +| [0005](0005-locale-formatting.md) | Locale formatting with Babel (CLDR) | Proposed | + +## Template + +```markdown +# NNNN. Title + +- Status: Proposed | Accepted | Superseded by NNNN +- Date: YYYY-MM-DD + +## Context +## Decision +## Consequences +``` diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..c1f2f5e --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,70 @@ +# Roadmap + +> Milestones are in order but have no dates. Each one ends with a release to PyPI and an updated +> `CONFORMANCE.md`. + +## Before the first line of code: upstream prerequisites + +The renderer depends on `legaldown-validator` (the core package) and on the specification. These +changes belong there, not here. They are listed in the order that unblocks the most work. + +| # | Where | Change | Why the renderer needs it | +|---|---|---|---| +| U1 | validator | **Keep nested list structure** in the model. Today `- a` / ` - b` parse as two sibling items | Enumeration (a)/(i)/(A) (§13.2) and item anchors at any depth (§5.7). This also matters for Core validation of item anchors | +| U2 | validator | **Source positions** (line spans) on sections and blocks | Lets the build stage align the CommonMark tree with the validator's outline exactly, and gives diagnostics precise lines | +| U3 | validator | **Public helpers** for identifier generation (§5.3, §5.5) and definition resolution (§7.3) | The renderer must compute the same anchors and term targets the validator checks, never its own version of them | +| U4 | validator | **Specification 0.2** (templates, §15) and template **assembly** (§15.7) | The template view is a Rendering MUST (§17.3). Assembly is Core and belongs in the core package | +| U5 | specification | A **rendering fixtures corpus** (`fixtures/rendering/`): source + style settings → expected plain-text output | A neutral definition of correct numbering and designations that any renderer can be tested against, like the validation corpus | + +U1–U3 block v0.1. U4 blocks v0.2. U5 is not blocking but makes conformance claims checkable. + +## Milestones + +### v0.1 — HTML, specification 0.1 + +The first usable renderer for single, non-template documents. + +- Pipeline stages 1, 3, 4, and 5 ([architecture](architecture.md)) +- All four numbering schemes, list enumeration, and a single numbering scope (attachments render + as titles only, which is what Rendering level requires) +- Resolution of `{{ref:}}`, `{{term:}}`, `{{def:}}`, `{{party:}}`, `{{side:}}`, `{{attach:}}`, + and every field spec, with every failure marker +- Title block, preamble, and basic signature blocks +- `text` and `html` writers +- `default` style template, the template loader, and validation +- `legaldown-render` CLI +- Golden tests over every example in the specification repository +- `CONFORMANCE.md` claiming Rendering at specification 0.1, with any gaps named + +### v0.2 — Specification 0.2: templates + +- Template view (§15.8): conditional units, `{{choose:}}`, drafting notes, template labels +- Rendering with an answers set (through the core package's assembly) +- Item and paragraph anchors with section-decimal items and paragraph numbering (§5.7, §13.2) +- `continental` and `outline` built-in templates + +### v0.3 — DOCX + +- `docx` writer behind the `[docx]` extra +- Decide first: static numbers, or Word-native numbering with `REF` fields so lawyers can keep + editing in Word ([ADR 0004](decisions/0004-html-first.md)) +- Reference-document styling (`docx.reference_docx`) + +### v0.4 — PDF + +- Choose the engine ([ADR 0004](decisions/0004-html-first.md)). WeasyPrint on the HTML writer is + the leading option. The alternatives are Typst, or converting the DOCX output +- Page layout, running headers and footers, and page numbers from the style template + +### Later — Full level + +Includes, attachment contents with per-attachment numbering scopes (§13.8), amendments, and +bilingual rendering such as side-by-side columns. These need a configured document root and file +access (§2.3), which should come from the core package first. + +## Not planned + +- **Editing or round-tripping from output.** Output is one-way. The source is the only document + of record. +- **A JavaScript port.** A browser-side renderer for live preview may be worth doing later. It + would be a separate package tested against the same fixtures corpus. diff --git a/docs/style-templates.md b/docs/style-templates.md new file mode 100644 index 0000000..4beab83 --- /dev/null +++ b/docs/style-templates.md @@ -0,0 +1,107 @@ +# Style templates (draft) + +> Status: **draft for discussion.** The keys below are a proposal. Nothing is implemented, and the +> format will be versioned before its first release. + +A style template holds everything about how a document looks and nothing about what it says +(§13.7). The same source must render correctly with any compatible template. + +## Principles + +- **YAML, validated on load.** Templates are YAML files with a `version` field. Unknown keys are + errors, so a typo cannot be ignored silently. Loading reports every problem, with its key path. +- **Layered.** Values merge in this order, each overriding the one before: + 1. the built-in `default` template + 2. the chosen template, or the template named in its `extends` + 3. per-job overrides (CLI flags or `RenderOptions`) + + A template only needs to state what it changes. +- **Semantic first, per format second.** The shared part (numbering, enumeration, locale, + labels, attachments) drives resolution, so it applies to every output format. Format sections + (`html`, `docx`, `pdf`) only affect layout in that writer. +- **Language-neutral labels.** The renderer never hardcodes text such as "Section", "Schedule", + or "Drafting note". Any word it has to generate comes from `labels`, so a template in Czech + produces Czech output. + +## Sketch + +```yaml +version: 1 +name: continental +extends: default + +locale: cs-CZ # formatting locale (§10.1); not read from the document + +numbering: + scheme: decimal # decimal | legal-outline | mixed | none (§13.1) + suffix: "." # "1." vs "1" + attachments: restart # restart | continue (§13.8) + +enumeration: # §13.2 + enabled: true + levels: ["(a)", "(i)", "(A)"] + ordered_lists: renumber # renumber | enumerate (apply the levels above) + first_level: letters # letters | section-decimal (5.1, 5.2, …) + +paragraphs: + numbered: false # top-level paragraph numbering (§13.2); off by default + +references: + # How a designation is written. {number} is the target's designation, {title} its heading text. + format: "{number}" + cross_scope: "{scope_title}, {number}" # §13.3, across attachment scopes + +values: + money: { display: symbol } # symbol | code | name + date: { style: long } # short | medium | long | full, or a CLDR pattern + +placeholders: + blank: "[_____]" # §13.5 + +attachments: + separator: page-break # page-break | rule | none + non_legaldown: placeholder # placeholder | omit (§13.8) + +signatures: + enabled: true # document metadata `include_signatures: false` still wins + layout: side-by-side # side-by-side | stacked + +labels: # every word the renderer generates + drafting_note: "Poznámka pro zpracovatele" + conditional: "Pouze pokud: {condition}" + +html: + font_family: "Georgia, serif" + font_size: 11pt + max_width: 48rem + headings: + 1: { size: 1.25em, weight: 700, transform: uppercase } + 2: { size: 1.1em, weight: 700 } + extra_css: "" # appended last; trusted configuration only + +docx: # v0.3 + reference_docx: null # optional .docx whose styles are reused + +pdf: # v0.4 + page: { size: A4, margins: 25mm } + header: "{title}" + footer: "{page} / {pages}" +``` + +## Built-in templates (planned) + +| Name | Look | +|---|---| +| `default` | Decimal numbering, (a)/(i)/(A) lists, `en-US` locale, neutral serif typography | +| `continental` | Decimal numbering with section-decimal list items and numbered paragraphs, the "čl. 5 odst. 2" convention | +| `outline` | Legal outline: I. / A. / 1. / a. | + +## Open questions + +- Should `references.format` allow a generated prefix ("Section 4.2") at all? That brings back a + language-specific word. It would have to come from `labels`, and it could clash with source + text that already says "Section {{ref: x}}". +- How far should the `docx` section map onto Word styles? Should it name styles in a reference + document, or define them inline? +- Should templates be distributable as Python packages, discovered through entry points, so firms + can `pip install` their house style? From 08a217d8b960312565a5aa1b52a2e24bb677cdb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:20:37 +0000 Subject: [PATCH 02/22] Build the renderer: HTML and text output for LegalDown 0.2 Implements the pipeline from docs/architecture.md on top of legaldown-validator 0.2.0: - build: source -> render tree. The LegalDown layer (directives, defined terms, markers, identifiers) comes from the validator; CommonMark structure from markdown-it-py, joined through sentinels, with an outline check that both parsers agree on the sections. - resolve: numbering under four schemes and custom level formats, list and paragraph labels, item designations such as 2.1(b)(i), anchors, every directive with its failure marker, the template view, title block, attachment placeholders, and signature blocks. - style templates: a validated dataclass model, layering through extends and per-job --set overrides, three built-in styles, and labels in English and Czech. - writers: plain text (the test oracle) and a self-contained HTML page. - legaldown-render CLI, golden and unit tests, a conformance run over the specification's examples and fixtures, and CI. Docs updated to match: architecture, styles and preferences, roadmap, CONFORMANCE.md, and ADR 0006 (styles vs render options). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- .github/workflows/ci.yml | 78 ++ CONFORMANCE.md | 62 ++ README.md | 119 ++- docs/architecture.md | 396 +++++----- docs/concepts.md | 4 +- docs/decisions/0005-locale-formatting.md | 4 +- docs/decisions/0006-styles-and-options.md | 41 ++ docs/decisions/README.md | 3 +- docs/roadmap.md | 80 +- docs/style-templates.md | 208 ++++-- pyproject.toml | 78 ++ src/legaldown_render/__init__.py | 49 ++ src/legaldown_render/api.py | 155 ++++ src/legaldown_render/build.py | 431 +++++++++++ src/legaldown_render/cli.py | 124 ++++ src/legaldown_render/errors.py | 31 + src/legaldown_render/py.typed | 0 src/legaldown_render/resolve/__init__.py | 5 + src/legaldown_render/resolve/numbering.py | 91 +++ src/legaldown_render/resolve/resolver.py | 682 ++++++++++++++++++ src/legaldown_render/resolve/values.py | 87 +++ src/legaldown_render/style/__init__.py | 51 ++ .../style/builtin/continental.yaml | 24 + .../style/builtin/default.yaml | 7 + .../style/builtin/outline.yaml | 7 + src/legaldown_render/style/labels.py | 60 ++ src/legaldown_render/style/loader.py | 286 ++++++++ src/legaldown_render/style/model.py | 268 +++++++ src/legaldown_render/tree.py | 401 ++++++++++ src/legaldown_render/writers/__init__.py | 21 + src/legaldown_render/writers/html.py | 433 +++++++++++ src/legaldown_render/writers/text.py | 183 +++++ tests/conformance/__init__.py | 0 tests/conformance/test_spec_corpus.py | 90 +++ tests/conftest.py | 30 + tests/documents/features.lgd | 101 +++ tests/documents/template.lgd | 59 ++ tests/golden/features.continental.txt | 96 +++ tests/golden/features.default.html | 209 ++++++ tests/golden/features.default.txt | 96 +++ tests/golden/features.outline.txt | 96 +++ tests/golden/template.default.html | 149 ++++ tests/golden/template.default.txt | 57 ++ tests/test_cli.py | 44 ++ tests/test_golden.py | 31 + tests/test_render.py | 237 ++++++ tests/test_style.py | 71 ++ tests/test_values.py | 54 ++ 48 files changed, 5491 insertions(+), 398 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CONFORMANCE.md create mode 100644 docs/decisions/0006-styles-and-options.md create mode 100644 pyproject.toml create mode 100644 src/legaldown_render/__init__.py create mode 100644 src/legaldown_render/api.py create mode 100644 src/legaldown_render/build.py create mode 100644 src/legaldown_render/cli.py create mode 100644 src/legaldown_render/errors.py create mode 100644 src/legaldown_render/py.typed create mode 100644 src/legaldown_render/resolve/__init__.py create mode 100644 src/legaldown_render/resolve/numbering.py create mode 100644 src/legaldown_render/resolve/resolver.py create mode 100644 src/legaldown_render/resolve/values.py create mode 100644 src/legaldown_render/style/__init__.py create mode 100644 src/legaldown_render/style/builtin/continental.yaml create mode 100644 src/legaldown_render/style/builtin/default.yaml create mode 100644 src/legaldown_render/style/builtin/outline.yaml create mode 100644 src/legaldown_render/style/labels.py create mode 100644 src/legaldown_render/style/loader.py create mode 100644 src/legaldown_render/style/model.py create mode 100644 src/legaldown_render/tree.py create mode 100644 src/legaldown_render/writers/__init__.py create mode 100644 src/legaldown_render/writers/html.py create mode 100644 src/legaldown_render/writers/text.py create mode 100644 tests/conformance/__init__.py create mode 100644 tests/conformance/test_spec_corpus.py create mode 100644 tests/conftest.py create mode 100644 tests/documents/features.lgd create mode 100644 tests/documents/template.lgd create mode 100644 tests/golden/features.continental.txt create mode 100644 tests/golden/features.default.html create mode 100644 tests/golden/features.default.txt create mode 100644 tests/golden/features.outline.txt create mode 100644 tests/golden/template.default.html create mode 100644 tests/golden/template.default.txt create mode 100644 tests/test_cli.py create mode 100644 tests/test_golden.py create mode 100644 tests/test_render.py create mode 100644 tests/test_style.py create mode 100644 tests/test_values.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8de7216 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install ruff + - run: ruff check . + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install -e ".[dev]" + - run: pytest -q + + conformance: + name: Specification examples and fixtures + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check out the LegalDown specification + uses: actions/checkout@v4 + with: + repository: ForLegalAI/LegalDown + ref: v0.2 + path: .legaldown-spec + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -e ".[dev]" + - run: pytest -q -m conformance + env: + LEGALDOWN_SPEC_DIR: .legaldown-spec + + validator-main: + # Surfaces changes in legaldown-validator's model before they are released + # (docs/decisions/0001). Informational: a failure here is a heads-up. + name: Against legaldown-validator main + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -e ".[dev]" + - run: pip install --force-reinstall --no-deps "legaldown-validator @ git+https://github.com/ForLegalAI/legaldown-validator@main" + - run: pytest -q diff --git a/CONFORMANCE.md b/CONFORMANCE.md new file mode 100644 index 0000000..0780cfa --- /dev/null +++ b/CONFORMANCE.md @@ -0,0 +1,62 @@ +# Conformance + +`legaldown-render` 0.1.0 targets **Level 2 — Rendering** of the LegalDown specification **0.2** +(§17.3). Core parsing and validation come from `legaldown-validator` 0.2.0, which claims Level 1 +— Core. Its own [CONFORMANCE.md](https://github.com/ForLegalAI/legaldown-validator/blob/main/CONFORMANCE.md) +lists the Core rules it covers. + +It is checked against the specification's own examples and fixtures corpus (`tests/conformance/`): + +- every document renders to HTML and to text +- every internal link resolves to an anchor that exists +- each invalid fixture renders its rule's failure marker + +## Rendering requirements (§17.3) + +| Requirement | Status | +|---|---| +| Section numbering generated at render time, configurable per job (§13.1) | ✅ `decimal`, `legal-outline`, `mixed`, `none`, and custom level formats; per job with `--set numbering.scheme=…` | +| Resolution of every §11.1 directive except `{{include:}}`, with every failure marker (§6.3, §7.3, §13.3–§13.5, §15.8) | ✅ | +| `{{include:}}` below Full (§17.5) | ✅ Renders `[NOT PROCESSED: include path]` with a Warning (`render-not-processed`) | +| Party and side display rules (§3.6) | ✅ | +| At least one output format (§13.6) | ✅ HTML (recommended) and plain text (optional) | +| Comment stripping (§8.6) | ✅ | +| Template view without answers (§15.8) | ✅ Conditions marked with style-defined labels; every `{{choose:}}` phrase shown as `[a / b]`; drafting notes styled; alternatives share a number | +| `{{attach:}}` resolves to the declared title without reading the file | ✅ Linked to an attachment placeholder, or to the file when placeholders are omitted | + +## SHOULD and MAY features + +| Feature | Status | +|---|---| +| List enumeration (§13.2) | ✅ Configurable per depth; can be disabled. Ordered lists are always renumbered | +| Paragraph numbering and section-qualified items (§13.2) | ✅ Style settings; the `continental` style uses both | +| Style templates in a separate file (§13.7) | ✅ YAML, layered and validated; see [docs/style-templates.md](docs/style-templates.md) | +| Signature blocks (§2.2) | ✅ One per party, with `legal_name` and each representative | +| Labels following the document language (§13.7) | ✅ Built in for `en` and `cs`; any label can be set by a style | +| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ | +| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ | +| Automatic term recognition (§7.4, MAY) | ❌ Not supported | +| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML is dropped with the Warning | +| Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` | + +## Known limitations + +- **Templates with answers.** Assembly (§15.7) is a Core capability that `legaldown-validator` + 0.2.0 does not provide yet, so a template always renders as its template view. +- **Diagnostics have no line numbers**, because the validator's diagnostics do not carry them yet. +- **Malformed directives** (a §11.2 grammar violation) render their type's marker without a value, + for example `[INVALID AMOUNT]`. The specification defines no marker for this case. +- **Nested lists** are parsed by markdown-it-py until the validator's model keeps them + ([legaldown-validator#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)). An + outline check guarantees both parsers agree on the document's sections. + +## Beyond this level (Full, §17.4) + +These are not processed, and each renders visibly rather than being dropped: + +- includes (§12) +- attachment contents and per-attachment numbering scopes (§13.8) +- cross-scope reference qualification (§13.3) +- amendment processing (§7.5) +- bilingual sets (§14) +- file existence checks diff --git a/README.md b/README.md index 355a1ee..a290d0b 100644 --- a/README.md +++ b/README.md @@ -4,82 +4,127 @@ ### The reference renderer for [LegalDown](https://github.com/ForLegalAI/LegalDown) -**Turn a LegalDown document into HTML now, with DOCX and PDF planned. Section numbers, -cross-references, defined terms, and formatted values are all generated at render time.** +**Turn a LegalDown document into HTML or plain text, with section numbers, cross-references, +defined terms, and formatted values all generated at render time.** -Status: **design phase**. There is no code yet. Start with [the concepts](docs/concepts.md). +Targets specification **v0.2** · Conformance level **Rendering** · DOCX and PDF planned --- -## What it will do +## What it does [LegalDown](https://github.com/ForLegalAI/LegalDown) documents never hardcode section numbers, -reference text, or formatted values. The source says `{{ref: payment-terms}}`, and the renderer -turns it into "Section 4.2" with a link to that section. This package is that renderer: +reference text, or formatted values. The source says `{{ref: late-payment}}`, and the renderer +turns it into "4.2(b)" with a link. This package is that renderer: -🔢 **Numbers** sections and list items under a configurable scheme (§13.1, §13.2). +🔢 **Numbers** sections, list items, and paragraphs under a configurable scheme (§13.1, §13.2). +References to items resolve to full designations such as "2.1(b)(i)". 🔗 **Resolves** cross-references, defined terms, parties, sides, and attachment references into -display text with links (§13.3, §13.4, §13.5). +display text with links (§13.3–§13.5). -🌍 **Formats** dates, money, and durations for a locale chosen at render time, not stored in the -document (§10). +🌍 **Formats** dates, money, and durations for a locale chosen at render time. A Czech locale +gives "1. června 2026", "10 000,00 Kč", and "12 měsíců", and amounts are never rounded (§10). -🎨 **Applies a style template**, so the same source can render as a US-style contract or a +🎨 **Applies a style template**, so the same source renders as a US-style contract or a continental one without editing the text (§13.7). +📝 **Shows templates as templates.** Conditional clauses are marked, every `{{choose:}}` phrase is +shown, drafting notes are styled, and placeholders render as blanks (§15.8). + 🚩 **Never hides a problem.** Anything unresolved renders as a visible marker, such as -`[BROKEN REF: id]`, and is reported with the specification's stable rule id. Nothing is ever -dropped silently (§17.5). +`[BROKEN REF: id]`, and is reported with the specification's stable rule id. Nothing is dropped +silently (§17.5). -📄 **Writes** HTML first, then DOCX, then PDF. Every output format comes from the same resolved -document, so a clause gets the same number in all of them. +Parsing and Core validation come from +[`legaldown-validator`](https://github.com/ForLegalAI/legaldown-validator), the reference parser. +This package never reimplements the LegalDown grammar. -It targets conformance **Level 2 — Rendering** of the specification (§17.3). Parsing and Core -validation come from [`legaldown-validator`](https://github.com/ForLegalAI/legaldown-validator), -the reference parser. This package does not reimplement the LegalDown grammar. +## Install -## Planned usage +```bash +pip install legaldown-render +``` -> Everything in this section is illustrative. The API is not implemented yet and may change. +Python 3.11 or newer. The dependencies are all pure Python: `legaldown-validator`, +`markdown-it-py`, `babel`, and `pyyaml`. -```bash -pip install legaldown-render # HTML -pip install "legaldown-render[docx,pdf]" # later: DOCX and PDF +## Command line -legaldown-render contract.lgd -o contract.html -legaldown-render contract.lgd -o contract.html --style continental --locale cs-CZ -legaldown-render contract.lgd -o contract.html --strict # refuse to render on any error +```bash +legaldown-render contract.lgd -o contract.html # HTML page +legaldown-render contract.lgd -o contract.txt # plain text (format from extension) +legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html +legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumeration.enabled=false +legaldown-render contract.lgd --strict # refuse if the document has errors +legaldown-render --print-style --style continental # every setting, as YAML ``` +Diagnostics go to stderr, one per line with its rule id. The exit code is 0 when the document +rendered, even if it has errors (they show as markers), 1 when rendering was refused or the file +cannot be read, and 2 for an invalid style or setting. + +## Python + ```python from legaldown_render import render -result = render(open("contract.lgd").read(), to="html", style="default") +result = render(open("contract.lgd").read(), format="html", style="continental", locale="cs-CZ") for diagnostic in result.diagnostics: print(diagnostic.level, diagnostic.rule, diagnostic.message) open("contract.html", "w").write(result.output) ``` +`render()` also accepts a `RenderOptions`. Problems in the document never raise. Only an invalid +style (`StyleError`), an unreadable document (`DocumentError`), or strict mode +(`RenderRefused`) do. + +## Styles and preferences + +A **style template** says how documents look: numbering, list enumeration, locale, labels, +typography. It is a YAML file you can share across a team: + +```yaml +version: 1 +extends: continental +locale: cs-CZ +numbering: + levels: + - { counter: decimal, label: "Článek {n}", ref: "{n}" } +definitions: { style: small-caps } +typography: { font_family: "Georgia, serif", justify: true } +``` + +**Render options** say what one job does: the output format, strict mode, a full page or an HTML +fragment. Values layer in this order: built-in defaults, then the `extends` chain, then your +style, then per-job `--set` overrides. Every value is validated before rendering starts. See +[Styles and preferences](docs/style-templates.md). + +Built-in styles are `default` (1. / 1.1, with (a) / (i) list items), `continental` (numbered +articles, 2.1-style items, numbered paragraphs), and `outline` (I. / A. / 1. / a.). + ## Documentation | Document | What it covers | |---|---| -| [Concepts](docs/concepts.md) | The ideas and vocabulary: what a renderer is responsible for, the principles, the terms used in the rest of the docs | -| [Architecture](docs/architecture.md) | The pipeline, the render tree, package layout, dependencies, error handling, testing | -| [Style templates](docs/style-templates.md) | Draft of the style template format | -| [Roadmap](docs/roadmap.md) | Milestones, and the changes needed in `legaldown-validator` and the specification first | +| [Concepts](docs/concepts.md) | What a renderer is responsible for, principles, and vocabulary | +| [Architecture](docs/architecture.md) | The pipeline, the render tree, the package layout, and testing | +| [Styles and preferences](docs/style-templates.md) | The style format, layering, and every setting | +| [Conformance](CONFORMANCE.md) | What the Rendering level requires, and what is covered | +| [Roadmap](docs/roadmap.md) | Milestones, and the changes needed upstream | | [Decision records](docs/decisions/) | Why the main choices were made | -## Related repositories +## Development -| Repository | Role | -|---|---| -| [LegalDown](https://github.com/ForLegalAI/LegalDown) | The specification and its conformance fixtures | -| [legaldown-validator](https://github.com/ForLegalAI/legaldown-validator) | Reference parser, document model, serializer, and Core validator; this package depends on it | -| **legaldown-render** | Rendering (this repository) | +```bash +pip install -e ".[dev]" +pytest # unit, golden, and CLI tests +pytest --update-golden # regenerate tests/golden/ after an intended output change +LEGALDOWN_SPEC_DIR=../LegalDown pytest -m conformance # the specification's examples and fixtures +ruff check . +``` ## License diff --git a/docs/architecture.md b/docs/architecture.md index 28831e1..12a8276 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,286 +1,214 @@ # Architecture -> Status: **draft, not implemented.** This describes the intended design. The decisions behind it -> are in [decision records](decisions/). Where this page and an accepted decision record disagree, -> the decision record wins, and this page should be fixed. - -Read [Concepts](concepts.md) first for the vocabulary. +This page describes how the renderer is built. The decisions behind it are in +[decision records](decisions/). Read [Concepts](concepts.md) first for the vocabulary, and +[Styles and preferences](style-templates.md) for configuration. --- ## The pipeline ``` - source .lgd ─┐ - answers ─────┤ (templates only) - ▼ - ┌──────────────────────┐ - │ 1. Parse & validate │ legaldown-validator: Document model + Core diagnostics - └──────────┬───────────┘ - ▼ - ┌──────────────────────┐ - │ 2. Assemble │ only with an answers set (§15.7); otherwise template view - └──────────┬───────────┘ - ▼ - ┌──────────────────────┐ - │ 3. Build │ source + Document → render tree (blocks, inlines, directive nodes) - └──────────┬───────────┘ - ▼ - ┌──────────────────────┐ style template ─┐ - │ 4. Resolve │ ◄────────────────┘ numbering, enumeration, anchors, references, - └──────────┬───────────┘ terms, fields, parties → resolved document - ▼ + Rendering-level diagnostics - ┌──────────────────────┐ - │ 5. Write │ html │ text │ docx (later) │ pdf (later) - └──────────┬───────────┘ - ▼ - RenderResult(output, diagnostics) + source .lgd + │ normalize_source(): strip BOM, unify line endings + ▼ + ┌─────────────────────────┐ + │ 1. Parse & validate │ legaldown-validator: Document, ValidationResult (Core diagnostics, + └───────────┬─────────────┘ resolved identifiers, party/side/definition/attachment lookups) + ▼ + ┌─────────────────────────┐ + │ 3. Build build.py │ source + validator → render tree (source inlines) + └───────────┬─────────────┘ + ▼ style template (style/) ──┐ + ┌─────────────────────────┐ │ + │ 4. Resolve resolve/ │ ◄──────────────────────────┘ numbers, labels, anchors, display text, + └───────────┬─────────────┘ failure markers + Rendering diagnostics + ▼ + ┌─────────────────────────┐ + │ 5. Write writers/ │ html │ text (docx, pdf later) + └───────────┬─────────────┘ + ▼ + RenderResult(output, diagnostics, format, tree, style) ``` -Each stage has a single job and hands an immutable value to the next one. Only stage 4 knows -about the style template's legal settings. Only stage 5 knows about file formats. - -### 1. Parse and validate - -This stage calls `legaldown.parse_document` and `legaldown.validate_document` from -`legaldown-validator`. The result is: - -- the **metadata**: title, sides, parties, attachments, `field_types`, language -- the **section outline** and identifiers, including auto-generated ones (§5.3) and duplicate - handling (§5.5) -- the **definitions** table (§7) -- the **Core diagnostics** - -The renderer never reimplements any of this ([ADR 0002](decisions/0002-one-parser.md)). +Stage 2, assembly with an answers set (§15.7), is not implemented. It belongs in the core +package, and `legaldown-validator` 0.2.0 does not provide it yet ([roadmap](roadmap.md)). A +template is always rendered as its **template view** (§15.8). -### 2. Assemble (templates only) +`api.render()` runs the pipeline. Each stage returns frozen values. Only stage 4 reads the style's +semantic settings, and only stage 5 knows about file formats. -Given an answers set, the template is assembled into a plain document first (§15.7), and that -document is rendered. Assembly is a text-to-text transformation that the specification places at -Core (the fixtures' `assembly/` cases), so it belongs in the core package. The renderer calls it -and does not own it. Without answers, stages 3 and 4 produce the template view (§15.8) instead. - -Assembly depends on the core package supporting specification 0.2 first. See the -[roadmap](roadmap.md). +### 1. Parse and validate -### 3. Build the render tree +`legaldown.parse_document` and `legaldown.validate_document` provide: -This stage turns the document into a **render tree**: a format-neutral tree that is structurally -complete. Nested lists stay nested, inline formatting becomes nodes, and every directive is a -typed node that has not been resolved yet. +- the **metadata**: title, sides and parties, attachments, questions, language +- the **section identifiers** exactly as the validator resolved them: explicit `{#id}`, or + generated by §5.3 with §5.5 collision handling. These come from `ValidationResult.sections`, + so the renderer can never compute a different anchor than the one the validator checked +- **lookups** for definitions, parties, sides, and attachments, already reduced to display names + (§3.6) +- the **Core diagnostics**, each with a stable rule id -The current `legaldown.Document` model is designed for validation and round-trip editing, and it -loses some things a renderer needs: +A document that cannot be read at all, such as one whose frontmatter is not a YAML mapping, +raises `DocumentError`. Every other problem renders. -- Nested list items are flattened into siblings. -- Continuation lines are joined. -- Inline text is kept as raw strings. +### 3. Build the render tree (`build.py`) -So the tree is built from two sources: +The validator's model does not keep nested lists or inline structure yet +([ForLegalAI/legaldown-validator#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)). +So, per [ADR 0002](decisions/0002-one-parser.md), the builder takes: | From | What | |---|---| -| `legaldown-validator` | Metadata, section identifiers and titles, definitions, directive lexing (`legaldown.directives`), and the §11.2 grammar | -| A CommonMark parser ([markdown-it-py](https://github.com/executablebooks/markdown-it-py)) | Block structure inside sections (nested lists, quotes, tables, code) and inline structure (emphasis, links, code spans) | +| `legaldown-validator` | Directive lexing (`legaldown.directives.lex`), defined-term spans (`find_definition_anchors`), heading and paragraph markers (`legaldown.markers`), section identifiers, frontmatter splitting | +| markdown-it-py (CommonMark + tables) | Block structure (nested lists, quotes, tables, code) and inline structure (emphasis, links, code spans) | -A small markdown-it plugin recognises `{{…}}` and `{#…}` and hands them to the validator's -lexer, so the directive grammar is defined in one place only. +**Sentinels** join the two. Before markdown-it sees the body, every directive, and every +`"Term" {{def:}}` span, is replaced with a private-use token (``). Markdown +therefore cannot reinterpret directive syntax, such as an underscore in an identifier or a pipe +in a table cell. Text nodes are then split on the sentinels, and the directives return as +`DirectiveSource` and `DefinitionSource` nodes. Where Markdown shows text literally (code, URLs), +the sentinels are turned back into their source text. -The two parsers must agree on the outline. After building, the builder checks that the headings -it found match the validator's sections one-to-one (level, title, identifier). A mismatch is an -**internal error**, reported as a bug. It is never silently papered over. +**Markers** (`{#id when=…}`) are taken off only where §5.7 and §15.3 allow one: -This hybrid is a stopgap. The long-term fix is for the core package to expose what the renderer -needs; [ADR 0002](decisions/0002-one-parser.md) and the [roadmap](roadmap.md) list those -upstream changes. +- after a heading +- at the end of a top-level paragraph +- at the end of a list item's first paragraph, outside block quotes +- in the preamble, with a condition only -### 4. Resolve +Anywhere else, a marker stays literal text, and the validator reports it. -Resolution computes everything the specification leaves to render time. It runs as an ordered -set of small resolvers. Each one reads the tree plus the style template and records its results -for the next: +**Outline check.** The builder's top-level headings must match the validator's sections one to +one. A mismatch raises `InternalError`: the two parsers disagree, and the renderer never guesses. +The whole specification fixtures corpus passes this check. -| Resolver | Spec | Produces | -|---|---|---| -| `scopes` | §13.8 | The main body and each attachment as numbering scopes, depending on the template's restart setting | -| `numbering` | §13.1 | A number for every heading under the active scheme (`decimal`, `legal-outline`, `mixed`, `none`) | -| `enumeration` | §13.2 | A label for every list item: (a) / (i) / (A), section-qualified decimals, or plain bullets. Also paragraph numbers when enabled. Ordered-list numbers in the source are ignored | -| `anchors` | §5.6, §5.7 | A collision-free output anchor for every section, item, paragraph, and definition, for example `def-` prefixes for definitions | -| `references` | §6.3, §13.3 | The designation and link for each `{{ref:}}`, including the item path, the `none` scheme (heading text), and cross-scope qualification. Otherwise `[BROKEN REF: id]`. Emits `ref-not-enumerated` | -| `attachments` | §6.4 | `{{attach:}}` resolves to the declared `title` (Rendering level reads no attachment file) | -| `terms` | §7.3, §13.4 | Display text (the `label` or the defined term) and a link to the definition, or `[UNDEFINED: id]` | -| `parties` | §3.6, §13.5 | Display text for `{{party:}}` and `{{side:}}`, with the label and `legal_name` fallbacks | -| `fields` | §10, §13.5 | Dates, money, durations, custom fields, and placeholders formatted for the locale, or their failure markers | -| `template_view` | §15.8 | Conditional units marked, every `{{choose:}}` phrase shown, drafting notes flagged | -| `unprocessed` | §17.5 | `[NOT PROCESSED: …]` markers, such as for `{{include:}}` | -| `signatures` | §2.2, §3.6 | Signature block nodes from frontmatter, when the template asks for them | - -The output is the **resolved document**: the same tree, frozen, where every node carries its -final display text, number, and anchor. From here on, nothing needs the style template's legal -settings, the definitions table, or the specification. - -Resolvers are pure functions over data, so each one can be tested in isolation with a -hand-built tree. - -### 5. Write - -A writer turns the resolved document into bytes. Writers follow one rule: - -> **A writer contains no LegalDown rules.** If a writer needs to know what a `{{ref:}}` means, -> how numbering works, or what a failure marker says, the logic is in the wrong place. It -> belongs in a resolver. - -| Writer | Milestone | Notes | -|---|---|---| -| `text` | v0.1 | Plain text (§13.6, OPTIONAL). Cheap to write, easy to diff. Serves as the **test oracle** for resolution (see [Testing](#testing)) | -| `html` | v0.1 | A single self-contained file with inline CSS generated from the style template. Semantic markup (`
    `, `
      `, ``). Every output anchor is a real `id` | -| `docx` | v0.3 | python-docx. Open question: static numbers vs Word-native numbering and `REF` fields. See [ADR 0004](decisions/0004-html-first.md) | -| `pdf` | v0.4 | Engine not chosen yet. The leading option is HTML plus paged-media CSS through WeasyPrint. See [ADR 0004](decisions/0004-html-first.md) | +Everything else is stripped at this stage: -## The render tree +- HTML comments (§8.6) +- raw HTML, which is dropped and counted for a `raw-html` Warning (§8.7) +- `> [!DRAFTING]` quotes, which become `DraftingNote` blocks (§15.6) -The tree is a set of frozen dataclasses. It is sketched here so the shape can be reviewed before -code exists. Names are provisional. +### 4. Resolve (`resolve/`) -``` -ResolvedDocument -├── meta: title, subtitle, language, locale, style name -├── title_block (title, subtitle, parties per side) -├── preamble: [Block] (§4.4; unnumbered, not referenceable) -├── body: [Section] -├── attachments: [AttachmentPart] (§13.8; title + separator, or placeholder) -└── signatures: [SignatureBlock] - -Section level, number ("4.2" or None), title: [Inline], anchor, children: [Section], blocks: [Block] - -Block Paragraph(inlines, number?, anchor?) - List(ordered, items: [ListItem]) - ListItem(label "(b)", anchor?, blocks: [Block]) ← nesting kept - Quote(blocks) · DraftingNote(blocks) · Conditional(condition, label, blocks) - Table(headers, rows, alignments) · CodeBlock(text) · Rule - -Inline Text · Emphasis · Strong · Code · Link(url) · LineBreak - CrossRef(display, target_anchor) ← {{ref:}} - TermRef(display, target_anchor) ← {{term:}} - DefinedTerm(display, anchor) ← "Term" {{def:}} - Value(kind, display) ← date / money / duration / field / party / side / attach - Blank(id, display) ← placeholder - Choice(options) ← template view only - Marker(text, rule) ← every failure marker -``` +`Resolver.run()` works in three steps: -Before resolution, the directive nodes hold the parsed `legaldown.directives.Directive` and no -display text. After resolution, they hold display text and targets. The unresolved and resolved -variants are separate types, so a writer cannot receive an unresolved node. +1. **Survey.** It collects which definitions exist, whether template constructs are used, and + which placeholder ids are used with conflicting types (§10.7). +2. **Structure.** It numbers sections under the style's level formats. Alternatives, meaning + consecutive conditional siblings with one identifier, share a number (§15.8). It then labels + list items by depth and top-level paragraphs, and allocates output anchors. Every anchor + target is registered with its **designation**: "4.2", "4.2(b)(ii)", or "Termination (a)" + under the `none` scheme. +3. **Inlines.** It replaces every source inline with a resolved one: + +| Directive | Resolves to | Failure markers | +|---|---|---| +| `{{ref:}}` | `CrossRef(designation, anchor)` (§13.3). A ref to an unnumbered item or paragraph falls back to its section's designation, with `ref-not-enumerated` | `[BROKEN REF: id]` | +| `{{term:}}` | `TermRef(label or term, def anchor)` (§7.3, §13.4) | `[UNDEFINED: id]` | +| `"Term" {{def:}}` | `DefinedTerm` without quotation marks, anchored at `def:` (§7.2) | n/a | +| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005) | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` | +| `{{party:}}` `{{side:}}` | `Value(label or display name)` (§3.6) | `[INVALID PARTY: …]`, `[UNKNOWN PARTY: …]`, and the side forms | +| `{{field:}}` | `Value(raw value)` | `[INVALID FIELD: …]` | +| `{{placeholder:}}` | `Blank`, with the question's prompt if the style asks for it | `[INVALID PLACEHOLDER: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION UNIT: …]` | +| `{{attach:}}` | `Value(label or title)`, linked to the attachment placeholder or file (§6.4) | `[UNKNOWN ATTACHMENT: id]` | +| `{{choose:}}` | `Choice(all phrases)`, shown as `[a / b]` (§15.8) | `[INVALID CHOICE: q]` | +| `{{include:}}` | Not processed at this level (§17.5), with `render-not-processed` | `[NOT PROCESSED: include path]` | +| unknown name | Never printed verbatim (§11.5) | `[UNKNOWN DIRECTIVE: name]` | + +A `label` never hides a failure marker (§11.5). Frontmatter strings go through the same resolver, +so placeholders in frontmatter render as blanks (§3.10). + +The resolver also builds the **title block** (effective date, sides and parties), **attachment +placeholders** (§13.8; files are not read at this level), and **signature blocks** (§2.2, always +with `legal_name`, per §3.6). + +**Anchors.** Section, item, paragraph, and attachment anchors use their identifiers. Definition +anchors are `def:`. Identifiers cannot contain `:`, so the two can never collide in HTML's +single id space (§5.6). When alternatives share an identifier, only the first keeps the anchor, +so ids stay unique. + +### 5. Write (`writers/`) + +> **A writer contains no LegalDown rules.** `tree.assert_resolved()` rejects a tree that still +> holds a source inline. + +| Writer | Notes | +|---|---| +| `TextWriter` | Plain text (§13.6). Line breaks inside paragraphs are joined, and lists are indented with their labels. It is the **test oracle** for resolution | +| `HtmlWriter` | A self-contained HTML5 page, or with `standalone=False` just the `
      `. Semantic markup, with labels as real text rather than CSS counters, and a stylesheet generated from the style's presentation settings, including print rules | +| DOCX, PDF | Planned ([ADR 0004](decisions/0004-html-first.md)) | ## Package layout ``` -legaldown-render/ -├── pyproject.toml -├── src/legaldown_render/ -│ ├── __init__.py public API: render(), RenderOptions, RenderResult, load_style() -│ ├── api.py pipeline orchestration -│ ├── options.py RenderOptions -│ ├── diagnostics.py Rendering-level rules; reuses the validator's Diagnostic type -│ ├── tree/ render tree node types (unresolved and resolved) -│ ├── build/ stage 3: blocks.py, inlines.py, markdown_it plugin, outline check -│ ├── resolve/ stage 4: one module per resolver (see the table above) -│ ├── locale/ date, money, and duration formatting -│ ├── style/ style template schema, loader, validation, built-in templates -│ │ └── builtin/ default.yaml, continental.yaml, … -│ ├── writers/ -│ │ ├── base.py Writer protocol -│ │ ├── text.py -│ │ ├── html/ writer.py, default.css -│ │ ├── docx/ (v0.3) -│ │ └── pdf/ (v0.4) -│ └── cli.py `legaldown-render` command -├── tests/ -│ ├── unit/ one file per resolver and writer -│ ├── golden/ source → expected output snapshots -│ └── conformance/ the specification's fixtures corpus -└── docs/ +src/legaldown_render/ +├── __init__.py public API, __version__, SPEC_VERSION, CONFORMANCE_LEVEL +├── api.py render(), render_file(), RenderOptions, RenderResult +├── cli.py the legaldown-render command +├── errors.py RenderError, DocumentError, RenderRefused, InternalError +├── tree.py render tree nodes (source and resolved inlines, blocks, document) +├── build.py stage 3 +├── resolve/ +│ ├── resolver.py stage 4 +│ ├── numbering.py counters, the §13.1 scheme presets, label templates +│ └── values.py Babel formatting of dates, money, durations +├── style/ +│ ├── model.py the style dataclasses (every field has a default) +│ ├── loader.py layering, extends, overrides, validation +│ ├── labels.py built-in labels per language (en, cs) +│ └── builtin/ default.yaml, continental.yaml, outline.yaml +└── writers/ html.py, text.py ``` -The import name is `legaldown_render`, not `legaldown.render`. The validator's `legaldown` -package is a regular package with its own `__init__.py`, so another distribution cannot safely -add a subpackage to it. - ## Dependencies -| Dependency | Why | Kind | -|---|---|---| -| `legaldown-validator` | The parser, model, lexer, and Core validation | runtime, pinned to a compatible minor range | -| `markdown-it-py` | CommonMark block and inline structure | runtime | -| `babel` | Formatting dates, numbers, currencies, and units from CLDR data. See [ADR 0005](decisions/0005-locale-formatting.md) | runtime (proposed) | -| `python-docx` | DOCX writer | extra: `[docx]` | -| PDF engine (not chosen yet) | PDF writer | extra: `[pdf]` | - -Everything in the base install is pure Python. Heavy or native dependencies go behind extras, so -installing the HTML renderer never needs Pango, LibreOffice, or a TeX installation. - -## Public API (sketch) - -```python -render(source: str, *, to: str = "html", style: str | Style = "default", - answers: Mapping | None = None, options: RenderOptions | None = None) -> RenderResult - -render_document(document: legaldown.Document, ...) -> RenderResult # already parsed +| Dependency | Why | +|---|---| +| `legaldown-validator>=0.2.0,<0.3` | The only LegalDown parser; Core validation | +| `markdown-it-py` | CommonMark structure (ADR 0002) | +| `babel` | CLDR locale data (ADR 0005) | +| `pyyaml` | Style templates | -RenderResult - output: str | bytes # bytes for docx and pdf - diagnostics: list[Diagnostic] # Core and Rendering, in source order - ok: bool # no errors -``` +All of them are pure Python. The DOCX and PDF writers will bring their dependencies in as extras. -The public API is `render` and `render_document`. The tree and resolvers are importable for -advanced use but are not covered by a stability promise before 1.0. +The renderer imports a few names from `legaldown` submodules that the validator does not +re-export at the top level: `legaldown.directives.lex`, `legaldown.markers`, +`legaldown.parser.FRONTMATTER_RE`, `legaldown.validator.helpers`, and +`legaldown.validator.patterns`. The pin to one minor version exists because of them. Asking the +validator to export them publicly is roadmap item U3. ## Errors and diagnostics -- **Document problems** (a broken reference, an invalid date): render a failure marker, add a - diagnostic, and continue. `strict=True` raises a `RenderRefused` error that carries the - diagnostics, and produces no output. -- **Configuration problems** (an invalid style template, an unknown output format): raise before - rendering starts, with a message that names the setting. -- **Internal inconsistencies** (the outline check in stage 3): raise `InternalError` and ask for a - bug report. Never produce plausible-looking wrong output. +- **Document problems** render as failure markers and are listed in `diagnostics`. `strict=True` + raises `RenderRefused` instead, carrying the diagnostics. +- **Configuration problems** (an invalid style, setting, locale, or format) raise before + rendering, with every problem named. +- **Internal inconsistencies** raise `InternalError`, and the CLI exits with 70. -Diagnostics use the validator's `Diagnostic` type and stable rule ids. Rules that only a renderer -can evaluate, such as `ref-not-enumerated` (it depends on the style template), are emitted by -the resolver responsible for them. +Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate: +`ref-not-enumerated` and `raw-html`, both specification rule ids, plus renderer-specific ids +prefixed `render-`: `render-not-processed` and `render-locale-fallback`. Validator diagnostics +have no line numbers yet, and neither do the renderer's. ## Security - Raw HTML in the source is never emitted (§8.7). -- Every text node is escaped for its target format. -- Link URLs must use an allowed scheme (`http`, `https`, `mailto`, relative, fragment). Anything - else, such as `javascript:`, renders as plain text with a Warning. -- At Rendering level, the only files read are the document and the chosen style template. Full - level's file access will require a configured document root (§2.3). -- Style templates are trusted configuration, not document content. A template can contain CSS, - so a hosted service must not accept templates from untrusted users without sandboxing them. +- Every text node and attribute is escaped. +- Links and images whose URL has a scheme other than `http`, `https`, `mailto`, or `tel` render + as text. markdown-it-py also refuses `javascript:`-style URLs when parsing. +- At Rendering level, the only files read are the document and the style template. Attachment + files and includes are never opened. +- Style values that are placed into CSS are stripped of `;{}<>`. `html.extra_css` is trusted + configuration, so a hosted service must not accept styles from untrusted users. ## Testing -| Layer | What it checks | +| Suite | What it checks | |---|---| -| Unit | Each resolver on hand-built trees: numbering schemes, enumeration depth, designations, locale formats, and every failure marker in §13.3–§13.5 | -| Golden | Every example in the specification repository rendered to `text` and `html` and compared byte for byte with a committed snapshot. Snapshots are regenerated on purpose, never automatically | -| Conformance | The specification's fixtures corpus. Invalid fixtures must render their failure markers and diagnostics; valid fixtures must render with no errors | -| Property | Rendering never raises on a document that parses, output is deterministic, and every output link points to an anchor that exists | - -The **plain-text writer is the resolution oracle**. It prints numbers, designations, and display -text with no layout noise, so most tests assert on text output and only the HTML-specific tests -look at markup. The same idea could give the specification a format-neutral rendering fixtures -corpus. See the [roadmap](roadmap.md). - -## Versioning and compatibility - -- The package declares `SPEC_VERSION` and `CONFORMANCE_LEVEL = "rendering"`, like the validator. -- `CONFORMANCE.md` lists every Rendering requirement that is not met yet. A gap is never left - implicit. -- CI runs against the pinned `legaldown-validator` release and also against its `main` branch, - so upstream model changes surface before release. -- The style template format has its own `version` field and is validated when loaded. +| `tests/test_golden.py` | The test documents under each built-in style, compared byte for byte with `tests/golden/`. Regenerate with `pytest --update-golden` and review the diff | +| `tests/test_render.py` | Numbering schemes, designations, fallbacks, every failure marker, template view, labels by language, HTML safety, unique anchors, determinism, strict mode, locales | +| `tests/test_style.py`, `tests/test_values.py` | Style layering and validation; counters and locale formatting | +| `tests/test_cli.py` | The command | +| `tests/conformance/` | Every example and fixture in the specification repository renders, every internal link has a target, and each invalid fixture renders its rule's failure marker. Needs `$LEGALDOWN_SPEC_DIR` or a sibling `../LegalDown` checkout | diff --git a/docs/concepts.md b/docs/concepts.md index c06768d..9582ba4 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -100,7 +100,7 @@ style template. | **Enumeration** | How list items are labelled at each nesting level, for example (a) / (i) / (A) (§13.2) | | **Designation** | The rendered name of a reference target: "4.2", "4.2(b)(ii)", "Termination (a)" under the `none` scheme, or "Schedule A: Services, Section 2" across attachment scopes (§13.3) | | **Numbering scope** | A region that is numbered independently: the main body, or each attachment when the style template restarts numbering (§13.8) | -| **Anchor** | A link target in the output. Section, item, and paragraph anchors share one namespace in the source (§5.6). The renderer maps them, together with definition anchors, into the output's single anchor space without collisions, for example by prefixing definition anchors with `def-` | +| **Anchor** | A link target in the output. Section, item, and paragraph anchors share one namespace in the source (§5.6). The renderer maps them, together with definition anchors, into the output's single anchor space without collisions, by prefixing definition anchors with `def:`, a character no identifier can contain | | **Failure marker** | The visible bracketed text the specification requires in place of something that did not resolve, for example `[BROKEN REF: id]` or `[INVALID DATE: value]` | -| **Diagnostic** | A finding with a stable rule id, a severity, a message, and a source line. It uses the same type and rule ids as the validator, plus the rules only a renderer can evaluate, such as `ref-not-enumerated` | +| **Diagnostic** | A finding with a stable rule id, a severity, and a message (source lines will follow once the validator provides them). It uses the same type and rule ids as the validator, plus the rules only a renderer can evaluate, such as `ref-not-enumerated` | | **Template view** | How a template is rendered without answers: conditional units marked, every `{{choose:}}` phrase shown, drafting notes styled distinctly (§15.8) | diff --git a/docs/decisions/0005-locale-formatting.md b/docs/decisions/0005-locale-formatting.md index 97381d1..cc1828e 100644 --- a/docs/decisions/0005-locale-formatting.md +++ b/docs/decisions/0005-locale-formatting.md @@ -1,6 +1,6 @@ # 0005. Locale formatting with Babel -- Status: Proposed +- Status: Accepted (implemented in 0.1.0) - Date: 2026-09-23 ## Context @@ -13,7 +13,7 @@ the active locale (§10, §13.5). Doing this correctly takes real locale data: - currency symbols, their placement, and minor units - plural rules for durations ("1 month", "12 months", "2 měsíce", "5 měsíců") -## Decision (proposed) +## Decision Use [Babel](https://babel.pocoo.org/). It is pure Python and ships CLDR data. diff --git a/docs/decisions/0006-styles-and-options.md b/docs/decisions/0006-styles-and-options.md new file mode 100644 index 0000000..8076211 --- /dev/null +++ b/docs/decisions/0006-styles-and-options.md @@ -0,0 +1,41 @@ +# 0006. Style templates are data; render options are separate + +- Status: Accepted +- Date: 2026-09-23 + +## Context + +A renderer has many settings, and they differ in kind. Some describe a house style that a firm +applies to every document: numbering, enumeration, locale, typography. Others describe one job: +write HTML or text, refuse on errors, produce a page or a fragment. The specification asks for +style templates in a separate file, independent of the document (§13.7). It also asks for the +numbering scheme to be configurable per render job (§13.1). + +## Decision + +1. **Two layers.** A *style template* (YAML) holds presentation. *Render options* (CLI flags, + `RenderOptions`) hold the job. Options never change how a document looks, so they are never + stored in a style. A style never says what to output. +2. **Per-job style overrides** use one generic mechanism: dotted keys (`--set numbering.scheme=none`, + `overrides={...}`). This satisfies §13.1 without a flag per setting. `--locale` is the one + shorthand, because it is the most common override. +3. **Layering** runs from built-in defaults, through the `extends` chain, to the chosen style, + and then to the overrides. Mappings merge field by field. +4. **Validated dataclasses.** The style model is a tree of frozen dataclasses with a default for + every field. The loader checks the merged data against it and reports every problem with its + key path before rendering starts. +5. **Numbering as data.** Heading schemes, list enumeration, and paragraph numbering share one + *level format* (`counter`, `label`, `ref`). The four schemes of §13.1 are presets of it, and + a house style can express "Article {n}" or "{section}.{n}" without new code. +6. **Labels by language.** Words the renderer generates come from `labels`. Unset labels fall + back to built-in labels for the document's `language` (§13.7). + +## Consequences + +- One style file can be shared across a team and versioned in Git next to the documents. +- `--print-style` prints the effective style as a complete YAML file, which makes the layering + inspectable and gives users a starting point for their own style. +- Adding a setting means adding a dataclass field with a default. Validation, `--set`, and + `--print-style` pick it up automatically. +- The style format has its own `version`. A breaking change to it must bump that version, and the + loader rejects formats it does not know. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2698f9f..a3046ea 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -9,7 +9,8 @@ costs. A record is never rewritten after it is accepted. A later record supersed | [0002](0002-one-parser.md) | One LegalDown parser: the core package; CommonMark structure from markdown-it-py for now | Accepted | | [0003](0003-resolved-document.md) | Resolve all legal semantics once, into a format-neutral resolved document | Accepted | | [0004](0004-html-first.md) | Output formats in order: text + HTML, then DOCX, then PDF | Accepted (PDF engine open) | -| [0005](0005-locale-formatting.md) | Locale formatting with Babel (CLDR) | Proposed | +| [0005](0005-locale-formatting.md) | Locale formatting with Babel (CLDR) | Accepted | +| [0006](0006-styles-and-options.md) | Style templates are data; render options are separate | Accepted | ## Template diff --git a/docs/roadmap.md b/docs/roadmap.md index c1f2f5e..04aafc0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,70 +1,62 @@ # Roadmap > Milestones are in order but have no dates. Each one ends with a release to PyPI and an updated -> `CONFORMANCE.md`. +> [`CONFORMANCE.md`](../CONFORMANCE.md). -## Before the first line of code: upstream prerequisites +## Upstream prerequisites -The renderer depends on `legaldown-validator` (the core package) and on the specification. These -changes belong there, not here. They are listed in the order that unblocks the most work. +These changes belong in `legaldown-validator` (the core package) or in the specification, not +here. -| # | Where | Change | Why the renderer needs it | +| # | Where | Change | Status | |---|---|---|---| -| U1 | validator | **Keep nested list structure** in the model. Today `- a` / ` - b` parse as two sibling items | Enumeration (a)/(i)/(A) (§13.2) and item anchors at any depth (§5.7). This also matters for Core validation of item anchors | -| U2 | validator | **Source positions** (line spans) on sections and blocks | Lets the build stage align the CommonMark tree with the validator's outline exactly, and gives diagnostics precise lines | -| U3 | validator | **Public helpers** for identifier generation (§5.3, §5.5) and definition resolution (§7.3) | The renderer must compute the same anchors and term targets the validator checks, never its own version of them | -| U4 | validator | **Specification 0.2** (templates, §15) and template **assembly** (§15.7) | The template view is a Rendering MUST (§17.3). Assembly is Core and belongs in the core package | -| U5 | specification | A **rendering fixtures corpus** (`fixtures/rendering/`): source + style settings → expected plain-text output | A neutral definition of correct numbering and designations that any renderer can be tested against, like the validation corpus | - -U1–U3 block v0.1. U4 blocks v0.2. U5 is not blocking but makes conformance claims checkable. +| U1 | validator | **Keep nested list structure** in the model ([#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)) | Open. The renderer works around it with markdown-it-py and an outline check (ADR 0002) | +| U2 | validator | **Source positions** (line numbers) on sections, blocks, and diagnostics | Open. Diagnostics have no line numbers in either package | +| U3 | validator | **Public API** for what the renderer imports from submodules: the lexer, markers, frontmatter splitting, value checks, `IDENTIFIER_RE`, `KNOWN_CURRENCIES` | Open. Until then the dependency is pinned to one minor version | +| U4a | validator | **Specification 0.2** (templates, §15) | Done in 0.2.0 | +| U4b | validator | Template **assembly** with an answers set (§15.7) | Open. It blocks rendering templates with answers | +| U5 | specification | A **rendering fixtures corpus**: source + style settings → expected plain-text output | Not started. The plain-text writer is designed to be its oracle | ## Milestones -### v0.1 — HTML, specification 0.1 - -The first usable renderer for single, non-template documents. +### v0.1 — HTML and text, specification 0.2 (this release) -- Pipeline stages 1, 3, 4, and 5 ([architecture](architecture.md)) -- All four numbering schemes, list enumeration, and a single numbering scope (attachments render - as titles only, which is what Rendering level requires) -- Resolution of `{{ref:}}`, `{{term:}}`, `{{def:}}`, `{{party:}}`, `{{side:}}`, `{{attach:}}`, - and every field spec, with every failure marker -- Title block, preamble, and basic signature blocks -- `text` and `html` writers -- `default` style template, the template loader, and validation -- `legaldown-render` CLI -- Golden tests over every example in the specification repository -- `CONFORMANCE.md` claiming Rendering at specification 0.1, with any gaps named +- The full pipeline: parse and validate, build, resolve, write +- All four numbering schemes; list enumeration; paragraph numbering; item and paragraph anchors + with full designations ("2.1(b)(i)") +- Every directive, with every failure marker +- Template view: conditions, `{{choose:}}`, drafting notes, alternatives sharing a number +- Title block, attachment placeholders, signature blocks +- Style templates: layering, `extends`, `--set`, validation, and three built-in styles; labels in + English and Czech +- `text` and `html` writers; the `legaldown-render` CLI +- Golden tests, and a conformance run over the specification's examples and fixtures -### v0.2 — Specification 0.2: templates +### v0.2 — Templates with answers -- Template view (§15.8): conditional units, `{{choose:}}`, drafting notes, template labels -- Rendering with an answers set (through the core package's assembly) -- Item and paragraph anchors with section-decimal items and paragraph numbering (§5.7, §13.2) -- `continental` and `outline` built-in templates +- Rendering with an answers set, through the core package's assembly (U4b) +- `--answers answers.yaml` on the CLI ### v0.3 — DOCX - `docx` writer behind the `[docx]` extra -- Decide first: static numbers, or Word-native numbering with `REF` fields so lawyers can keep - editing in Word ([ADR 0004](decisions/0004-html-first.md)) -- Reference-document styling (`docx.reference_docx`) +- To decide first: static numbers, or Word-native numbering with `REF` fields + ([ADR 0004](decisions/0004-html-first.md)) ### v0.4 — PDF -- Choose the engine ([ADR 0004](decisions/0004-html-first.md)). WeasyPrint on the HTML writer is - the leading option. The alternatives are Typst, or converting the DOCX output -- Page layout, running headers and footers, and page numbers from the style template +- Choose the engine ([ADR 0004](decisions/0004-html-first.md)). The leading option is WeasyPrint + on the HTML writer; its print stylesheet already exists +- Running headers and footers, and page numbers, from the style's `page` settings ### Later — Full level -Includes, attachment contents with per-attachment numbering scopes (§13.8), amendments, and -bilingual rendering such as side-by-side columns. These need a configured document root and file -access (§2.3), which should come from the core package first. +Includes, attachment contents with per-attachment numbering scopes and cross-scope designations +(§13.3, §13.8), amendments, and bilingual rendering. These need file access under a configured +document root (§2.3), which should come from the core package first. ## Not planned -- **Editing or round-tripping from output.** Output is one-way. The source is the only document - of record. -- **A JavaScript port.** A browser-side renderer for live preview may be worth doing later. It - would be a separate package tested against the same fixtures corpus. +- **Editing or round-tripping from output.** The source is the only document of record. +- **A JavaScript port.** A browser-side renderer for live preview may come later, as a separate + package tested against the same fixtures. diff --git a/docs/style-templates.md b/docs/style-templates.md index 4beab83..8787bb2 100644 --- a/docs/style-templates.md +++ b/docs/style-templates.md @@ -1,107 +1,159 @@ -# Style templates (draft) - -> Status: **draft for discussion.** The keys below are a proposal. Nothing is implemented, and the -> format will be versioned before its first release. +# Styles and preferences A style template holds everything about how a document looks and nothing about what it says -(§13.7). The same source must render correctly with any compatible template. +(§13.7). The same source renders correctly with any style. + +## Two kinds of settings + +The renderer keeps two kinds of settings apart on purpose +([ADR 0006](decisions/0006-styles-and-options.md)): + +| | Style template | Render options | +|---|---|---| +| Answers | How documents look | What this one job does | +| Examples | Numbering scheme, list enumeration, locale, labels, fonts, headings | Output format, strict mode, full page or HTML fragment | +| Where | A YAML file, reused across documents and shared across a team | CLI flags or `RenderOptions` | +| Override per job | `--set key=value`, `--locale`, `overrides={...}` | n/a | + +The document never holds presentation settings. Its `language` is only a hint, used when the +style sets no `locale` (§10.1) and to pick the built-in labels (§13.7). + +## How a style is resolved + +Values are layered. Each layer overrides the one before it, **field by field**: -## Principles +1. **Built-in defaults.** Every setting has one, defined in `legaldown_render/style/model.py`. + The `default` style is exactly these defaults. +2. **The `extends` chain.** A style names the style it builds on, which can be a built-in name or + a path relative to the file. Chains are resolved base first, and cycles are rejected. +3. **The chosen style**: `--style NAME|PATH`, or `RenderOptions(style=...)`. +4. **Per-job overrides**: `--set numbering.scheme=mixed` or `overrides={"numbering.scheme": "mixed"}`, + plus `--locale`. -- **YAML, validated on load.** Templates are YAML files with a `version` field. Unknown keys are - errors, so a typo cannot be ignored silently. Loading reports every problem, with its key path. -- **Layered.** Values merge in this order, each overriding the one before: - 1. the built-in `default` template - 2. the chosen template, or the template named in its `extends` - 3. per-job overrides (CLI flags or `RenderOptions`) +Mappings merge field by field, so `headings: {1: {align: center}}` keeps level 1's size and +weight. Lists, such as `enumeration.levels`, replace the list they override. - A template only needs to state what it changes. -- **Semantic first, per format second.** The shared part (numbering, enumeration, locale, - labels, attachments) drives resolution, so it applies to every output format. Format sections - (`html`, `docx`, `pdf`) only affect layout in that writer. -- **Language-neutral labels.** The renderer never hardcodes text such as "Section", "Schedule", - or "Drafting note". Any word it has to generate comes from `labels`, so a template in Czech - produces Czech output. +The result is **validated in full** before rendering starts. Unknown keys, wrong types, and +values outside an allowed set are all reported together, each with its key path, and suggestions +are offered for typos: -## Sketch +``` +$ legaldown-render contract.lgd --set numbring.scheme=none +legaldown-render: Invalid style 'default': + - numbring: unknown key (did you mean 'numbering'?) +``` + +To see the effective style, with every setting after all layers are applied, use +`legaldown-render --print-style --style continental --set locale=cs-CZ`. The output is itself a +valid style file. + +## Built-in styles + +| Name | Look | +|---|---| +| `default` | Decimal numbering (1. / 1.1 / 1.1.1), (a) / (i) / (A) list items, bold defined terms, a neutral serif page | +| `continental` | Numbered articles, section-qualified list items (2.1, 2.2), and numbered paragraphs, for "Article 5(2)" references | +| `outline` | Legal outline: I. / A. / 1. / a. | + +## Reference ```yaml -version: 1 -name: continental -extends: default +version: 1 # style format version +name: house +extends: continental # built-in name or relative path; default: default -locale: cs-CZ # formatting locale (§10.1); not read from the document +locale: cs-CZ # formatting locale (§10.1); unset: the document's language -numbering: - scheme: decimal # decimal | legal-outline | mixed | none (§13.1) - suffix: "." # "1." vs "1" - attachments: restart # restart | continue (§13.8) +numbering: # §13.1 + scheme: decimal # decimal | legal-outline | mixed | none + levels: # optional; overrides the scheme level by level from level 1 + - { counter: upper-roman, label: "Article {n}", ref: "{n}" } enumeration: # §13.2 - enabled: true - levels: ["(a)", "(i)", "(A)"] - ordered_lists: renumber # renumber | enumerate (apply the levels above) - first_level: letters # letters | section-decimal (5.1, 5.2, …) + enabled: true # false: plain bullets; refs to items fall back to the section + levels: # by nesting depth, cycling deeper + - { counter: lower-alpha, label: "({n})", ref: "({n})" } + - { counter: lower-roman, label: "({n})", ref: "({n})" } + - { counter: upper-alpha, label: "({n})", ref: "({n})" } + ordered: renumber # renumber (1., 2.) | enumerate (apply levels) -paragraphs: - numbered: false # top-level paragraph numbering (§13.2); off by default +paragraphs: # §13.2, off by default + numbered: false + format: { counter: decimal, label: "{section}.{n}", ref: ".{n}" } references: - # How a designation is written. {number} is the target's designation, {title} its heading text. - format: "{number}" - cross_scope: "{scope_title}, {number}" # §13.3, across attachment scopes + format: "{designation}" # how {{ref:}} displays, e.g. "4.2(b)" + +definitions: + style: bold # the defining occurrence: bold | italic | underline | small-caps | plain + term_style: plain # every {{term:}} reference values: - money: { display: symbol } # symbol | code | name - date: { style: long } # short | medium | long | full, or a CLDR pattern + date: long # short | medium | long | full | a CLDR pattern such as "d. M. yyyy" + money: symbol # symbol ($10,000.00) | code (USD 10,000.00) | name (10,000.00 US dollars) + duration: long # long (12 months) | short | narrow placeholders: - blank: "[_____]" # §13.5 - -attachments: - separator: page-break # page-break | rule | none - non_legaldown: placeholder # placeholder | omit (§13.8) - -signatures: - enabled: true # document metadata `include_signatures: false` still wins - layout: side-by-side # side-by-side | stacked + blank: "[_____]" + show_prompt: false # show the question's prompt next to the blank (§15.8) + +title_block: { parties: true, effective_date: true, version: false } +attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none +signatures: { enabled: true } # the document's include_signatures: false wins +template_view: { choice_separator: " / ", empty_choice: "" } + +labels: # every word the renderer generates; unset: built-in for the document language + drafting_note: Drafting note + condition: "Only if: {condition}" + # identification_number, date_of_birth, address, represented_by, effective_date, version, + # attachments, attachment_file ("{file}"), signatures, signature_date, signature_place, + # signature_name, signature_title + +typography: + font_family: 'Georgia, "Times New Roman", serif' + font_size: 11pt + line_height: "1.5" + color: "#1a1a1a" + link_color: "#1f4e8c" + justify: false -labels: # every word the renderer generates - drafting_note: "Poznámka pro zpracovatele" - conditional: "Pouze pokud: {condition}" +headings: # per level 1–6 + 1: { size: 1.15em, weight: "700", italic: false, transform: uppercase, align: left } +page: { size: A4, margin: 25mm } # print stylesheet now; the PDF writer later html: - font_family: "Georgia, serif" - font_size: 11pt - max_width: 48rem - headings: - 1: { size: 1.25em, weight: 700, transform: uppercase } - 2: { size: 1.1em, weight: 700 } - extra_css: "" # appended last; trusted configuration only - -docx: # v0.3 - reference_docx: null # optional .docx whose styles are reused - -pdf: # v0.4 - page: { size: A4, margins: 25mm } - header: "{title}" - footer: "{page} / {pages}" + max_width: 46rem + extra_css: "" # appended to the stylesheet; trusted configuration only ``` -## Built-in templates (planned) +### Level formats -| Name | Look | +Headings, list items, and paragraphs are all numbered with the same building block: + +| Field | Meaning | |---|---| -| `default` | Decimal numbering, (a)/(i)/(A) lists, `en-US` locale, neutral serif typography | -| `continental` | Decimal numbering with section-decimal list items and numbered paragraphs, the "čl. 5 odst. 2" convention | -| `outline` | Legal outline: I. / A. / 1. / a. | +| `counter` | `decimal`, `lower-alpha`, `upper-alpha`, `lower-roman`, `upper-roman` | +| `label` | What the output shows next to the heading or item | +| `ref` | This level's piece of a **designation**, the text `{{ref:}}` renders | + +Both templates can use `{n}` (this level's counter), `{path}` (the full designation up to this +level), and `{section}` (the containing section's designation, for items and paragraphs). For +example, decimal headings use `label: "{path}"` and `ref: ".{n}"`. Legal enumeration uses +`label: "({n})"` and `ref: "({n})"`, so the second item of Section 2.3 is shown as "(b)" and +referenced as "2.3(b)". Under the `none` scheme, designations are heading text, and item pieces +are set off by a space ("Termination (a)", §13.3). + +### Built-in labels + +Labels follow the document's `language`. English (`en`) and Czech (`cs`) are built in, and other +languages fall back to English. A style sets any label explicitly to override it, which is also +how a firm adds a language. ## Open questions -- Should `references.format` allow a generated prefix ("Section 4.2") at all? That brings back a - language-specific word. It would have to come from `labels`, and it could clash with source - text that already says "Section {{ref: x}}". -- How far should the `docx` section map onto Word styles? Should it name styles in a reference - document, or define them inline? -- Should templates be distributable as Python packages, discovered through entry points, so firms - can `pip install` their house style? +- Should styles be distributable as Python packages, discovered through entry points, so a firm + can `pip install` its house style? +- The DOCX writer will need a `docx` section that maps onto Word styles in a reference document. + Its shape is decided with that writer. +- Should `references.format` gain a generated prefix ("Section 4.2")? The word would have to come + from `labels`, and it could double up with source text that already says "Section". diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3e6cf27 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,78 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "legaldown-render" +# Single-sourced from src/legaldown_render/__init__.py. +dynamic = ["version"] +description = "Reference renderer for the LegalDown legal document format: HTML and plain text, DOCX and PDF planned" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "ForLegalAI s.r.o." }] +maintainers = [{ name = "ForLegalAI s.r.o." }] +keywords = ["legaldown", "legal", "contracts", "legaltech", "markdown", "renderer", "html"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Legal Industry", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Text Processing :: Markup :: HTML", + "Typing :: Typed", +] +dependencies = [ + # The only LegalDown parser (docs/decisions/0002). A minor bump may change + # the model, so the range is pinned to one minor version. + "legaldown-validator>=0.2.0,<0.3", + # CommonMark block and inline structure the validator's model does not + # keep yet (nested lists, inline formatting). + "markdown-it-py>=3.0,<5", + # CLDR locale data for dates, money, and durations (docs/decisions/0005). + "babel>=2.14,<3", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.6", "build>=1.2"] + +[project.scripts] +legaldown-render = "legaldown_render.cli:main" + +[project.urls] +Homepage = "https://github.com/ForLegalAI/legaldown-render" +Repository = "https://github.com/ForLegalAI/legaldown-render" +Issues = "https://github.com/ForLegalAI/legaldown-render/issues" +Specification = "https://github.com/ForLegalAI/LegalDown" + +[tool.hatch.version] +path = "src/legaldown_render/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/legaldown_render"] + +[tool.hatch.build.targets.sdist] +include = ["src/legaldown_render", "tests", "docs", "README.md", "LICENSE", "pyproject.toml"] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +pythonpath = ["src"] +markers = [ + "conformance: renders the LegalDown specification's examples and fixtures (needs a checkout of the specification)", +] diff --git a/src/legaldown_render/__init__.py b/src/legaldown_render/__init__.py new file mode 100644 index 0000000..48adf90 --- /dev/null +++ b/src/legaldown_render/__init__.py @@ -0,0 +1,49 @@ +"""legaldown-render — the reference renderer for LegalDown. + +Render a LegalDown document to HTML or plain text. Section numbers, +cross-references, defined terms, and formatted values are generated at render +time, under a style template (§13.7):: + + from legaldown_render import render + + result = render(open("contract.lgd").read(), format="html", style="default") + for diagnostic in result.diagnostics: + print(diagnostic.level, diagnostic.rule, diagnostic.message) + open("contract.html", "w").write(result.output) + +Parsing and Core validation come from legaldown-validator; this package +claims the Rendering conformance level (§17.3). Exact coverage: +https://github.com/ForLegalAI/legaldown-render/blob/main/CONFORMANCE.md +""" +from __future__ import annotations + +from .api import RenderOptions, RenderResult, render, render_file +from .errors import DocumentError, InternalError, RenderError, RenderRefused +from .style import Style, StyleError, builtin_styles, dump_style, load_style + +__version__ = "0.1.0" + +#: The LegalDown specification version this renderer targets. +SPEC_VERSION = "0.2" + +#: Conformance level per specification §17. +CONFORMANCE_LEVEL = "rendering" + +__all__ = [ + "__version__", + "SPEC_VERSION", + "CONFORMANCE_LEVEL", + "render", + "render_file", + "RenderOptions", + "RenderResult", + "Style", + "StyleError", + "load_style", + "dump_style", + "builtin_styles", + "RenderError", + "DocumentError", + "RenderRefused", + "InternalError", +] diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py new file mode 100644 index 0000000..765702d --- /dev/null +++ b/src/legaldown_render/api.py @@ -0,0 +1,155 @@ +"""The rendering pipeline and its public API (docs/architecture.md). + +Preferences are split in two, on purpose: + +* the **style template** says how documents look — numbering, enumeration, + locale, labels, typography. It is a reusable YAML file (``--style``), with + single values overridable per job (``--set key=value``, ``overrides``); +* **render options** say what this one job does — the output format, strict + mode, a full HTML page or a fragment. They never change how a document + looks, so they are not part of the style. +""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml +from legaldown import Diagnostic, Document, ValidationResult, parse_document, validate_document + +from .build import build_tree, normalize_source +from .errors import DocumentError, RenderRefused +from .resolve import Formatter, parse_locale, resolve_tree +from .style import Style, StyleError, load_style +from .tree import RenderTree, assert_resolved +from .writers import FORMATS, HtmlWriter, TextWriter + + +@dataclass(frozen=True, slots=True) +class RenderOptions: + """Settings for one render job.""" + + #: ``html`` or ``text``. + format: str = "html" + #: A built-in style name, a path to a YAML style template, or a Style. + style: str | Path | Style | None = None + #: Per-job style overrides by dotted key, e.g. ``{"numbering.scheme": "none"}``. + overrides: Mapping[str, Any] = field(default_factory=dict) + #: Shorthand for ``overrides={"locale": ...}``: the formatting locale. + locale: str | None = None + #: Refuse to render (raise RenderRefused) when the document has errors, + #: instead of rendering them as visible failure markers. + strict: bool = False + #: HTML only: a complete page (True) or just the ``
      `` (False). + standalone: bool = True + + +@dataclass(frozen=True, slots=True) +class RenderResult: + """What a render produced.""" + + output: str + #: Core (validator) and Rendering diagnostics, each with its stable rule id. + diagnostics: list[Diagnostic] + format: str + #: The resolved tree, for tools that want more than the output text. + tree: RenderTree + style: Style + + @property + def ok(self) -> bool: + """True when the document has no errors.""" + return not any(diagnostic.level == "error" for diagnostic in self.diagnostics) + + +def render(source: str, options: RenderOptions | None = None, /, **settings: Any) -> RenderResult: + """Render LegalDown *source*. + + Settings are a :class:`RenderOptions` or the same fields as keywords:: + + render(text, format="html", style="continental", locale="cs-CZ") + + Problems in the document never raise: they render as failure markers and + are listed in ``diagnostics``. Raises :class:`StyleError` for an invalid + style or setting, :class:`DocumentError` for a document that cannot be + read, and :class:`RenderRefused` in strict mode when the document has + errors. + """ + if options is None: + options = RenderOptions(**settings) + elif settings: + raise TypeError("pass either a RenderOptions or keyword settings, not both") + if options.format not in FORMATS: + raise ValueError(f"unknown output format '{options.format}' (known: {', '.join(FORMATS)})") + overrides = dict(options.overrides) + if options.locale: + overrides["locale"] = options.locale + style = load_style(options.style, overrides=overrides) + + source = normalize_source(source) + document = _parse(source) + result = validate_document(document) + diagnostics = list(result.diagnostics) + if options.strict and any(d.level == "error" for d in diagnostics): + raise RenderRefused(diagnostics) + + tree, extra = _resolve(source, document, result, style) + diagnostics += [d for d in extra if d not in diagnostics] + if options.strict and any(d.level == "error" for d in diagnostics): + raise RenderRefused(diagnostics) + + assert_resolved(tree) + output = _writer(options, style).write(tree) + return RenderResult(output, diagnostics, options.format, tree, style) + + +def render_file(path: str | Path, options: RenderOptions | None = None, /, **settings: Any) -> RenderResult: + """Render the LegalDown file at *path* (see :func:`render`).""" + return render(Path(path).read_text(encoding="utf-8"), options, **settings) + + +def _parse(source: str) -> Document: + try: + return parse_document(source) + except (ValueError, yaml.YAMLError) as error: + raise DocumentError(f"The document cannot be read: {error}") from error + + +def _resolve(source: str, document: Document, result: ValidationResult, style: Style) -> tuple[RenderTree, list[Diagnostic]]: + diagnostics: list[Diagnostic] = [] + tree, raw_html = build_tree(source, document, result) + if raw_html: + diagnostics.append(Diagnostic( + rule="raw-html", + level="warning", + message=f"{raw_html} raw HTML construct(s) in the body are not rendered; HTML does not render " + f"portably (§8.7).", + )) + locale = parse_locale(style.locale) if style.locale else None + if style.locale and locale is None: + raise StyleError("setting", [f"locale: '{style.locale}' is not a known locale (e.g. en-US, cs-CZ)"]) + if locale is None: + # No locale set: the document's language is the hint (§10.1). + locale = parse_locale(tree.language) + if locale is None: + diagnostics.append(Diagnostic( + rule="render-locale-fallback", + level="info", + message=f"No formatting locale is set and the document language '{tree.language}' is not a " + f"known locale; values are formatted for 'en'. Set one with --locale or the style's " + f"'locale'.", + )) + locale = parse_locale("en") + assert locale is not None + resolved, found = resolve_tree(tree, document, result, style, Formatter(locale, style.values)) + return resolved, diagnostics + found + + +def _writer(options: RenderOptions, style: Style) -> HtmlWriter | TextWriter: + if options.format == "text": + return TextWriter() + from . import __version__ + + return HtmlWriter(style, standalone=options.standalone, generator=f"legaldown-render {__version__}") diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py new file mode 100644 index 0000000..ce32509 --- /dev/null +++ b/src/legaldown_render/build.py @@ -0,0 +1,431 @@ +"""Stage 3: build the render tree from the source (docs/architecture.md). + +The LegalDown layer comes only from ``legaldown-validator`` +(docs/decisions/0002): the directive lexer, defined-term anchors, heading and +paragraph markers, and the section identifiers the validator resolved. +CommonMark structure — nested lists, block quotes, tables, emphasis, links — +comes from markdown-it-py, because the validator's model does not keep it yet +(ForLegalAI/legaldown-validator#14). + +The two meet through **sentinels**. Before markdown-it sees the body, every +directive (and every ``"Term" {{def:}}`` span) is replaced by a private-use +token, ``\\ue000\\ue001``, so Markdown can never reinterpret directive +syntax — an underscore in a party name, a pipe in a table cell. Text nodes are +split on the sentinels afterwards and the directives put back as tree nodes. + +The builder then checks that its headings match the validator's sections one +to one. A mismatch means the two parsers disagree about the document, which +must never be papered over: it raises :class:`InternalError`. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from legaldown import Directive, Document, ValidationResult, find_definition_anchors +from legaldown.directives import lex +from legaldown.markers import HTML_COMMENT_RE, Marker, split_heading +from legaldown.parser import FRONTMATTER_RE +from markdown_it import MarkdownIt +from markdown_it.tree import SyntaxTreeNode + +from .errors import InternalError +from .tree import ( + Block, + Code, + CodeBlock, + DefinitionSource, + DirectiveSource, + DraftingNote, + Emphasis, + HardBreak, + Image, + Inline, + Link, + List, + ListItem, + Paragraph, + Quote, + RenderTree, + Rule, + Section, + SoftBreak, + Strong, + Table, + Text, +) + +_OPEN, _CLOSE = "\ue000", "\ue001" +_SENTINEL_RE = re.compile(f"{_OPEN}(\\d+){_CLOSE}") +_ENCODED_SENTINEL_RE = re.compile("%EE%80%80(\\d+)%EE%80%81", re.IGNORECASE) +_DRAFTING_RE = re.compile(r"^\[!drafting\][ \t]*$", re.IGNORECASE) +_COMMENT_ONLY_RE = re.compile(r"^\s*(?:\s*)+$", re.DOTALL) +_ALIGN_RE = re.compile(r"text-align:\s*(left|center|right)") + + +def normalize_source(source: str) -> str: + """*source* with a byte-order mark removed and line endings unified, + exactly as it is handed to both parsers.""" + return source.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n") + + +def body_of(source: str) -> str: + """The body of *source*: everything after the frontmatter.""" + match = FRONTMATTER_RE.match(source) + return source[match.end():] if match else source + + +@dataclass(frozen=True, slots=True) +class _DefinitionPayload: + directive: Directive + #: The term's text between its quotation marks, inner directives + #: already replaced by sentinels. + inner: str + term: str + #: The full source span, for restoring the text where Markdown shows it + #: literally (a link URL, say). + source: str + + +class _Builder: + def __init__(self, source: str, document: Document, result: ValidationResult) -> None: + self.document = document + self.result = result + self.language = document.metadata.language or "en" + self.md = MarkdownIt("commonmark", {"html": True}).enable("table") + self.env: dict = {} + self.payloads: list[DirectiveSource | _DefinitionPayload] = [] + self.raw_html = 0 + self.body = self._protect(body_of(source)) + + # -- sentinels ------------------------------------------------------------ + + def _sentinel(self, payload: DirectiveSource | _DefinitionPayload) -> str: + self.payloads.append(payload) + return f"{_OPEN}{len(self.payloads) - 1}{_CLOSE}" + + def _protect(self, body: str) -> str: + """*body* with every directive replaced by a sentinel.""" + lexed = lex(body) + # A defined term and its {{def:}} become one sentinel, spanning from + # the opening quotation mark to the end of the directive. + definitions = {} + for anchor in find_definition_anchors(body, language=self.language, lexed=lexed): + if anchor.term is None or anchor.pair is None: + continue # a bare {{def:}}: an Error the validator reports; it renders nothing + opening = body.index(anchor.pair[0], anchor.start) + closing = body.rindex(anchor.pair[1], opening + 1, anchor.directive.start) + definitions[id(anchor.directive)] = (anchor.start, anchor, opening, closing) + covered = [(start, anchor.directive.end) for start, anchor, _, _ in definitions.values()] + + out: list[str] = [] + cursor = 0 + for directive in lexed.directives: + if id(directive) in definitions: + start, anchor, opening, closing = definitions[id(directive)] + inner = self._protect_inner(body, opening + 1, closing, lexed.directives) + payload = _DefinitionPayload(directive, inner, anchor.term, body[start:directive.end]) + elif any(start <= directive.start and directive.end <= end for start, end in covered): + continue # inside a defined term: protected with the term + else: + start, payload = directive.start, DirectiveSource(directive) + out.append(body[cursor:start]) + out.append(self._sentinel(payload)) + cursor = directive.end + out.append(body[cursor:]) + return "".join(out) + + def _protect_inner(self, body: str, start: int, end: int, directives: list[Directive]) -> str: + """The term text ``body[start:end]`` with its own directives replaced.""" + out: list[str] = [] + cursor = start + for directive in directives: + if start <= directive.start and directive.end <= end: + out.append(body[cursor:directive.start]) + out.append(self._sentinel(DirectiveSource(directive))) + cursor = directive.end + out.append(body[cursor:end]) + return "".join(out).strip() + + def _restore(self, text: str) -> str: + """*text* with sentinels turned back into their source text, for + places Markdown shows literally: code, URLs, alt text.""" + def source(match: re.Match[str]) -> str: + payload = self.payloads[int(match.group(1))] + return payload.source if isinstance(payload, _DefinitionPayload) else payload.directive.source + return _SENTINEL_RE.sub(source, text) + + def _restore_url(self, url: str) -> str: + """A link or image URL with its sentinels restored. markdown-it has + percent-encoded them, so they are decoded first and the URL is + normalised again afterwards.""" + decoded = _ENCODED_SENTINEL_RE.sub(lambda match: f"{_OPEN}{match.group(1)}{_CLOSE}", url) + return self.md.normalizeLink(self._restore(decoded)) if decoded != url else url + + # -- inlines -------------------------------------------------------------- + + def inlines(self, content: str) -> tuple[Inline, ...]: + """Inline nodes for the inline Markdown *content* (sentinels included).""" + tokens = self.md.parseInline(content, self.env) + if not tokens or not tokens[0].children: + return () + root = SyntaxTreeNode(tokens[0].children, create_root=True) + return self._inline_children(root) + + def _inline_children(self, node: SyntaxTreeNode) -> tuple[Inline, ...]: + out: list[Inline] = [] + for child in node.children: + out.extend(self._inline(child)) + return _merge_text(out) + + def _inline(self, node: SyntaxTreeNode) -> list[Inline]: + match node.type: + case "text": + return self._split_sentinels(node.content) + case "softbreak": + return [SoftBreak()] + case "hardbreak": + return [HardBreak()] + case "em": + return [Emphasis(self._inline_children(node))] + case "strong": + return [Strong(self._inline_children(node))] + case "code_inline": + return [Code(self._restore(node.content))] + case "link": + href = self._restore_url(str(node.attrs.get("href", ""))) + return [Link(href, self._inline_children(node), str(node.attrs.get("title", "") or ""))] + case "image": + src = self._restore_url(str(node.attrs.get("src", ""))) + return [Image(src, self._inline_children(node), str(node.attrs.get("title", "") or ""))] + case "html_inline": + if not node.content.startswith(" + +# Definitions {#definitions} + +"Services" {{def: services}} means the services described in {{attach: schedule-a}}. + +"Business Day" {{def:}} means a day on which banks are open in Delaware. + +# Services {#services} + +## Performance {#performance} + +{{party: acme, label=The Provider}} shall perform the {{term: services}} on each +{{term: business-day, label=business day}}, and shall: + +- keep records of all work performed {#records} +- notify the Client of any delay, including: {#delay} + - the cause of the delay {#delay-cause} + - its expected duration +- comply with the policies in {{attach: exhibit-1, label=the price list}}. + +The steps of the escalation procedure are: + +7. contact the account manager +7. contact the service director {#escalation-director} + +## Changes + +A change under item {{ref: delay-cause}} or step {{ref: escalation-director}} is made in +writing under Section {{ref: performance}}. + +# Fees {#fees} + +The Client shall pay {{money: 10000, currency=USD}} per month, and {{money: 1234.5, currency=EUR}} +for setup, within {{duration: 30, unit=D}} of {{date: 2026-07-01}}. The Provider's VAT number is +{{field: DE123456789, type=vat-id}}. + +| Service | Fee | Notes | +|:---|---:|:---:| +| Hosting | {{money: 500, currency=USD}} | per *month* | +| Support | {{money: 250.75, currency=USD}} | see {{ref: fees}} | + +> The fees are fixed for the first year. + +```text +{{ref: not-a-directive-in-code}} stays literal +``` + +Inline code keeps `{{term: services}}` literal. + +--- + +# Broken References {#broken} + +These are deliberately wrong: {{ref: nowhere}}, {{term: nothing}}, {{date: 2026-13-45}}, +{{money: 12,000, currency=USD}}, {{money: 100, currency=XYZ}}, {{party: nobody, label=Someone}}, +{{party: Bad_Name}}, {{side: nobody}}, {{duration: -1, unit=D}}, {{duration: 3, unit=M}}, +{{field: value}}, {{trem: services}}, {{attach: schedule-z}}, {{placeholder: fee, type=widget}}, +and {{include: includes/extra.lgd}}. + +Raw HTML is dropped. [A safe link](https://example.com) stays; +[an unsafe one](javascript:alert(1)) does not. diff --git a/tests/documents/template.lgd b/tests/documents/template.lgd new file mode 100644 index 0000000..dcce262 --- /dev/null +++ b/tests/documents/template.lgd @@ -0,0 +1,59 @@ +--- +legaldown: "0.2" +title: "Consulting Agreement with {{placeholder: client-name}}" +document_type: contract +questions: + client-name: + type: text + prompt: The client's name + fee: + type: money + prompt: Monthly fee + non-solicit: + type: boolean + prompt: Include a non-solicitation covenant? + forum: + type: choice + prompt: How are disputes resolved? + choices: + courts: State courts + arbitration: Arbitration +sides: + - name: consultants + label: Consultant + parties: + - name: acme + type: legal_entity + legal_name: Acme Corporation + - name: clients + label: Client + parties: + - name: client + type: legal_entity + legal_name: "{{placeholder: client-name}}" +language: en +--- + +# Fees {#fees} + +The Client shall pay {{placeholder: fee, currency=EUR}} per month. + +> [!DRAFTING] +> The standard fee is agreed with the partner in charge. + +# Non-Solicitation {#non-solicit when=non-solicit} + +Neither party shall solicit the other's employees. + +# Dispute Resolution {#disputes when=forum:courts} + +Disputes are resolved by the courts. + +# Dispute Resolution {#disputes when=forum:arbitration} + +Disputes are resolved by arbitration. + +# Relief + +- interim relief is available from {{choose: forum, courts="a competent court", arbitration="a competent court or an emergency arbitrator"}} {#relief-forum} +- nothing in Section {{ref: disputes}} limits this item {when=!non-solicit} diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt new file mode 100644 index 0000000..15c79e3 --- /dev/null +++ b/tests/golden/features.continental.txt @@ -0,0 +1,96 @@ +Services Agreement + +Test document covering every rendered construct + +Effective date: June 1, 2026 + +Provider + Acme Corporation + Registration No.: DE-12345678 + Address: 123 Main Street, Dover, DE 19901 + John Smith, Chief Executive Officer + +Client Group + Beta Industries Inc. + +This Services Agreement (this Agreement) is made between Acme and Beta Industries Inc. (together with its affiliates, the Client Group). + +1. Definitions + +(1) Services means the services described in Schedule A: Service Description. + +(2) Business Day means a day on which banks are open in Delaware. + +2. Services + +2.1 Performance + +(1) The Provider shall perform the Services on each business day, and shall: + +2.1.1 keep records of all work performed +2.1.2 notify the Client of any delay, including: + a) the cause of the delay + b) its expected duration +2.1.3 comply with the policies in the price list. + +(2) The steps of the escalation procedure are: + +1. contact the account manager +2. contact the service director + +2.2 Changes + +(1) A change under item 2.1.2(a) or step 2.1(2) is made in writing under Section 2.1. + +3. Fees + +(1) The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789. + +| Service | Fee | Notes | +|---|---|---| +| Hosting | $500.00 | per month | +| Support | $250.75 | see 3 | + +> The fees are fixed for the first year. + + {{ref: not-a-directive-in-code}} stays literal + +(2) Inline code keeps {{term: services}} literal. + +--- + +4. Broken References + +(1) These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], [INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], [INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], [INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], and [NOT PROCESSED: include includes/extra.lgd]. + +(2) Raw HTML is dropped. A safe link stays; [an unsafe one](javascript:alert(1)) does not. + +--- + +Attachments + +Schedule A: Service Description +File: attachments/service-description.lgd + +Exhibit 1: Price List +File: attachments/price-list.pdf + +--- + +Provider +Acme Corporation + +______________________________ +Name: John Smith +Title: Chief Executive Officer +Date: +Place: + +Client Group +Beta Industries Inc. + +______________________________ +Name: +Title: +Date: +Place: diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html new file mode 100644 index 0000000..12ba159 --- /dev/null +++ b/tests/golden/features.default.html @@ -0,0 +1,209 @@ + + + + + +Services Agreement + + + + +
      +
      +

      Services Agreement

      +

      Test document covering every rendered construct

      +
      +
      Effective date
      June 1, 2026
      +
      +
      +
      +

      Provider

      +
      +

      Acme Corporation

      +
      +
      Registration No.
      DE-12345678
      +
      Address
      123 Main Street, Dover, DE 19901
      +
      +

      John Smith, Chief Executive Officer

      +
      +
      +
      +

      Client Group

      +
      +

      Beta Industries Inc.

      +
      +
      +
      +
      +
      +

      This Services Agreement (this Agreement) is made between Acme +and Beta Industries Inc. (together with its affiliates, the Client Group).

      +
      +
      +

      1. Definitions

      +

      Services means the services described in Schedule A: Service Description.

      +

      Business Day means a day on which banks are open in Delaware.

      +
      +
      +

      2. Services

      +
      +

      2.1 Performance

      +

      The Provider shall perform the Services on each +business day, and shall:

      +
        +
      1. (a)

        keep records of all work performed

      2. +
      3. (b)

        notify the Client of any delay, including:

        +
          +
        1. (i)

          the cause of the delay

        2. +
        3. (ii)

          its expected duration

        4. +
      4. +
      5. (c)

        comply with the policies in the price list.

      6. +
      +

      The steps of the escalation procedure are:

      +
        +
      1. 1.

        contact the account manager

      2. +
      3. 2.

        contact the service director

      4. +
      +
      +
      +

      2.2 Changes

      +

      A change under item 2.1(b)(i) or step 2.1(2) is made in +writing under Section 2.1.

      +
      +
      +
      +

      3. Fees

      +

      The Client shall pay $10,000.00 per month, and €1,234.50 +for setup, within 30 days of July 1, 2026. The Provider's VAT number is +DE123456789.

      + + + + + + + + + + +
      ServiceFeeNotes
      Hosting$500.00per month
      Support$250.75see 3
      +
      +

      The fees are fixed for the first year.

      +
      +
      {{ref: not-a-directive-in-code}} stays literal
      +
      +

      Inline code keeps {{term: services}} literal.

      +
      +
      +
      +

      4. Broken References

      +

      These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], +[INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], +[INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], +[INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], +and [NOT PROCESSED: include includes/extra.lgd].

      +

      Raw HTML is dropped. A safe link stays; +[an unsafe one](javascript:alert(1)) does not.

      +
      +
      +

      Attachments

      +
      +

      Schedule A: Service Description

      +

      File: attachments/service-description.lgd

      +
      +
      +

      Exhibit 1: Price List

      +

      File: attachments/price-list.pdf

      +
      +
      +
      +
      +

      Provider

      +

      Acme Corporation

      +
      +

      Name: John Smith

      +

      Title: Chief Executive Officer

      +

      Date:

      +

      Place:

      +
      +
      +

      Client Group

      +

      Beta Industries Inc.

      +
      +

      Name:

      +

      Title:

      +

      Date:

      +

      Place:

      +
      +
      +
      + + diff --git a/tests/golden/features.default.txt b/tests/golden/features.default.txt new file mode 100644 index 0000000..3c41299 --- /dev/null +++ b/tests/golden/features.default.txt @@ -0,0 +1,96 @@ +Services Agreement + +Test document covering every rendered construct + +Effective date: June 1, 2026 + +Provider + Acme Corporation + Registration No.: DE-12345678 + Address: 123 Main Street, Dover, DE 19901 + John Smith, Chief Executive Officer + +Client Group + Beta Industries Inc. + +This Services Agreement (this Agreement) is made between Acme and Beta Industries Inc. (together with its affiliates, the Client Group). + +1. Definitions + +Services means the services described in Schedule A: Service Description. + +Business Day means a day on which banks are open in Delaware. + +2. Services + +2.1 Performance + +The Provider shall perform the Services on each business day, and shall: + +(a) keep records of all work performed +(b) notify the Client of any delay, including: + (i) the cause of the delay + (ii) its expected duration +(c) comply with the policies in the price list. + +The steps of the escalation procedure are: + +1. contact the account manager +2. contact the service director + +2.2 Changes + +A change under item 2.1(b)(i) or step 2.1(2) is made in writing under Section 2.1. + +3. Fees + +The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789. + +| Service | Fee | Notes | +|---|---|---| +| Hosting | $500.00 | per month | +| Support | $250.75 | see 3 | + +> The fees are fixed for the first year. + + {{ref: not-a-directive-in-code}} stays literal + +Inline code keeps {{term: services}} literal. + +--- + +4. Broken References + +These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], [INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], [INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], [INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], and [NOT PROCESSED: include includes/extra.lgd]. + +Raw HTML is dropped. A safe link stays; [an unsafe one](javascript:alert(1)) does not. + +--- + +Attachments + +Schedule A: Service Description +File: attachments/service-description.lgd + +Exhibit 1: Price List +File: attachments/price-list.pdf + +--- + +Provider +Acme Corporation + +______________________________ +Name: John Smith +Title: Chief Executive Officer +Date: +Place: + +Client Group +Beta Industries Inc. + +______________________________ +Name: +Title: +Date: +Place: diff --git a/tests/golden/features.outline.txt b/tests/golden/features.outline.txt new file mode 100644 index 0000000..874024c --- /dev/null +++ b/tests/golden/features.outline.txt @@ -0,0 +1,96 @@ +Services Agreement + +Test document covering every rendered construct + +Effective date: June 1, 2026 + +Provider + Acme Corporation + Registration No.: DE-12345678 + Address: 123 Main Street, Dover, DE 19901 + John Smith, Chief Executive Officer + +Client Group + Beta Industries Inc. + +This Services Agreement (this Agreement) is made between Acme and Beta Industries Inc. (together with its affiliates, the Client Group). + +I. Definitions + +Services means the services described in Schedule A: Service Description. + +Business Day means a day on which banks are open in Delaware. + +II. Services + +A. Performance + +The Provider shall perform the Services on each business day, and shall: + +(a) keep records of all work performed +(b) notify the Client of any delay, including: + (i) the cause of the delay + (ii) its expected duration +(c) comply with the policies in the price list. + +The steps of the escalation procedure are: + +1. contact the account manager +2. contact the service director + +B. Changes + +A change under item II.A(b)(i) or step II.A(2) is made in writing under Section II.A. + +III. Fees + +The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789. + +| Service | Fee | Notes | +|---|---|---| +| Hosting | $500.00 | per month | +| Support | $250.75 | see III | + +> The fees are fixed for the first year. + + {{ref: not-a-directive-in-code}} stays literal + +Inline code keeps {{term: services}} literal. + +--- + +IV. Broken References + +These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], [INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], [INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], [INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], and [NOT PROCESSED: include includes/extra.lgd]. + +Raw HTML is dropped. A safe link stays; [an unsafe one](javascript:alert(1)) does not. + +--- + +Attachments + +Schedule A: Service Description +File: attachments/service-description.lgd + +Exhibit 1: Price List +File: attachments/price-list.pdf + +--- + +Provider +Acme Corporation + +______________________________ +Name: John Smith +Title: Chief Executive Officer +Date: +Place: + +Client Group +Beta Industries Inc. + +______________________________ +Name: +Title: +Date: +Place: diff --git a/tests/golden/template.default.html b/tests/golden/template.default.html new file mode 100644 index 0000000..48d2dd6 --- /dev/null +++ b/tests/golden/template.default.html @@ -0,0 +1,149 @@ + + + + + +Consulting Agreement with [_____] + + + + +
      +
      +

      Consulting Agreement with [_____]

      +
      +
      +

      Consultant

      +
      +

      Acme Corporation

      +
      +
      +
      +

      Client

      +
      +

      [_____]

      +
      +
      +
      +
      +
      +

      1. Fees

      +

      The Client shall pay [_____] per month.

      + +
      +
      +

      2. Non-Solicitation

      +

      Only if: non-solicit

      +

      Neither party shall solicit the other's employees.

      +
      +
      +

      3. Dispute Resolution

      +

      Only if: forum:courts

      +

      Disputes are resolved by the courts.

      +
      +
      +

      3. Dispute Resolution

      +

      Only if: forum:arbitration

      +

      Disputes are resolved by arbitration.

      +
      +
      +

      4. Relief

      +
        +
      1. (a)

        interim relief is available from [a competent court / a competent court or an emergency arbitrator]

      2. +
      3. (b)

        Only if: !non-solicit nothing in Section 3 limits this item

      4. +
      +
      +
      +
      +

      Consultant

      +

      Acme Corporation

      +
      +

      Name:

      +

      Title:

      +

      Date:

      +

      Place:

      +
      +
      +

      Client

      +

      [_____]

      +
      +

      Name:

      +

      Title:

      +

      Date:

      +

      Place:

      +
      +
      +
      + + diff --git a/tests/golden/template.default.txt b/tests/golden/template.default.txt new file mode 100644 index 0000000..2236c3b --- /dev/null +++ b/tests/golden/template.default.txt @@ -0,0 +1,57 @@ +Consulting Agreement with [_____] + +Consultant + Acme Corporation + +Client + [_____] + +1. Fees + +The Client shall pay [_____] per month. + +> [Drafting note] +> The standard fee is agreed with the partner in charge. + +2. Non-Solicitation + +[Only if: non-solicit] + +Neither party shall solicit the other's employees. + +3. Dispute Resolution + +[Only if: forum:courts] + +Disputes are resolved by the courts. + +3. Dispute Resolution + +[Only if: forum:arbitration] + +Disputes are resolved by arbitration. + +4. Relief + +(a) interim relief is available from [a competent court / a competent court or an emergency arbitrator] +(b) [Only if: !non-solicit] nothing in Section 3 limits this item + +--- + +Consultant +Acme Corporation + +______________________________ +Name: +Title: +Date: +Place: + +Client +[_____] + +______________________________ +Name: +Title: +Date: +Place: diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d169d93 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import DOCUMENTS + +from legaldown_render.cli import main + +FEATURES = str(DOCUMENTS / "features.lgd") + + +def test_render_to_file_infers_the_format(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + out = tmp_path / "out.txt" + assert main([FEATURES, "-o", str(out)]) == 0 + assert out.read_text().startswith("Services Agreement") + assert "[ref-broken]" in capsys.readouterr().err + + +def test_style_set_and_locale(capsys: pytest.CaptureFixture[str]) -> None: + assert main([FEATURES, "-f", "text", "-q", "--style", "outline", "--set", "enumeration.enabled=false", + "--locale", "cs-CZ"]) == 0 + output = capsys.readouterr().out + assert "II. Services" in output + assert "1. června 2026" in output + + +def test_bad_setting_exits_2(capsys: pytest.CaptureFixture[str]) -> None: + assert main([FEATURES, "--set", "numbring.scheme=none"]) == 2 + assert "did you mean 'numbering'" in capsys.readouterr().err + + +def test_strict_exits_1(capsys: pytest.CaptureFixture[str]) -> None: + assert main([FEATURES, "--strict"]) == 1 + assert "refused" in capsys.readouterr().err + + +def test_print_and_list_styles(capsys: pytest.CaptureFixture[str]) -> None: + assert main(["--print-style", "--style", "continental", "--set", "locale=de"]) == 0 + printed = capsys.readouterr().out + assert "name: continental" in printed + assert "locale: de" in printed + assert main(["--list-styles"]) == 0 + assert "default" in capsys.readouterr().out.split() diff --git a/tests/test_golden.py b/tests/test_golden.py new file mode 100644 index 0000000..69a72f6 --- /dev/null +++ b/tests/test_golden.py @@ -0,0 +1,31 @@ +"""Golden files: the test documents rendered under each built-in style, +compared byte for byte. Regenerate on purpose with ``pytest --update-golden`` +and review the diff like any other change.""" +from __future__ import annotations + +import pytest +from conftest import DOCUMENTS, GOLDEN + +from legaldown_render import render + +CASES = [ + ("features", "default", "text"), + ("features", "default", "html"), + ("features", "continental", "text"), + ("features", "outline", "text"), + ("template", "default", "text"), + ("template", "default", "html"), +] + + +@pytest.mark.parametrize(("document", "style", "output_format"), CASES) +def test_golden(document: str, style: str, output_format: str, update_golden: bool) -> None: + source = (DOCUMENTS / f"{document}.lgd").read_text(encoding="utf-8") + output = render(source, format=output_format, style=style).output + extension = "txt" if output_format == "text" else output_format + golden = GOLDEN / f"{document}.{style}.{extension}" + if update_golden or not golden.exists(): + golden.write_text(output, encoding="utf-8") + if not update_golden: + pytest.fail(f"{golden.name} did not exist and was written; review it and run again") + assert output == golden.read_text(encoding="utf-8") diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..123b3b2 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,237 @@ +"""Rendering behaviour, asserted mostly on plain-text output (the resolution +oracle) and on HTML where markup is the point.""" +from __future__ import annotations + +import re + +import pytest +from conftest import DOCUMENTS + +from legaldown_render import DocumentError, RenderOptions, RenderRefused, render + +FRONT = """--- +title: T +sides: + - name: providers + label: Provider + parties: + - name: acme + label: Acme + type: legal_entity + legal_name: Acme Corporation + - name: clients + parties: + - name: beta + type: legal_entity + legal_name: Beta Inc. +--- +""" + + +def text(body: str, **settings: object) -> str: + return render(FRONT + body, format="text", **settings).output + + +def features(**settings: object) -> str: + return render((DOCUMENTS / "features.lgd").read_text(encoding="utf-8"), **settings).output + + +# -- numbering and designations (§13.1–§13.3) -------------------------------- + +NUMBERED = """ +# Scope {#scope} + +## Termination {#termination} + +The Provider may terminate if: + +- payment is late {#late} + - by thirty days {#late-thirty} +- the Client breaches {#breach} + +See {{ref: termination}}, {{ref: late}}, and {{ref: late-thirty}}. +""" + + +@pytest.mark.parametrize(("scheme", "heading", "refs"), [ + ("decimal", "1.1 Termination", "See 1.1, 1.1(a), and 1.1(a)(i)."), + ("legal-outline", "A. Termination", "See I.A, I.A(a), and I.A(a)(i)."), + ("mixed", "(a) Termination", "See 1(a), 1(a)(a), and 1(a)(a)(i)."), + ("none", "Termination", "See Termination, Termination (a), and Termination (a) (i)."), +]) +def test_numbering_schemes(scheme: str, heading: str, refs: str) -> None: + output = text(NUMBERED, overrides={"numbering.scheme": scheme}) + assert heading in output.splitlines() + assert refs in output + + +def test_ordered_lists_are_renumbered() -> None: + output = text("# A\n\n7. first\n7. second\n") + assert "1. first\n2. second" in output + + +def test_ref_to_unenumerated_item_falls_back_with_warning() -> None: + result = render(FRONT + NUMBERED, format="text", overrides={"enumeration.enabled": False}) + assert "See 1.1, 1.1, and 1.1." in result.output + assert "- payment is late" in result.output + assert "ref-not-enumerated" in {d.rule for d in result.diagnostics} + + +def test_paragraph_numbering_and_references() -> None: + body = "# Fees {#fees}\n\nFirst.\n\nSecond. {#fees-second}\n\nSee {{ref: fees-second}}.\n" + output = text(body, overrides={"paragraphs.numbered": True}) + assert "1.1 First." in output + assert "1.2 Second." in output + assert "1.3 See 1.2." in output + + +def test_custom_level_labels() -> None: + output = text("# Fees\n\n## Late\n", overrides={ + "numbering.levels": [{"counter": "upper-roman", "label": "Article {n}", "ref": "{n}"}]}) + assert "Article I Fees" in output + assert "I.1 Late" in output + + +# -- inline directives (§13.3–§13.5) -------------------------------------------- + + +def test_labels_never_hide_failure_markers() -> None: + output = text("# A\n\n{{party: nobody, label=Someone}} and {{term: nothing, label=Thing}}.\n") + assert "[UNKNOWN PARTY: nobody] and [UNDEFINED: nothing]." in output + + +def test_party_and_side_display() -> None: + output = text("# A\n\n{{party: acme}}, {{party: beta}}, {{side: clients}}, {{party: acme, label=the Provider}}.\n") + assert "Acme, Beta Inc., Clients, the Provider." in output + + +def test_defined_terms_render_without_quotation_marks() -> None: + body = '# D\n\n"Services" {{def: services}} means work. The {{term: services}} are paid.\n' + result = render(FRONT + body) + assert 'Services means work' in result.output + assert 'Services' in result.output + assert '"Services"' not in result.output + + +def test_directives_in_code_stay_literal() -> None: + output = text("# A\n\nUse `{{ref: a}}` here.\n\n```\n{{term: x}}\n```\n") + assert "Use {{ref: a}} here." in output + assert " {{term: x}}" in output + + +def test_every_failure_marker_in_the_features_document() -> None: + output = features(format="text") + for marker in ("[BROKEN REF: nowhere]", "[UNDEFINED: nothing]", "[INVALID DATE: 2026-13-45]", + "[UNKNOWN CURRENCY: XYZ]", "[UNKNOWN PARTY: nobody]", "[INVALID PARTY: Bad_Name]", + "[UNKNOWN SIDE: nobody]", "[INVALID DURATION: -1]", "[INVALID DURATION UNIT: M]", + "[INVALID FIELD: value]", "[UNKNOWN DIRECTIVE: trem]", "[UNKNOWN ATTACHMENT: schedule-z]", + "[INVALID PLACEHOLDER: fee]", "[NOT PROCESSED: include includes/extra.lgd]"): + assert marker in output + + +def test_frontmatter_placeholders_render_as_blanks() -> None: + output = render((DOCUMENTS / "template.lgd").read_text(encoding="utf-8"), format="text").output + assert output.startswith("Consulting Agreement with [_____]") + + +# -- template view (§15.8) ------------------------------------------------------ + + +def test_template_view() -> None: + output = render((DOCUMENTS / "template.lgd").read_text(encoding="utf-8"), format="text").output + assert "[Only if: non-solicit]" in output + assert output.count("3. Dispute Resolution") == 2 # alternatives share a number + assert "[a competent court / a competent court or an emergency arbitrator]" in output + assert "> [Drafting note]" in output + assert "(b) [Only if: !non-solicit] nothing in Section 3 limits this item" in output + + +def test_template_labels_follow_the_document_language() -> None: + source = (DOCUMENTS / "template.lgd").read_text(encoding="utf-8").replace("language: en", "language: cs") + output = render(source, format="text").output + assert "[Pouze pokud: non-solicit]" in output + assert "> [Poznámka pro zpracovatele]" in output + + +# -- HTML safety and structure --------------------------------------------------- + + +def test_raw_html_is_never_emitted() -> None: + result = render(FRONT + '# A\n\n\n\nText bold.\n') + assert "\n\n\n\n
      Secret
      \n", + standalone=False) + assert "alert(1)" not in result.output + assert "Secret" not in result.output + assert "raw-html" in {d.rule for d in result.diagnostics} + + +def test_a_backslash_hard_break_is_a_line_break() -> None: + output = render(FRONT + "# A\n\nLine one\\\nline two\n", standalone=False).output + assert "Line one
      \nline two" in output + + +def test_table_rows_are_as_wide_as_the_header() -> None: + output = text("# A\n\n| a | b |\n|---|---|\n| 1 | 2 | 3 |\n| x |\n") + assert "| 1 | 2 |\n| x | |" in output + + +def test_template_decision_matches_the_validator_on_the_test_documents() -> None: + from legaldown import parse_document + + from legaldown_render.validator_bridge import placed_markers, validator_template + + for name in ("features", "template"): + document = parse_document((DOCUMENTS / f"{name}.lgd").read_text(encoding="utf-8")) + assert placed_markers(document).template == validator_template(document) From 81a532d5e35df1414a2d832ed14e86ca30cd8571 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 05:59:20 +0000 Subject: [PATCH 12/22] Stop guessing at structure the validator's model lost Removes the three workarounds the eighth review found fragile, and follows the validator's model instead, with each gap documented and filed upstream: - No HTML-block detection: it dropped real text joined to a comment line, and anchors with it. Every tag is still dropped and reported; the text between an HTML block's tags now renders (legaldown-validator#23). - No hard-break rewrite: it fired on any backslash-space, in titles and cells too. A joined backslash break shows as written (legaldown-validator#25). - No signature-heading split in quotes and items: it cut fenced code and missed setext and conditional headings (legaldown-validator#24). - The unreachable parse fallback is gone; quote and item text is still read as a body, never as frontmatter. - The validator-template spy moved from the package into tests/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- CONFORMANCE.md | 9 ++-- docs/architecture.md | 11 +++-- src/legaldown_render/build.py | 52 ++++-------------------- src/legaldown_render/validator_bridge.py | 26 +----------- tests/conformance/test_spec_corpus.py | 3 +- tests/test_render.py | 35 ++++++++++------ tests/validator_spy.py | 25 ++++++++++++ 7 files changed, 73 insertions(+), 88 deletions(-) create mode 100644 tests/validator_spy.py diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 8595509..860a045 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -34,7 +34,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes | Signature blocks (§2.2) | ✅ One per party, with `legal_name` and each representative | | Labels following the document language (§13.7) | ✅ Built in for `en` and `cs`; any label can be set by a style | | `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ | -| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ | +| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ No tag is ever emitted. Text between an HTML block's tags renders as text until the validator's model recognises HTML blocks (see below) | | Automatic term recognition (§7.4, MAY) | ❌ Not supported | | Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML is dropped with the Warning | | Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` | @@ -57,11 +57,12 @@ It is checked against the specification's own examples and fixtures corpus (`tes - **`*` and `+` bullets and `1)` ordered lists** render as one paragraph ([#21](https://github.com/ForLegalAI/legaldown-validator/issues/21)). - **Tables:** column alignment is not kept, and an escaped pipe (`\|`) or a pipe inside a code span splits a cell ([#22](https://github.com/ForLegalAI/legaldown-validator/issues/22)). Rows are padded or trimmed to the header's width. - - **Line breaks** within a paragraph are joined. A backslash hard break is restored; a - two-space hard break is not. Link reference definitions (`[label]: url`) are not resolved + - **Line breaks** within a paragraph are joined, and a backslash hard break shows as a literal + backslash. Link reference definitions (`[label]: url`) are not resolved ([#25](https://github.com/ForLegalAI/legaldown-validator/issues/25)). - **A heading inside a multi-line HTML comment** is a section, with the comment's delimiters - shown as text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). + shown as text. In an **HTML block**, every tag is dropped and reported (`raw-html`), but the + text between the tags renders as paragraph text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). - **Everything after a `# Signature Block {#signature-block}` heading** is dropped ([#24](https://github.com/ForLegalAI/legaldown-validator/issues/24)). - **Indented code blocks** are read as paragraphs ([#9](https://github.com/ForLegalAI/legaldown-validator/issues/9)). diff --git a/docs/architecture.md b/docs/architecture.md index 93eb640..8a76e14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,14 +82,19 @@ that renders nothing, led by a punctuation character so that an emphasis closer still closes. A defined term in a link title or alt text shows its term but is not the definition's anchor. -HTML comments are dropped (§8.6). Raw HTML is dropped and counted for a `raw-html` Warning +HTML comments are dropped (§8.6). Every HTML tag is dropped and counted for a `raw-html` Warning (§8.7). -Until the validator's model keeps them, some structure is rendered as the validator holds it -(see `CONFORMANCE.md`): +The builder never works around the validator's model. Where the model loses or misreads +something, the output follows the model, and the gap is listed in `CONFORMANCE.md` and filed on +the validator: - lists have one level of items - table column alignment is not kept - a paragraph's line breaks are joined +- the text between an HTML block's tags stays +- content after a `Signature Block` heading is dropped + +Guessing at lost structure was tried, and it traded each gap for new bugs. ### 4. Resolve (`resolve/`) diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py index b95144e..5f99b14 100644 --- a/src/legaldown_render/build.py +++ b/src/legaldown_render/build.py @@ -26,10 +26,9 @@ from dataclasses import dataclass, replace from urllib.parse import unquote -import yaml from legaldown import Block as ModelBlock from legaldown import Directive, Document, ValidationResult, find_definition_anchors, parse_document, render_block -from legaldown.markers import HTML_COMMENT_RE, split_heading +from legaldown.markers import HTML_COMMENT_RE from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode @@ -67,11 +66,6 @@ _HIDDEN_LEAD = "\u2e31" # A fenced code block's opening line (validator's model keeps the fences). _FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") -# The heading at which the validator's parser stops reading a body. -_SIGNATURE_HEADING_RE = re.compile(r"^ {0,3}#{1,6}[ \t]+Signature Block[ \t]+\{#signature-block\}[ \t]*$", re.MULTILINE) -# A CommonMark backslash hard break, after the validator joined the line -# that ended in it to the next with a space. -_JOINED_HARD_BREAK_RE = re.compile(r"(? str: @@ -251,11 +245,8 @@ def _restore_url(self, url: str) -> str: def text(self, source: str) -> tuple[Inline, ...]: """Inline nodes for *source*, one block's text as the validator - holds it: directives protected, then parsed as inline Markdown. A - backslash hard break the validator joined to the next line with a - space is a hard break again.""" - protected = self._protect(source) - return _trim(self.inlines(_JOINED_HARD_BREAK_RE.sub(lambda m: m.group(1) + "\\\n", protected))) + holds it: directives protected, then parsed as inline Markdown.""" + return _trim(self.inlines(self._protect(source))) def inlines(self, content: str) -> tuple[Inline, ...]: """Inline nodes for the inline Markdown *content* (sentinels included).""" @@ -357,10 +348,7 @@ def block(self, block: ModelBlock, placed: dict[int, tuple[int, str, str]], *, t match block.kind: case "paragraph" | "definition" | "ref" | "term": block, identifier, condition = _strip_markers(block, placed) - source = _paragraph_source(block) - if self._is_html_block(source): - return None # raw HTML is never rendered (§8.7), text between tags included - inlines = self.text(source) + inlines = self.text(_paragraph_source(block)) if not inlines and not identifier: return None # only a comment return Paragraph(inlines, anchor_id=identifier, condition=condition, top_level=top_level) @@ -416,40 +404,16 @@ def quote(self, block: ModelBlock) -> Block: def fragment(self, text: str) -> tuple[Block, ...]: """Blocks for *text* inside a quote or a list item, read by the - validator's parser as a body — never as a document with frontmatter, - and never cut short at a signature-block heading. No marker is - placed there (§5.7), and a heading is not a section (§4.1).""" - blocks: tuple[Block, ...] = () - cursor = 0 - for match in _SIGNATURE_HEADING_RE.finditer(text): - blocks += self._fragment_part(text[cursor:match.start()]) - title, _marker = split_heading(match.group().strip().lstrip("#")) - blocks += (Paragraph((Strong(self.text(title)),)),) - cursor = match.end() - return blocks + self._fragment_part(text[cursor:]) - - def _fragment_part(self, text: str) -> tuple[Block, ...]: - try: - # A leading newline: text starting with "---" is not frontmatter. - inner = parse_document("\n" + text) - except (ValueError, yaml.YAMLError): - return (Paragraph(self.text(" ".join(text.split()))),) if text.strip() else () + validator's parser — as a body, so that text starting with ``---`` + is not frontmatter. No marker is placed there (§5.7), and a heading + is not a section (§4.1).""" + inner = parse_document("\n" + text) blocks = self.blocks(inner.preamble, None, markers=False) for section in inner.sections: blocks += (Paragraph((Strong(self.text(section.title)),)),) blocks += self.blocks(section.blocks, None, markers=False) return blocks - def _is_html_block(self, source: str) -> bool: - """True when a paragraph's text is an HTML block (CommonMark): it is - dropped whole and counted, unless it is only a comment.""" - parsed = self.md.parse(source) - if not parsed or parsed[0].type != "html_block": - return False - if not parsed[0].content.lstrip().startswith("" across the following blocks, within one section, quote or item: the one documented exception to following the validator's model (§8.6) - Leave one space where inline HTML or a comment was dropped - Count the raw-html Warning per text with dropped HTML, not per tag - Stop pinning validator bugs #24 and #25 in tests; assert only the content that must render - Assert no escaped script tag is emitted either - Bind the template spy's arguments by signature - Fix stale comments and CONFORMANCE rows (comments, extended tables) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- CONFORMANCE.md | 8 +- docs/architecture.md | 20 ++-- .../0007-one-parser-validator-model.md | 3 + pyproject.toml | 4 +- src/legaldown_render/api.py | 2 +- src/legaldown_render/build.py | 96 +++++++++++++++---- tests/test_render.py | 46 +++++++-- tests/validator_spy.py | 6 +- 8 files changed, 147 insertions(+), 38 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 860a045..828405e 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -20,7 +20,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes | `{{include:}}` below Full (§17.5) | ✅ Renders `[NOT PROCESSED: include path]` with a Warning (`render-not-processed`) | | Party and side display rules (§3.6) | ✅ | | At least one output format (§13.6) | ✅ HTML (recommended) and plain text (optional) | -| Comment stripping (§8.6) | ✅ | +| Comment stripping (§8.6) | ✅ Including a comment across blank lines, up to the end of its section (see below) | | Template view without answers (§15.8) | ✅ Conditions marked with style-defined labels; every `{{choose:}}` phrase shown as `[a / b]`; drafting notes styled; alternatives share a number | | `{{attach:}}` resolves to the declared title without reading the file | ✅ Linked to an attachment placeholder, or to the file when placeholders are omitted | @@ -36,7 +36,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes | `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ | | `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ No tag is ever emitted. Text between an HTML block's tags renders as text until the validator's model recognises HTML blocks (see below) | | Automatic term recognition (§7.4, MAY) | ❌ Not supported | -| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML is dropped with the Warning | +| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the tags are dropped with the Warning, and the cell text renders as paragraph text | | Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` | ## Known limitations @@ -60,8 +60,8 @@ It is checked against the specification's own examples and fixtures corpus (`tes - **Line breaks** within a paragraph are joined, and a backslash hard break shows as a literal backslash. Link reference definitions (`[label]: url`) are not resolved ([#25](https://github.com/ForLegalAI/legaldown-validator/issues/25)). - - **A heading inside a multi-line HTML comment** is a section, with the comment's delimiters - shown as text. In an **HTML block**, every tag is dropped and reported (`raw-html`), but the + - **A heading inside a multi-line HTML comment** is a section: the comment ends at the heading, + and its closing `-->` shows as text. In an **HTML block**, every tag is dropped and reported (`raw-html`), but the text between the tags renders as paragraph text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). - **Everything after a `# Signature Block {#signature-block}` heading** is dropped ([#24](https://github.com/ForLegalAI/legaldown-validator/issues/24)). diff --git a/docs/architecture.md b/docs/architecture.md index 8a76e14..ba1aa64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,12 +82,20 @@ that renders nothing, led by a punctuation character so that an emphasis closer still closes. A defined term in a link title or alt text shows its term but is not the definition's anchor. -HTML comments are dropped (§8.6). Every HTML tag is dropped and counted for a `raw-html` Warning -(§8.7). - -The builder never works around the validator's model. Where the model loses or misreads -something, the output follows the model, and the gap is listed in `CONFORMANCE.md` and filed on -the validator: +HTML comments are dropped (§8.6), and where one stood between two spaces, one space stays. Every +HTML tag is dropped, and each text that had one (a paragraph, a title, a table cell) counts once +for the `raw-html` Warning (§8.7). + +The builder never works around the validator's model, with **one exception: comments**. The +specification requires every comment to be stripped (§8.6), and the validator's model holds a +comment that spans a blank line as ordinary blocks. So a ``, and the blocks in between render +nothing. The text before the `` renders. The comment never runs past the +end of the section, or of the quote or list item, it opened in: a heading inside a comment is +still a section ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). + +Everywhere else, where the model loses or misreads something, the output follows the model, and +the gap is listed in `CONFORMANCE.md` and filed on the validator: - lists have one level of items - table column alignment is not kept - a paragraph's line breaks are joined diff --git a/docs/decisions/0007-one-parser-validator-model.md b/docs/decisions/0007-one-parser-validator-model.md index b7497cd..d135fca 100644 --- a/docs/decisions/0007-one-parser-validator-model.md +++ b/docs/decisions/0007-one-parser-validator-model.md @@ -53,6 +53,9 @@ module, `validator_bridge.py`. That module is the list for roadmap item U3. - a paragraph's line breaks are joined - constructs the validator reads unusually, such as a heading inside an HTML comment, render as the validator reads them +- One deliberate exception: a comment left open in a paragraph runs on to its `-->` across the + following blocks, because §8.6 requires every comment to be stripped. It never crosses a + section boundary (docs/architecture.md, stage 3). - When the validator keeps nested lists (#14), the renderer gains them with no parser of its own. The resolver and writers already handle nested lists. - The build stage is smaller: about 100 lines of position rules and special cases are gone. diff --git a/pyproject.toml b/pyproject.toml index 3e6cf27..6c9fc6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ # The only LegalDown parser (docs/decisions/0002). A minor bump may change # the model, so the range is pinned to one minor version. "legaldown-validator>=0.2.0,<0.3", - # CommonMark block and inline structure the validator's model does not - # keep yet (nested lists, inline formatting). + # Inline Markdown (emphasis, links, code spans) within the validator's + # blocks, parsed in inline mode only (docs/decisions/0007). "markdown-it-py>=3.0,<5", # CLDR locale data for dates, money, and durations (docs/decisions/0005). "babel>=2.14,<3", diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py index b3bb301..9a32e7e 100644 --- a/src/legaldown_render/api.py +++ b/src/legaldown_render/api.py @@ -124,7 +124,7 @@ def _resolve(document: Document, result: ValidationResult, style: Style) -> tupl diagnostics.append(Diagnostic( rule="raw-html", level="warning", - message=f"{raw_html} raw HTML construct(s) in the body are not rendered; HTML does not render " + message=f"Raw HTML in {raw_html} place(s) in the body is not rendered; HTML does not render " f"portably (§8.7).", )) locale = parse_locale(style.locale) if style.locale else None diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py index 5f99b14..8e8d072 100644 --- a/src/legaldown_render/build.py +++ b/src/legaldown_render/build.py @@ -66,6 +66,11 @@ _HIDDEN_LEAD = "\u2e31" # A fenced code block's opening line (validator's model keeps the fences). _FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") +# The validator's block kinds that render as a paragraph. +_PARAGRAPH_KINDS = frozenset({"paragraph", "definition", "ref", "term"}) +# Stands where inline HTML or a comment was dropped, until _merge_text +# joins the text around it. +_DROPPED = Text("") def normalize_source(source: str) -> str: @@ -144,12 +149,13 @@ def __init__(self, document: Document, result: ValidationResult) -> None: self.document = document self.result = result self.language = document.metadata.language or "en" - # Inline parsing only: block structure is the validator's. Its block - # parser only tells whether one paragraph's text is an HTML block. + # Inline parsing only: block structure is the validator's. self.md = MarkdownIt("commonmark", {"html": True}) self.env: dict = {} self.payloads: list[_Payload] = [] + # Texts in which raw HTML was dropped (§8.7), counted once each. self.raw_html = 0 + self._dropped_html = False self.markers = placed_markers(document) nonce = secrets.token_hex(8) # A hidden sentinel has its own form ("h"), so the lead character is @@ -246,7 +252,11 @@ def _restore_url(self, url: str) -> str: def text(self, source: str) -> tuple[Inline, ...]: """Inline nodes for *source*, one block's text as the validator holds it: directives protected, then parsed as inline Markdown.""" - return _trim(self.inlines(self._protect(source))) + self._dropped_html = False + inlines = _trim(self.inlines(self._protect(source))) + if self._dropped_html: + self.raw_html += 1 + return inlines def inlines(self, content: str) -> tuple[Inline, ...]: """Inline nodes for the inline Markdown *content* (sentinels included).""" @@ -284,8 +294,8 @@ def _inline(self, node: SyntaxTreeNode) -> list[Inline]: return [Image(src, self._inline_children(node), self._title(node))] case "html_inline": if not node.content.startswith("`` + in the blocks that follow, which render nothing + (docs/architecture.md, stage 3; ForLegalAI/legaldown-validator#23). + It never runs past the end of *blocks*.""" out: list[Block] = [] + in_comment = False for index, block in enumerate(blocks): placed = self._placed(block, section, index) if markers else {} - built = self.block(block, placed, top_level=section is not None and markers) + top_level = section is not None and markers + if block.kind in _PARAGRAPH_KINDS: + block, identifier, condition = _strip_markers(block, placed) + built, in_comment = self.paragraph( + _paragraph_source(block), identifier, condition, top_level=top_level, in_comment=in_comment) + elif in_comment: + built, in_comment = self.paragraph(render_block(block), "", "", top_level=top_level, in_comment=True) + else: + built = self.block(block, placed) if built is not None: out.append(built) return tuple(out) + def paragraph( + self, source: str, identifier: str, condition: str, *, top_level: bool, in_comment: bool, + ) -> tuple[Paragraph | None, bool]: + """A paragraph for *source*, and whether a comment is left open at + its end. With *in_comment*, the text up to the first ``-->`` is + inside a comment opened earlier; without one, the whole text is.""" + if in_comment: + end = source.find("-->") + if end < 0: + return None, True + source = source[end + 3:] + opening = self._unclosed_comment(source) + if opening >= 0: + source = source[:opening] + inlines = self.text(source) + if not inlines and not identifier: + return None, opening >= 0 # only a comment + return Paragraph(inlines, anchor_id=identifier, condition=condition, top_level=top_level), opening >= 0 + + def _unclosed_comment(self, source: str) -> int: + """Offset of a ```` closes, outside + code spans and directives, or -1. The lexer's view has code spans + and closed comments blanked already.""" + lexed = self.markers.lex(source) + at = lexed.view.find(" b`` reads + "a b".""" merged: list[Inline] = [] + dropped = False for inline in inlines: + if inline is _DROPPED: + dropped = True + continue if isinstance(inline, Text) and merged and isinstance(merged[-1], Text): - merged[-1] = Text(merged[-1].text + inline.text) + text = inline.text + if dropped and merged[-1].text.endswith(" "): + text = text.lstrip(" ") + merged[-1] = Text(merged[-1].text + text) else: merged.append(inline) + dropped = False return tuple(merged) def build_tree(document: Document, result: ValidationResult) -> tuple[RenderTree, int]: - """The render tree for *document*, and the number of raw HTML - constructs that were dropped (§8.7).""" + """The render tree for *document*, and the number of texts — a + paragraph, a title, a table cell — in which raw HTML was dropped (§8.7).""" builder = _Builder(document, result) tree = builder.tree() return tree, builder.raw_html diff --git a/tests/test_render.py b/tests/test_render.py index c22008d..931f1d1 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -600,12 +600,11 @@ def test_quote_content_is_read_as_a_body() -> None: assert line in output -def test_a_signature_block_heading_in_a_quote_follows_the_validator() -> None: - # The validator's parser stops at this heading, here too - # (ForLegalAI/legaldown-validator#24); the renderer does not patch it. +def test_a_signature_block_heading_in_a_quote_renders() -> None: + # What follows the heading depends on the validator's parser + # (ForLegalAI/legaldown-validator#24); only the text before it is pinned. output = text("# A\n\n> intro\n>\n> ## Signature Block {#signature-block}\n> after text\n") assert "> intro" in output - assert "after text" not in output def test_cutting_a_marker_keeps_the_space_after_a_reference() -> None: @@ -631,13 +630,46 @@ def test_html_tags_are_never_emitted() -> None: # (ForLegalAI/legaldown-validator#23); it is escaped like any text. result = render(FRONT + "# A\n\n\n\nAfter.\n", standalone=False) assert " None: - # The validator joins a paragraph's lines (ForLegalAI/legaldown-validator#25). - assert "Line one\\ line two" in text("# A\n\nLine one\\\nline two\n") +def test_a_hard_break_keeps_both_lines() -> None: + # How the break shows depends on the validator's parser + # (ForLegalAI/legaldown-validator#25); only the text is pinned. + output = text("# A\n\nLine one\\\nline two\n") + assert "Line one" in output + assert "line two" in output + + +def test_a_comment_across_blocks_renders_nothing() -> None: + # The one place the builder reads past the validator's model + # (docs/architecture.md, stage 3). + output = text("# A\n\nBefore after\n\nNext.\n") + assert "Before\n\nafter\n\nNext." in output + assert "hidden" not in output + assert "" not in output + + +def test_an_open_comment_ends_with_its_section() -> None: + output = text("# A\n\nShown b
      c d\n", format="text") + assert "a b c d" in result.output + warnings = [d for d in result.diagnostics if d.rule == "raw-html"] + assert len(warnings) == 1 and "in 1 place" in warnings[0].message def test_table_rows_are_as_wide_as_the_header() -> None: diff --git a/tests/validator_spy.py b/tests/validator_spy.py index ff15091..dca6903 100644 --- a/tests/validator_spy.py +++ b/tests/validator_spy.py @@ -2,6 +2,7 @@ renderer's copy of its formula (validator_bridge._is_template).""" from __future__ import annotations +import inspect from unittest import mock from legaldown import Document, validate_document @@ -14,9 +15,12 @@ def validator_template(document: Document) -> bool: (ForLegalAI/legaldown-validator#26).""" seen: list[bool] = [] original = core.Units + signature = inspect.signature(original) + assert "template" in signature.parameters, "Units no longer takes 'template'; update this spy" def spy(*args: object, **kwargs: object) -> object: - seen.append(bool(kwargs["template"])) + # However the validator passes it, by position or by name. + seen.append(bool(signature.bind(*args, **kwargs).arguments["template"])) return original(*args, **kwargs) with mock.patch.object(core, "Units", spy): From e4692f4d61579aeaea0b6ffdd4c5df8504c70c39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 09:39:02 +0000 Subject: [PATCH 14/22] Fix tenth code review findings in comment stripping - Take an opener as a comment only when it is not escaped and not the empty comment or (complete in CommonMark) - Strip a comment opened in a list item, through the items and blocks that follow; a comment ending inside a list keeps the remaining items - Leave a space where a
      between two words was dropped - Skip marker placement for blocks read only for a comment's end - Document the two gaps left until the validator's model drops such comments (legaldown-validator#23), and empty comments (#28) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- CONFORMANCE.md | 7 ++ docs/architecture.md | 13 +-- docs/roadmap.md | 2 +- src/legaldown_render/build.py | 109 ++++++++++++++++------- src/legaldown_render/validator_bridge.py | 4 +- tests/test_render.py | 27 +++++- 6 files changed, 119 insertions(+), 43 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 828405e..d6f220f 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -63,6 +63,13 @@ It is checked against the specification's own examples and fixtures corpus (`tes - **A heading inside a multi-line HTML comment** is a section: the comment ends at the heading, and its closing `-->` shows as text. In an **HTML block**, every tag is dropped and reported (`raw-html`), but the text between the tags renders as paragraph text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). + - **A comment across blank lines** is stripped up to its `-->` (the one place the renderer + reads past the model), but the model still holds its content as blocks. So a `{#id}` inside + it is still a valid target for the validator, and a reference to it renders + `[BROKEN REF: id]` with no Error. When the comment ends inside a quote, code block, or table, + the rest of that block renders as plain paragraph text ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). + - **After an empty comment** (``, ``), a directive before a later `-->` on the same + line is shown as its source text, unresolved and unchecked ([#28](https://github.com/ForLegalAI/legaldown-validator/issues/28)). - **Everything after a `# Signature Block {#signature-block}` heading** is dropped ([#24](https://github.com/ForLegalAI/legaldown-validator/issues/24)). - **Indented code blocks** are read as paragraphs ([#9](https://github.com/ForLegalAI/legaldown-validator/issues/9)). diff --git a/docs/architecture.md b/docs/architecture.md index ba1aa64..d8c26f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,11 +88,14 @@ for the `raw-html` Warning (§8.7). The builder never works around the validator's model, with **one exception: comments**. The specification requires every comment to be stripped (§8.6), and the validator's model holds a -comment that spans a blank line as ordinary blocks. So a ``, and the blocks in between render -nothing. The text before the `` renders. The comment never runs past the -end of the section, or of the quote or list item, it opened in: a heading inside a comment is -still a section ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). +comment that spans a blank line as ordinary blocks. So a ``, and the blocks and items in between render nothing. The +text before the `` renders. An opener counts only when it is not +escaped, not in a code span or a directive, and not the empty comment `` or ``. The comment never runs past the end of +the section, or of the quote or list item, it opened in: a heading inside a comment is still a +section ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)). Two gaps remain until +the model drops such comments itself, listed in `CONFORMANCE.md`: a `{#id}` inside one is still a +target, and a quote, code block, or table the comment ends in renders the rest as plain text. Everywhere else, where the model loses or misreads something, the output follows the model, and the gap is listed in `CONFORMANCE.md` and filed on the validator: diff --git a/docs/roadmap.md b/docs/roadmap.md index 206ce46..cb45790 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,7 +13,7 @@ here. | U1 | validator | **Keep nested list structure** in the model ([#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)) | Open. Until then lists render with one level of items (ADR 0007); the resolver and writers already handle nesting | | U2 | validator | **Source positions** (line numbers) on sections, blocks, and diagnostics ([#27](https://github.com/ForLegalAI/legaldown-validator/issues/27)) | Open. Diagnostics have no line numbers in either package | | U3 | validator | **Public API** ([#26](https://github.com/ForLegalAI/legaldown-validator/issues/26)) for what the renderer imports from submodules (all in `validator_bridge.py`): the lexer, markers, frontmatter splitting, value checks, conditions, `IDENTIFIER_RE`, `KNOWN_CURRENCIES`, the frontmatter fields it reads. Better still, expose the validator's own **placed markers** and **template decision**, so the renderer uses them instead of mirroring those rules | Open. Until then the dependency is pinned to one minor version | -| U1b | validator | **Parser gaps** the renderer inherits: list markers ([#21](https://github.com/ForLegalAI/legaldown-validator/issues/21)), tables ([#22](https://github.com/ForLegalAI/legaldown-validator/issues/22)), headings in comments ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)), signature-block cutoff ([#24](https://github.com/ForLegalAI/legaldown-validator/issues/24)), hard breaks ([#25](https://github.com/ForLegalAI/legaldown-validator/issues/25)), indented code ([#9](https://github.com/ForLegalAI/legaldown-validator/issues/9)) | Open. Listed in `CONFORMANCE.md` | +| U1b | validator | **Parser gaps** the renderer inherits: list markers ([#21](https://github.com/ForLegalAI/legaldown-validator/issues/21)), tables ([#22](https://github.com/ForLegalAI/legaldown-validator/issues/22)), headings in comments ([#23](https://github.com/ForLegalAI/legaldown-validator/issues/23)), signature-block cutoff ([#24](https://github.com/ForLegalAI/legaldown-validator/issues/24)), hard breaks ([#25](https://github.com/ForLegalAI/legaldown-validator/issues/25)), empty comments ([#28](https://github.com/ForLegalAI/legaldown-validator/issues/28)), indented code ([#9](https://github.com/ForLegalAI/legaldown-validator/issues/9)) | Open. Listed in `CONFORMANCE.md` | | U4a | validator | **Specification 0.2** (templates, §15) | Done in 0.2.0 | | U4b | validator | Template **assembly** with an answers set (§15.7) | Open. It blocks rendering templates with answers | | U5 | specification | A **rendering fixtures corpus**: source + style settings → expected plain-text output | Not started. The plain-text writer is designed to be its oracle | diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py index 8e8d072..f64154e 100644 --- a/src/legaldown_render/build.py +++ b/src/legaldown_render/build.py @@ -57,7 +57,7 @@ Table, Text, ) -from .validator_bridge import block_fragments, block_quotes, lex, placed_markers +from .validator_bridge import block_fragments, block_quotes, is_escaped, lex, placed_markers _OPEN, _CLOSE = "\ue000", "\ue001" # Leads the sentinel of source that renders nothing (a {{def:}}). It is @@ -66,11 +66,14 @@ _HIDDEN_LEAD = "\u2e31" # A fenced code block's opening line (validator's model keeps the fences). _FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") -# The validator's block kinds that render as a paragraph. +# The validator's block kinds that render as a paragraph, and as a list. _PARAGRAPH_KINDS = frozenset({"paragraph", "definition", "ref", "term"}) -# Stands where inline HTML or a comment was dropped, until _merge_text -# joins the text around it. +_LIST_KINDS = frozenset({"unordered_list", "ordered_list"}) +# Stand where inline HTML or a comment was dropped, until _merge_text joins +# the text around them. A dropped line break (
      ) still parts two words. _DROPPED = Text("") +_DROPPED_BREAK = Text("") +_BREAK_TAG_RE = re.compile(r"", re.IGNORECASE) def normalize_source(source: str) -> str: @@ -293,9 +296,10 @@ def _inline(self, node: SyntaxTreeNode) -> list[Inline]: src = self._restore_url(str(node.attrs.get("src", ""))) return [Image(src, self._inline_children(node), self._title(node))] case "html_inline": - if not node.content.startswith("`` - in the blocks that follow, which render nothing + comment (§8.6) left open in a paragraph or a list item runs on to + the first ``-->`` in the blocks that follow, which render nothing (docs/architecture.md, stage 3; ForLegalAI/legaldown-validator#23). It never runs past the end of *blocks*.""" out: list[Block] = [] in_comment = False + top_level = section is not None and markers for index, block in enumerate(blocks): + built: Block | None + if in_comment and block.kind not in _PARAGRAPH_KINDS | _LIST_KINDS: + # A quote, code, table, or rule the comment runs into: what + # follows its "-->" is kept as text, its structure is lost. + built, in_comment = self.paragraph(render_block(block), "", "", top_level=top_level, in_comment=True) + if built is not None: + out.append(built) + continue placed = self._placed(block, section, index) if markers else {} - top_level = section is not None and markers if block.kind in _PARAGRAPH_KINDS: block, identifier, condition = _strip_markers(block, placed) built, in_comment = self.paragraph( _paragraph_source(block), identifier, condition, top_level=top_level, in_comment=in_comment) - elif in_comment: - built, in_comment = self.paragraph(render_block(block), "", "", top_level=top_level, in_comment=True) + elif block.kind in _LIST_KINDS: + built, in_comment = self.list(block, placed, in_comment=in_comment) else: built = self.block(block, placed) if built is not None: @@ -370,15 +382,21 @@ def paragraph( return Paragraph(inlines, anchor_id=identifier, condition=condition, top_level=top_level), opening >= 0 def _unclosed_comment(self, source: str) -> int: - """Offset of a ```` closes, outside - code spans and directives, or -1. The lexer's view has code spans - and closed comments blanked already.""" + """Offset of a ```` closes, or -1. + + The lexer's view has code spans and closed comments blanked; what + it leaves is literal to it. Of that, an escaped ``\\`` and ```` + (complete in CommonMark) open nothing.""" lexed = self.markers.lex(source) - at = lexed.view.find("") + if end < 0: + continue + item, in_comment = item[end + 3:].lstrip(" "), False + if not item.strip() and not identifier: + continue # nothing after the comment's end first, _, rest = item.partition("\n") if first.lstrip().startswith(">") or _FENCE_RE.match(first): # The item opens with a quote or code, whose lines the # validator keeps as written (a drafting note included). blocks = self.fragment(item) else: - blocks = (Paragraph(self.text(first)),) + (self.fragment(rest) if rest else ()) + opening = self._unclosed_comment(first) + if opening >= 0: + first = first[:opening] + end = rest.find("-->") + rest, in_comment = ("", True) if end < 0 else (rest[end + 3:], False) + blocks = (Paragraph(self.text(first)),) + (self.fragment(rest) if rest.strip() else ()) items.append(ListItem(blocks=blocks, anchor_id=identifier, condition=condition)) - return List(ordered=block.kind == "ordered_list", items=tuple(items)) + return (List(ordered=block.kind == "ordered_list", items=tuple(items)) if items else None), in_comment def quote(self, block: ModelBlock) -> Block: """A block quote, its content read by the validator's parser. A @@ -549,21 +582,31 @@ def _trim(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: def _merge_text(inlines: list[Inline]) -> tuple[Inline, ...]: """*inlines* with adjacent text joined. Where HTML or a comment was dropped between two spaces, one of them goes: ``a b`` reads - "a b".""" + "a b". A dropped ``
      `` between two words leaves one space.""" merged: list[Inline] = [] - dropped = False + dropped = parted = False for inline in inlines: - if inline is _DROPPED: + if inline is _DROPPED or inline is _DROPPED_BREAK: dropped = True + parted = parted or inline is _DROPPED_BREAK continue - if isinstance(inline, Text) and merged and isinstance(merged[-1], Text): - text = inline.text - if dropped and merged[-1].text.endswith(" "): - text = text.lstrip(" ") - merged[-1] = Text(merged[-1].text + text) + before = merged[-1] if merged else None + if dropped and isinstance(inline, Text) and isinstance(before, Text) and before.text.endswith(" "): + inline = Text(inline.text.lstrip(" ")) + elif parted and before is not None and not isinstance(inline, SoftBreak | HardBreak): + if isinstance(before, Text): + if not before.text.endswith(" ") and not (isinstance(inline, Text) and inline.text.startswith(" ")): + merged[-1] = before = Text(before.text + " ") + elif isinstance(inline, Text): + if not inline.text.startswith(" "): + inline = Text(" " + inline.text) + else: + merged.append(Text(" ")) + dropped = parted = False + if isinstance(inline, Text) and isinstance(before, Text): + merged[-1] = Text(before.text + inline.text) else: merged.append(inline) - dropped = False return tuple(merged) diff --git a/src/legaldown_render/validator_bridge.py b/src/legaldown_render/validator_bridge.py index f49debe..ab725a7 100644 --- a/src/legaldown_render/validator_bridge.py +++ b/src/legaldown_render/validator_bridge.py @@ -17,12 +17,12 @@ from legaldown import Document from legaldown.definitions import block_fragments, text_fragments -from legaldown.directives import Lexed, lex +from legaldown.directives import Lexed, is_escaped, lex from legaldown.validator.core import _frontmatter_fields from legaldown.validator.templates import block_quotes from legaldown.validator.units import FoundMarker, find_markers -__all__ = ["PlacedMarkers", "block_fragments", "block_quotes", "lex", "placed_markers"] +__all__ = ["PlacedMarkers", "block_fragments", "block_quotes", "is_escaped", "lex", "placed_markers"] @dataclass(frozen=True, slots=True) diff --git a/tests/test_render.py b/tests/test_render.py index 931f1d1..34bc208 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -665,9 +665,32 @@ def test_a_comment_opener_in_code_or_a_directive_does_not_open() -> None: assert "\nlast\n" in output +def test_an_escaped_or_empty_comment_opener_does_not_open() -> None: + output = text("# A\n\nLiteral \\ here.\n\nNext.\n\nText after.\n\nA b\n") + assert "Literal here." in output + assert "\nNext.\n" in output + assert "Text after." in output + assert "A b" in output + + +def test_a_comment_opened_in_a_list_item() -> None: + output = text("# A\n\n- a\n- b shown\n- d\n\n- Item tail\n") + assert "(a) a\n(b) b\n(c) shown\n(d) d" in output + assert "hidden" not in output + assert "tail" in output + + +def test_a_comment_closing_in_a_list_keeps_its_items() -> None: + output = text("# A\n\nText \n- three {#three}\n- four\n\nSee {{ref: three}}.\n") + assert "(a) three\n(b) four" in output + assert "See 1(a)." in output + + def test_a_dropped_comment_or_tag_leaves_one_space() -> None: - result = render(FRONT + "# A\n\na b
      c d\n", format="text") - assert "a b c d" in result.output + result = render(FRONT + "# A\n\na b
      c d Line
      two Wordboldword\n", + format="text") + assert "a b c d Line two Wordboldword" in result.output warnings = [d for d in result.diagnostics if d.rule == "raw-html"] assert len(warnings) == 1 and "in 1 place" in warnings[0].message From 03dd9eac65ea16fca8300131bc8580bcb51c75c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:17:25 +0000 Subject: [PATCH 15/22] =?UTF-8?q?Add=20the=20final=20check=20(=C2=A715.9):?= =?UTF-8?q?=20--final=20and=20RenderOptions.final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator's final check reports a remaining blank (placeholder-unfilled) or template construct (template-construct-present) as an Error; with --strict, such a document is refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- CONFORMANCE.md | 1 + README.md | 5 +++-- docs/style-templates.md | 2 +- src/legaldown_render/api.py | 9 +++++++-- src/legaldown_render/cli.py | 5 ++++- tests/test_cli.py | 7 +++++++ tests/test_render.py | 11 +++++++++++ 7 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index d6f220f..872ff13 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -38,6 +38,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes | Automatic term recognition (§7.4, MAY) | ❌ Not supported | | Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the tags are dropped with the Warning, and the cell text renders as paragraph text | | Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` | +| Final check (§15.9, SHOULD) | ✅ `--final` / `final=True`: the validator's `placeholder-unfilled` and `template-construct-present` Errors; with `--strict`, the document is refused | ## Known limitations diff --git a/README.md b/README.md index 0c01070..4a4e3c0 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ legaldown-render contract.lgd -o contract.txt # plain text (fo legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumeration.enabled=false legaldown-render contract.lgd --strict # refuse if the document has errors +legaldown-render contract.lgd --final --strict # refuse if blanks or template constructs remain legaldown-render --print-style --style continental # every setting, as YAML ``` @@ -97,8 +98,8 @@ definitions: { style: small-caps } typography: { font_family: "Georgia, serif", justify: true } ``` -**Render options** say what one job does: the output format, strict mode, a full page or an HTML -fragment. Values layer in this order: built-in defaults, then the `extends` chain, then your +**Render options** say what one job does: the output format, strict mode, the final check, a full +page or an HTML fragment. Values layer in this order: built-in defaults, then the `extends` chain, then your style, then per-job `--set` overrides. Every value is validated before rendering starts. See [Styles and preferences](docs/style-templates.md). diff --git a/docs/style-templates.md b/docs/style-templates.md index 169969c..b00623f 100644 --- a/docs/style-templates.md +++ b/docs/style-templates.md @@ -11,7 +11,7 @@ The renderer keeps two kinds of settings apart on purpose | | Style template | Render options | |---|---|---| | Answers | How documents look | What this one job does | -| Examples | Numbering scheme, list enumeration, locale, labels, fonts, headings | Output format, strict mode, full page or HTML fragment | +| Examples | Numbering scheme, list enumeration, locale, labels, fonts, headings | Output format, strict mode, final check, full page or HTML fragment | | Where | A YAML file, reused across documents and shared across a team | CLI flags or `RenderOptions` | | Override per job | `--set key=value`, `--locale`, `overrides={...}` | n/a | diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py index 9a32e7e..ba3c542 100644 --- a/src/legaldown_render/api.py +++ b/src/legaldown_render/api.py @@ -6,7 +6,7 @@ locale, labels, typography. It is a reusable YAML file (``--style``), with single values overridable per job (``--set key=value``, ``overrides``); * **render options** say what this one job does — the output format, strict - mode, a full HTML page or a fragment. They never change how a document + mode, the final check, a full HTML page or a fragment. They never change how a document looks, so they are not part of the style. """ from __future__ import annotations @@ -42,6 +42,11 @@ class RenderOptions: #: Refuse to render (raise RenderRefused) when the document has errors, #: instead of rendering them as visible failure markers. strict: bool = False + #: The final check (§15.9): the document is meant for signature, so a + #: remaining blank (``placeholder-unfilled``) or template construct + #: (``template-construct-present``) is an Error. With ``strict``, such a + #: document is refused. + final: bool = False #: HTML only: a complete page (True) or just the ``
      `` (False). standalone: bool = True @@ -90,7 +95,7 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any source = normalize_source(source) document = _parse(source) - result = validate_document(document) + result = validate_document(document, final=options.final) diagnostics = list(result.diagnostics) if options.strict and any(d.level == "error" for d in diagnostics): raise RenderRefused(diagnostics) diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py index 311808c..add0891 100644 --- a/src/legaldown_render/cli.py +++ b/src/legaldown_render/cli.py @@ -47,6 +47,9 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--locale", help="formatting locale, e.g. en-US or cs-CZ (default: the style's, else the document language)") parser.add_argument("--fragment", action="store_true", help="HTML: write only the
      , without page and stylesheet") parser.add_argument("--strict", action="store_true", help="refuse to render a document that has errors") + parser.add_argument("--final", action="store_true", + help="the document is meant for signature: a remaining blank, condition, choice, or " + "drafting note is an error (§15.9); with --strict, it is refused") parser.add_argument("-q", "--quiet", action="store_true", help="do not list diagnostics") parser.add_argument("--print-style", action="store_true", help="print the effective style — the one --style, --set and --locale produce — as YAML, and exit") @@ -84,7 +87,7 @@ def main(argv: list[str] | None = None) -> int: return 1 options = RenderOptions(format=output_format, style=args.style, overrides=overrides, - strict=args.strict, standalone=not args.fragment) + strict=args.strict, final=args.final, standalone=not args.fragment) try: result = render(source, options) except StyleError as error: diff --git a/tests/test_cli.py b/tests/test_cli.py index 9ab55e5..2d0ab3e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,6 +35,13 @@ def test_strict_exits_1(capsys: pytest.CaptureFixture[str]) -> None: assert "refused" in capsys.readouterr().err +def test_final_with_strict_exits_1(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: + template = str(DOCUMENTS / "template.lgd") + assert main([template, "--final", "--quiet", "-o", str(tmp_path / "out.html")]) == 0 + assert main([template, "--final", "--strict"]) == 1 + assert "template-construct-present" in capsys.readouterr().err + + def test_print_and_list_styles(capsys: pytest.CaptureFixture[str]) -> None: assert main(["--print-style", "--style", "continental", "--set", "locale=de"]) == 0 printed = capsys.readouterr().out diff --git a/tests/test_render.py b/tests/test_render.py index 34bc208..c936efd 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -226,6 +226,17 @@ def test_strict_refuses_documents_with_errors() -> None: render(FRONT + "# A\n\nFine.\n", strict=True) +def test_final_check_reports_blanks_and_template_constructs() -> None: + body = "# A\n\nPay {{placeholder: fee, type=text}}.\n\n> [!DRAFTING]\n> Check the fee.\n" + assert not {"placeholder-unfilled", "template-construct-present"} & {d.rule for d in render(FRONT + body).diagnostics} + result = render(FRONT + body, final=True) + assert {"placeholder-unfilled", "template-construct-present"} <= {d.rule for d in result.diagnostics} + assert not result.ok + with pytest.raises(RenderRefused): + render(FRONT + body, final=True, strict=True) + render(FRONT + "# A\n\nFine.\n", final=True, strict=True) + + def test_locale_option_and_language_hint() -> None: body = "# A\n\n{{date: 2026-06-01}}\n" assert "1. června 2026" in text(body, locale="cs-CZ") From 8df1b9b66b4b9b958d2cb1d86b3064bcdc123d18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:19:13 +0000 Subject: [PATCH 16/22] Add a table of contents as a style setting contents: { enabled, depth (1-5), attachments } lists the sections down to the depth, then the attachment placeholders, after the title block. Entries hold plain text, so no link, definition, or anchor from a heading is repeated; in HTML each entry links to its section. Conditional sections carry their condition label in the template view. The "Contents" label follows the document language. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- README.md | 1 + docs/roadmap.md | 1 + docs/style-templates.md | 3 ++- src/legaldown_render/resolve/resolver.py | 27 +++++++++++++++++++++++- src/legaldown_render/style/labels.py | 2 ++ src/legaldown_render/style/model.py | 13 ++++++++++++ src/legaldown_render/tree.py | 15 +++++++++++++ src/legaldown_render/writers/html.py | 26 +++++++++++++++++++++++ src/legaldown_render/writers/text.py | 8 +++++++ tests/golden/features.default.html | 8 +++++++ tests/golden/template.default.html | 8 +++++++ tests/test_render.py | 26 +++++++++++++++++++++++ 12 files changed, 136 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a4e3c0..b4147fc 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ legaldown-render contract.lgd -o contract.html # HTML page legaldown-render contract.lgd -o contract.txt # plain text (format from extension) legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumeration.enabled=false +legaldown-render contract.lgd --set contents.enabled=true --set contents.depth=3 # table of contents legaldown-render contract.lgd --strict # refuse if the document has errors legaldown-render contract.lgd --final --strict # refuse if blanks or template constructs remain legaldown-render --print-style --style continental # every setting, as YAML diff --git a/docs/roadmap.md b/docs/roadmap.md index cb45790..7f71ba4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,6 +28,7 @@ here. - Every directive, with every failure marker - Template view: conditions, `{{choose:}}`, drafting notes, alternatives sharing a number - Title block, attachment placeholders, signature blocks +- A table of contents (`contents` style setting) and the final check (`--final`, §15.9) - Style templates: layering, `extends`, `--set`, validation, and three built-in styles; labels in English and Czech - `text` and `html` writers; the `legaldown-render` CLI diff --git a/docs/style-templates.md b/docs/style-templates.md index b00623f..9865c12 100644 --- a/docs/style-templates.md +++ b/docs/style-templates.md @@ -104,6 +104,7 @@ placeholders: show_prompt: false # show the question's prompt next to the blank (§15.8) title_block: { parties: true, effective_date: true, version: false } +contents: { enabled: false, depth: 2, attachments: true } # table of contents after the title block; depth 1-5 attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none signatures: { enabled: true } # the document's include_signatures: false wins template_view: { choice_separator: " / ", empty_choice: "" } @@ -112,7 +113,7 @@ labels: # every word the renderer generates; unset: built-i drafting_note: Drafting note condition: "Only if: {condition}" # identification_number, date_of_birth, address, represented_by, effective_date, version, - # attachments, attachment_file ("{file}"), signatures, signature_date, signature_place, + # attachments, contents, attachment_file ("{file}"), signatures, signature_date, signature_place, # signature_name, signature_title typography: diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py index cbee02d..cee493a 100644 --- a/src/legaldown_render/resolve/resolver.py +++ b/src/legaldown_render/resolve/resolver.py @@ -39,6 +39,7 @@ Block, Choice, CodeBlock, + ContentsEntry, CrossRef, DefinedTerm, DefinitionSource, @@ -155,6 +156,7 @@ def run(self) -> RenderTree: ) for section in sections ) + attachments = tuple(replace(a, title=resolve(a.title)) for a in attachments) resolved = replace( self.tree, title=resolve(self.tree.title), @@ -164,8 +166,10 @@ def run(self) -> RenderTree: sections=sections, header=self._header(), sides=self._sides() if self.style.title_block.parties else (), - attachments=tuple(replace(a, title=resolve(a.title)) for a in attachments), + attachments=attachments, attachments_label=self.labels.attachments or "", + contents=self._contents(sections, attachments), + contents_label=self.labels.contents or "", signatures=self._signatures(), signature_labels={ "date": self.labels.signature_date or "", @@ -180,6 +184,27 @@ def run(self) -> RenderTree: # definition may live in an attachment or an amended original. return map_tree_inlines(resolved, self._settle_term_target) + def _contents( + self, sections: tuple[Section, ...], attachments: tuple[AttachmentPart, ...], + ) -> tuple[ContentsEntry, ...]: + """The table of contents, when the style enables it: every section + down to its depth, then the attachment placeholders.""" + settings = self.style.contents + if not settings.enabled: + return () + entries = [ + ContentsEntry(section.level, section.label, plain_text(section.title), section.anchor, + section.condition_label) + for section in sections + if section.level <= settings.depth + ] + if settings.attachments: + entries += [ + ContentsEntry(1, None, plain_text(attachment.title), attachment.anchor, attachment.condition_label) + for attachment in attachments + ] + return tuple(entries) + def _settle_term_target(self, inline: Inline) -> Inline: if isinstance(inline, TermRef) and inline.target and inline.target not in self.used_anchors: return replace(inline, target=None) diff --git a/src/legaldown_render/style/labels.py b/src/legaldown_render/style/labels.py index 3a9625c..a51db04 100644 --- a/src/legaldown_render/style/labels.py +++ b/src/legaldown_render/style/labels.py @@ -17,6 +17,7 @@ effective_date="Effective date", version="Version", attachments="Attachments", + contents="Contents", attachment_file="File: {file}", signatures="Signatures", signature_date="Date", @@ -34,6 +35,7 @@ effective_date="Datum účinnosti", version="Verze", attachments="Přílohy", + contents="Obsah", attachment_file="Soubor: {file}", signatures="Podpisy", signature_date="Datum", diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py index 389526f..904d881 100644 --- a/src/legaldown_render/style/model.py +++ b/src/legaldown_render/style/model.py @@ -148,6 +148,17 @@ class Attachments: separator: Literal["page-break", "rule", "none"] = "page-break" +@dataclass(frozen=True, slots=True) +class Contents: + """A table of contents, after the title block.""" + + enabled: bool = False + #: The deepest heading level listed. + depth: Literal[1, 2, 3, 4, 5] = 2 + #: List the attachment placeholders after the sections. + attachments: bool = True + + @dataclass(frozen=True, slots=True) class Signatures: """Generated signature blocks (§2.2). The document's @@ -182,6 +193,7 @@ class Labels: effective_date: str | None = None version: str | None = None attachments: str | None = None + contents: str | None = None #: ``{file}`` is the attachment's declared file. attachment_file: str | None = None signatures: str | None = None @@ -258,6 +270,7 @@ class Style: values: Values = field(default_factory=Values) placeholders: Placeholders = field(default_factory=Placeholders) title_block: TitleBlock = field(default_factory=TitleBlock) + contents: Contents = field(default_factory=Contents) attachments: Attachments = field(default_factory=Attachments) signatures: Signatures = field(default_factory=Signatures) template_view: TemplateView = field(default_factory=TemplateView) diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py index 0a2384c..d7e6094 100644 --- a/src/legaldown_render/tree.py +++ b/src/legaldown_render/tree.py @@ -290,6 +290,18 @@ class SignatureParty: signatories: tuple[tuple[tuple[Inline, ...], tuple[Inline, ...]], ...] +@dataclass(frozen=True, slots=True) +class ContentsEntry: + """One line of the table of contents: a section or an attachment. Its + text is plain, so that no link or anchor is repeated from the body.""" + + level: int + label: str | None + text: str + anchor: str | None + condition_label: str | None = None + + @dataclass(frozen=True, slots=True) class RenderTree: """The whole document. Built with source inlines; resolved in place of @@ -311,6 +323,9 @@ class RenderTree: attachments_label: str = "" signatures: tuple[SignatureParty, ...] = () signature_labels: dict[str, str] = field(default_factory=dict) + #: The table of contents; empty unless the style enables it. + contents: tuple[ContentsEntry, ...] = () + contents_label: str = "" # --------------------------------------------------------------------------- diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py index 5537b66..83935d6 100644 --- a/src/legaldown_render/writers/html.py +++ b/src/legaldown_render/writers/html.py @@ -92,6 +92,8 @@ def __init__(self, style: Style, *, standalone: bool = True, generator: str = "" def write(self, tree: RenderTree) -> str: parts: list[str] = [f'
      '] parts.append(self.title_block(tree)) + if tree.contents: + parts.append(self.contents(tree)) if tree.preamble: parts.append('
      ') parts += [self.block(block) for block in tree.preamble] @@ -143,6 +145,22 @@ def title_block(self, tree: RenderTree) -> str: parts.append("") return "\n".join(parts) + def contents(self, tree: RenderTree) -> str: + """The table of contents: one link per entry, indented by level.""" + parts = [f'"] + return "\n".join(parts) + def sections(self, sections: tuple[Section, ...]) -> list[str]: """Sections nested by level, each a ``
      `` that closes before the next heading of the same or a higher level.""" @@ -387,6 +405,14 @@ def inline(self, inline: Inline) -> str: p.ld-condition { margin: 0 0 0.5em; } .ld-conditional { border-left: 2px dashed #b9a6e8; padding-left: 0.6em; } .ld-choice { background: #efe9fb; border-radius: 3px; padding: 0 0.2em; } +.ld-contents { margin: 2em 0; } +.ld-contents-list { list-style: none; padding-left: 0; } +.ld-contents-list li { margin: 0.2em 0; } +.ld-contents-list a { color: inherit; text-decoration: none; } +.ld-contents-level-2 { padding-left: 1.5em; } +.ld-contents-level-3 { padding-left: 3em; } +.ld-contents-level-4 { padding-left: 4.5em; } +.ld-contents-level-5 { padding-left: 6em; } .ld-attachments { margin-top: 3em; } .ld-attachment-file { color: var(--ld-muted); } .ld-signatures { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 2rem; margin-top: 3em; } diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py index 28959b7..5e58368 100644 --- a/src/legaldown_render/writers/text.py +++ b/src/legaldown_render/writers/text.py @@ -88,6 +88,14 @@ def write(self, tree: RenderTree) -> str: for name, title in party.representatives: lines.append(_INDENT + ", ".join(filter(None, (inline_text(name), inline_text(title))))) chunks.append("\n".join(lines)) + if tree.contents: + lines = [tree.contents_label] + for entry in tree.contents: + line = _INDENT * (entry.level - 1) + " ".join(filter(None, (entry.label, entry.text))) + if entry.condition_label: + line += f" [{entry.condition_label}]" + lines.append(line) + chunks.append("\n".join(lines)) chunks += [self.block(block) for block in tree.preamble] for section in tree.sections: heading = " ".join(filter(None, (section.label, inline_text(section.title)))) diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html index 3940d23..279fb30 100644 --- a/tests/golden/features.default.html +++ b/tests/golden/features.default.html @@ -50,6 +50,14 @@ p.ld-condition { margin: 0 0 0.5em; } .ld-conditional { border-left: 2px dashed #b9a6e8; padding-left: 0.6em; } .ld-choice { background: #efe9fb; border-radius: 3px; padding: 0 0.2em; } +.ld-contents { margin: 2em 0; } +.ld-contents-list { list-style: none; padding-left: 0; } +.ld-contents-list li { margin: 0.2em 0; } +.ld-contents-list a { color: inherit; text-decoration: none; } +.ld-contents-level-2 { padding-left: 1.5em; } +.ld-contents-level-3 { padding-left: 3em; } +.ld-contents-level-4 { padding-left: 4.5em; } +.ld-contents-level-5 { padding-left: 6em; } .ld-attachments { margin-top: 3em; } .ld-attachment-file { color: var(--ld-muted); } .ld-signatures { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 2rem; margin-top: 3em; } diff --git a/tests/golden/template.default.html b/tests/golden/template.default.html index 3e637c5..a6a5503 100644 --- a/tests/golden/template.default.html +++ b/tests/golden/template.default.html @@ -50,6 +50,14 @@ p.ld-condition { margin: 0 0 0.5em; } .ld-conditional { border-left: 2px dashed #b9a6e8; padding-left: 0.6em; } .ld-choice { background: #efe9fb; border-radius: 3px; padding: 0 0.2em; } +.ld-contents { margin: 2em 0; } +.ld-contents-list { list-style: none; padding-left: 0; } +.ld-contents-list li { margin: 0.2em 0; } +.ld-contents-list a { color: inherit; text-decoration: none; } +.ld-contents-level-2 { padding-left: 1.5em; } +.ld-contents-level-3 { padding-left: 3em; } +.ld-contents-level-4 { padding-left: 4.5em; } +.ld-contents-level-5 { padding-left: 6em; } .ld-attachments { margin-top: 3em; } .ld-attachment-file { color: var(--ld-muted); } .ld-signatures { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 2rem; margin-top: 3em; } diff --git a/tests/test_render.py b/tests/test_render.py index c936efd..7a9d56d 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -226,6 +226,32 @@ def test_strict_refuses_documents_with_errors() -> None: render(FRONT + "# A\n\nFine.\n", strict=True) +def test_contents_lists_sections_to_their_depth_then_attachments() -> None: + body = "# One {#one}\n\nText.\n\n## Sub {#sub}\n\n### Deep\n\n# Two with **bold**\n" + assert "Contents" not in text(body) + output = text(body, overrides={"contents.enabled": True}) + assert "Contents\n1. One\n 1.1 Sub\n2. Two with bold\n\n" in output + output = text(body, overrides={"contents.enabled": True, "contents.depth": 3}) + assert " 1.1.1 Deep" in output + + +def test_contents_in_html_links_each_entry_without_repeating_anchors() -> None: + body = "# One {#one}\n\n\"Services\" {{def: services}} means work.\n\n# Uses {{term: services}}\n" + html = render(FRONT + body, standalone=False, overrides={"contents.enabled": True}).output + nav = html[html.index("")] + assert '1. One' in nav + assert " None: body = "# A\n\nPay {{placeholder: fee, type=text}}.\n\n> [!DRAFTING]\n> Check the fee.\n" assert not {"placeholder-unfilled", "template-construct-present"} & {d.rule for d in render(FRONT + body).diagnostics} From 07636bed0aa36c3a8b174d53e13ff234d3d057fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:20:03 +0000 Subject: [PATCH 17/22] Add built-in labels for German, French, Polish, and Slovak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated word follows the document's language (§13.7). French labels put a no-break space before the colon. A test checks that every built-in language sets every label and keeps its {condition} and {file} fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- CONFORMANCE.md | 2 +- docs/roadmap.md | 2 +- docs/style-templates.md | 4 +- src/legaldown_render/style/labels.py | 73 ++++++++++++++++++++++++++++ tests/test_render.py | 23 +++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 872ff13..bf98253 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -32,7 +32,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes | Paragraph numbering and section-qualified items (§13.2) | ✅ Style settings; the `continental` style uses both | | Style templates in a separate file (§13.7) | ✅ YAML, layered and validated; see [docs/style-templates.md](docs/style-templates.md) | | Signature blocks (§2.2) | ✅ One per party, with `legal_name` and each representative | -| Labels following the document language (§13.7) | ✅ Built in for `en` and `cs`; any label can be set by a style | +| Labels following the document language (§13.7) | ✅ Built in for `en`, `cs`, `de`, `fr`, `pl`, and `sk`; any label can be set by a style | | `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ | | `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ No tag is ever emitted. Text between an HTML block's tags renders as text until the validator's model recognises HTML blocks (see below) | | Automatic term recognition (§7.4, MAY) | ❌ Not supported | diff --git a/docs/roadmap.md b/docs/roadmap.md index 7f71ba4..9e29bb6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,7 +30,7 @@ here. - Title block, attachment placeholders, signature blocks - A table of contents (`contents` style setting) and the final check (`--final`, §15.9) - Style templates: layering, `extends`, `--set`, validation, and three built-in styles; labels in - English and Czech + English, Czech, German, French, Polish, and Slovak - `text` and `html` writers; the `legaldown-render` CLI - Golden tests, and a conformance run over the specification's examples and fixtures diff --git a/docs/style-templates.md b/docs/style-templates.md index 9865c12..3dcab74 100644 --- a/docs/style-templates.md +++ b/docs/style-templates.md @@ -152,8 +152,8 @@ are set off by a space ("Termination (a)", §13.3). ### Built-in labels -Labels follow the document's `language`. English (`en`) and Czech (`cs`) are built in, and other -languages fall back to English. A style sets any label explicitly to override it, which is also +Labels follow the document's `language`. English (`en`), Czech (`cs`), German (`de`), French +(`fr`), Polish (`pl`), and Slovak (`sk`) are built in, and other languages fall back to English. A style sets any label explicitly to override it, which is also how a firm adds a language. ## Open questions diff --git a/src/legaldown_render/style/labels.py b/src/legaldown_render/style/labels.py index a51db04..ea21ed3 100644 --- a/src/legaldown_render/style/labels.py +++ b/src/legaldown_render/style/labels.py @@ -43,6 +43,79 @@ signature_name="Jméno", signature_title="Funkce", ), + "de": Labels( + drafting_note="Bearbeitungshinweis", + condition="Nur wenn: {condition}", + identification_number="Registernummer", + date_of_birth="Geburtsdatum", + address="Anschrift", + represented_by="Vertreten durch", + effective_date="Inkrafttreten", + version="Version", + attachments="Anlagen", + contents="Inhaltsverzeichnis", + attachment_file="Datei: {file}", + signatures="Unterschriften", + signature_date="Datum", + signature_place="Ort", + signature_name="Name", + signature_title="Funktion", + ), + # French puts a no-break space before a colon. + "fr": Labels( + drafting_note="Note de rédaction", + condition="Uniquement si\u00a0: {condition}", + identification_number="N° d'immatriculation", + date_of_birth="Date de naissance", + address="Adresse", + represented_by="Représentée par", + effective_date="Date d'effet", + version="Version", + attachments="Annexes", + contents="Table des matières", + attachment_file="Fichier\u00a0: {file}", + signatures="Signatures", + signature_date="Date", + signature_place="Lieu", + signature_name="Nom", + signature_title="Qualité", + ), + "pl": Labels( + drafting_note="Uwaga redakcyjna", + condition="Tylko jeżeli: {condition}", + identification_number="Nr rejestrowy", + date_of_birth="Data urodzenia", + address="Adres", + represented_by="Reprezentacja", + effective_date="Data wejścia w życie", + version="Wersja", + attachments="Załączniki", + contents="Spis treści", + attachment_file="Plik: {file}", + signatures="Podpisy", + signature_date="Data", + signature_place="Miejsce", + signature_name="Imię i nazwisko", + signature_title="Stanowisko", + ), + "sk": Labels( + drafting_note="Poznámka pre spracovateľa", + condition="Iba ak: {condition}", + identification_number="IČO", + date_of_birth="Dátum narodenia", + address="Adresa", + represented_by="Zastúpenie", + effective_date="Dátum účinnosti", + version="Verzia", + attachments="Prílohy", + contents="Obsah", + attachment_file="Súbor: {file}", + signatures="Podpisy", + signature_date="Dátum", + signature_place="Miesto", + signature_name="Meno", + signature_title="Funkcia", + ), } diff --git a/tests/test_render.py b/tests/test_render.py index 7a9d56d..e69d955 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -160,6 +160,29 @@ def test_template_labels_follow_the_document_language() -> None: assert "> [Poznámka pro zpracovatele]" in output +@pytest.mark.parametrize(("language", "condition", "note"), [ + ("de", "[Nur wenn: non-solicit]", "[Bearbeitungshinweis]"), + ("fr", "[Uniquement si\u00a0: non-solicit]", "[Note de rédaction]"), + ("pl", "[Tylko jeżeli: non-solicit]", "[Uwaga redakcyjna]"), + ("sk", "[Iba ak: non-solicit]", "[Poznámka pre spracovateľa]"), +]) +def test_built_in_labels_for_more_languages(language: str, condition: str, note: str) -> None: + source = (DOCUMENTS / "template.lgd").read_text(encoding="utf-8").replace("language: en", f"language: {language}") + output = render(source, format="text").output + assert condition in output + assert note in output + + +def test_every_built_in_language_sets_every_label() -> None: + from dataclasses import fields + + from legaldown_render.style.labels import BUILTIN_LABELS + for language, labels in BUILTIN_LABELS.items(): + for item in fields(labels): + assert getattr(labels, item.name), f"{language}: {item.name} is not set" + assert "{condition}" in labels.condition and "{file}" in labels.attachment_file, language + + # -- HTML safety and structure --------------------------------------------------- From ef7d831c4230b6d7e18d333f85772f78bdf34983 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:20:22 +0000 Subject: [PATCH 18/22] Write no-break spaces in tests as escapes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- tests/test_render.py | 2 +- tests/test_values.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_render.py b/tests/test_render.py index e69d955..51a18cb 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -409,7 +409,7 @@ def test_sentinels_percent_encoded_in_urls_stay_as_written() -> None: def test_emphasis_wrapping_only_the_term_is_left_to_the_style() -> None: - body = '# D\n\n*"Fee"* {{def:}} means the fee, and "Tax" {{def:}} means tax.\n' + body = '# D\n\n*"Fee"* {{def:}} means the fee, and "Tax"\xa0{{def:}} means tax.\n' output = render(FRONT + body, standalone=False, overrides={"definitions.style": "plain"}).output assert 'Fee means the fee' in output assert 'Tax means tax.' in output diff --git a/tests/test_values.py b/tests/test_values.py index c563eb7..55bea26 100644 --- a/tests/test_values.py +++ b/tests/test_values.py @@ -37,7 +37,7 @@ def test_money_is_padded_never_rounded() -> None: def test_money_display_styles() -> None: - assert formatter("en-US", money="code").money("10", "USD") == "USD 10.00" + assert formatter("en-US", money="code").money("10", "USD") == "USD\xa010.00" assert formatter("en-US", money="name").money("10", "USD") == "10.00 US dollars" @@ -45,7 +45,7 @@ def test_locales() -> None: cs = formatter("cs-CZ") assert cs.date("2026-06-01") == "1. června 2026" assert cs.duration("12", "MO") == "12 měsíců" - assert cs.money("10000", "CZK") == "10 000,00 Kč" + assert cs.money("10000", "CZK") == "10\xa0000,00\xa0Kč" assert formatter("de").duration("1", "Y") == "1 Jahr" assert formatter("en-GB", date="short").date("2026-06-01") == "01/06/2026" From 04589fa19f5ad4c3b0c7447835977d6dca935732 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:12:06 +0000 Subject: [PATCH 19/22] Fix code review findings on the contents, labels, and final check - Contents: filter and indent by how deep a section's number goes, not its heading level; keep each heading's formatting (prompts, failure markers) but none of its links or anchors; list attachments under their heading; leave out an empty heading; emit the contents CSS only when the contents are enabled - Reject a boolean or float for an integer choice such as contents.depth - A per-language labels.colon between a label and its value, so French output spaces every colon - Render the represented_by label: a representative is a labelled row of the party's details - Export Contents from legaldown_render.style; mention the questions key in the --final help Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- docs/style-templates.md | 2 + src/legaldown_render/cli.py | 4 +- src/legaldown_render/resolve/resolver.py | 26 +++++++----- src/legaldown_render/style/__init__.py | 2 + src/legaldown_render/style/labels.py | 6 +++ src/legaldown_render/style/loader.py | 3 +- src/legaldown_render/style/model.py | 3 ++ src/legaldown_render/tree.py | 49 ++++++++++++++++++----- src/legaldown_render/writers/html.py | 49 +++++++++++++---------- src/legaldown_render/writers/text.py | 20 +++++----- tests/golden/features.continental.txt | 2 +- tests/golden/features.default.html | 11 +---- tests/golden/features.default.txt | 2 +- tests/golden/features.outline.txt | 2 +- tests/golden/template.default.html | 9 ----- tests/test_render.py | 51 ++++++++++++++++++++++++ 16 files changed, 165 insertions(+), 76 deletions(-) diff --git a/docs/style-templates.md b/docs/style-templates.md index 3dcab74..75c1b91 100644 --- a/docs/style-templates.md +++ b/docs/style-templates.md @@ -105,6 +105,7 @@ placeholders: title_block: { parties: true, effective_date: true, version: false } contents: { enabled: false, depth: 2, attachments: true } # table of contents after the title block; depth 1-5 + # counts number levels ("2.1" is 2); attachments listed last attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none signatures: { enabled: true } # the document's include_signatures: false wins template_view: { choice_separator: " / ", empty_choice: "" } @@ -115,6 +116,7 @@ labels: # every word the renderer generates; unset: built-i # identification_number, date_of_birth, address, represented_by, effective_date, version, # attachments, contents, attachment_file ("{file}"), signatures, signature_date, signature_place, # signature_name, signature_title + colon: ": " # between a label and its value; French: no-break space, then ": " typography: font_family: 'Georgia, "Times New Roman", serif' diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py index add0891..a572acc 100644 --- a/src/legaldown_render/cli.py +++ b/src/legaldown_render/cli.py @@ -48,8 +48,8 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--fragment", action="store_true", help="HTML: write only the
      , without page and stylesheet") parser.add_argument("--strict", action="store_true", help="refuse to render a document that has errors") parser.add_argument("--final", action="store_true", - help="the document is meant for signature: a remaining blank, condition, choice, or " - "drafting note is an error (§15.9); with --strict, it is refused") + help="the document is meant for signature: a remaining blank, questions key, condition, " + "choice, or drafting note is an error (§15.9); with --strict, it is refused") parser.add_argument("-q", "--quiet", action="store_true", help="do not list diagnostics") parser.add_argument("--print-style", action="store_true", help="print the effective style — the one --style, --set and --locale produce — as YAML, and exit") diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py index cee493a..9d78ae0 100644 --- a/src/legaldown_render/resolve/resolver.py +++ b/src/legaldown_render/resolve/resolver.py @@ -62,10 +62,12 @@ Strong, Table, TermRef, + Text, Value, iter_tree_inlines, map_tree_inlines, plain_text, + unlinked, ) from .numbering import RENUMBERED, extend, fill, format_counter, heading_levels from .values import DURATION_UNITS, Formatter @@ -170,6 +172,7 @@ def run(self) -> RenderTree: attachments_label=self.labels.attachments or "", contents=self._contents(sections, attachments), contents_label=self.labels.contents or "", + colon=self.labels.colon or ": ", signatures=self._signatures(), signature_labels={ "date": self.labels.signature_date or "", @@ -188,19 +191,21 @@ def _contents( self, sections: tuple[Section, ...], attachments: tuple[AttachmentPart, ...], ) -> tuple[ContentsEntry, ...]: """The table of contents, when the style enables it: every section - down to its depth, then the attachment placeholders.""" + whose number is at most *depth* levels deep, indented by that depth, + then the attachment placeholders under the attachments heading.""" settings = self.style.contents if not settings.enabled: return () entries = [ - ContentsEntry(section.level, section.label, plain_text(section.title), section.anchor, + ContentsEntry(section.depth, section.label, unlinked(section.title), section.anchor, section.condition_label) for section in sections - if section.level <= settings.depth + if section.depth <= settings.depth ] - if settings.attachments: + if settings.attachments and attachments: + entries.append(ContentsEntry(1, None, (Text(self.labels.attachments or ""),), None)) entries += [ - ContentsEntry(1, None, plain_text(attachment.title), attachment.anchor, attachment.condition_label) + ContentsEntry(2, None, unlinked(attachment.title), attachment.anchor, attachment.condition_label) for attachment in attachments ] return tuple(entries) @@ -283,9 +288,11 @@ def _number_sections(self) -> list[Section]: label = fill(fmt.label, n=format_counter(counters[level], fmt.counter), path=designation) anchor = self._anchor(section.identifier) self._register(section.identifier, _Target(designation, anchor)) + depth = sum(1 for counter in counters[1:level + 1] if counter) # The level is stored clamped too, so every writer nests the section # where its number puts it. - out.append(replace(section, level=level, label=label, designation=designation, anchor=anchor)) + out.append(replace(section, level=level, label=label, designation=designation, anchor=anchor, + depth=depth)) return out def _presence(self, condition: str) -> Presence: @@ -655,13 +662,12 @@ def _sides(self) -> tuple[SideInfo, ...]: details.append((self.labels.address or "", self._frontmatter(party.address))) for custom in party.custom_fields: details.append((custom.label, self._frontmatter(custom.value))) - representatives = tuple( - (self._frontmatter(rep.name), self._frontmatter(rep.title)) for rep in party.representatives - ) + for rep in party.representatives: + name, title = self._frontmatter(rep.name), self._frontmatter(rep.title) + details.append((self.labels.represented_by or "", name + ((Text(", "),) + title if title else ()))) parties.append(PartyInfo( name=self._frontmatter(party.legal_name or party.label or party.name), details=tuple(details), - representatives=representatives, )) label = self.result.side_lookup.get(side.name) or side.label or side.name sides.append(SideInfo(self._frontmatter(label), tuple(parties))) diff --git a/src/legaldown_render/style/__init__.py b/src/legaldown_render/style/__init__.py index b860c31..bbee595 100644 --- a/src/legaldown_render/style/__init__.py +++ b/src/legaldown_render/style/__init__.py @@ -3,6 +3,7 @@ from .loader import StyleError, builtin_styles, dump_style, load_style, parse_override, style_to_dict from .model import ( Attachments, + Contents, Definitions, Enumeration, HeadingStyle, @@ -25,6 +26,7 @@ __all__ = [ "BUILTIN_LABELS", "Attachments", + "Contents", "Definitions", "Enumeration", "HeadingStyle", diff --git a/src/legaldown_render/style/labels.py b/src/legaldown_render/style/labels.py index ea21ed3..19a432a 100644 --- a/src/legaldown_render/style/labels.py +++ b/src/legaldown_render/style/labels.py @@ -18,6 +18,7 @@ version="Version", attachments="Attachments", contents="Contents", + colon=": ", attachment_file="File: {file}", signatures="Signatures", signature_date="Date", @@ -36,6 +37,7 @@ version="Verze", attachments="Přílohy", contents="Obsah", + colon=": ", attachment_file="Soubor: {file}", signatures="Podpisy", signature_date="Datum", @@ -54,6 +56,7 @@ version="Version", attachments="Anlagen", contents="Inhaltsverzeichnis", + colon=": ", attachment_file="Datei: {file}", signatures="Unterschriften", signature_date="Datum", @@ -73,6 +76,7 @@ version="Version", attachments="Annexes", contents="Table des matières", + colon="\u00a0: ", attachment_file="Fichier\u00a0: {file}", signatures="Signatures", signature_date="Date", @@ -91,6 +95,7 @@ version="Wersja", attachments="Załączniki", contents="Spis treści", + colon=": ", attachment_file="Plik: {file}", signatures="Podpisy", signature_date="Data", @@ -109,6 +114,7 @@ version="Verzia", attachments="Prílohy", contents="Obsah", + colon=": ", attachment_file="Súbor: {file}", signatures="Podpisy", signature_date="Dátum", diff --git a/src/legaldown_render/style/loader.py b/src/legaldown_render/style/loader.py index 7e48003..c3239ac 100644 --- a/src/legaldown_render/style/loader.py +++ b/src/legaldown_render/style/loader.py @@ -256,7 +256,8 @@ def _convert(value: Any, hint: Any, path: str, problems: list[str]) -> Any: return _convert_dataclass(value, hint, path, problems) if origin is Literal: allowed = get_args(hint) - if value not in allowed: + # By type too: True == 1 and 2.0 == 2, but neither is a level. + if not any(value == option and type(value) is type(option) for option in allowed): problems.append(f"{label}: must be one of {', '.join(map(str, allowed))} (got {value!r})") return allowed[0] return value diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py index 904d881..b7e7600 100644 --- a/src/legaldown_render/style/model.py +++ b/src/legaldown_render/style/model.py @@ -194,6 +194,9 @@ class Labels: version: str | None = None attachments: str | None = None contents: str | None = None + #: Between a label and its value ("Name: ..."); French puts a no-break + #: space before the colon. + colon: str | None = None #: ``{file}`` is the attachment's declared file. attachment_file: str | None = None signatures: str | None = None diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py index d7e6094..560ceb7 100644 --- a/src/legaldown_render/tree.py +++ b/src/legaldown_render/tree.py @@ -258,13 +258,17 @@ class Section: designation: str | None = None anchor: str | None = None condition_label: str | None = None + #: How deep its number goes: 1 for "2", 2 for "2.1". Less than + #: ``level`` when a heading level is skipped. + depth: int = 0 @dataclass(frozen=True, slots=True) class PartyInfo: name: tuple[Inline, ...] + #: (label, value) rows: identification, address, custom fields, and + #: one row per representative. details: tuple[tuple[str, tuple[Inline, ...]], ...] - representatives: tuple[tuple[tuple[Inline, ...], tuple[Inline, ...]], ...] @dataclass(frozen=True, slots=True) @@ -292,12 +296,13 @@ class SignatureParty: @dataclass(frozen=True, slots=True) class ContentsEntry: - """One line of the table of contents: a section or an attachment. Its - text is plain, so that no link or anchor is repeated from the body.""" + """One line of the table of contents: a section, the attachments + heading, or an attachment. Its title reads as in the body, without the + body's links and anchors (see :func:`unlinked`).""" level: int label: str | None - text: str + title: tuple[Inline, ...] anchor: str | None condition_label: str | None = None @@ -326,6 +331,8 @@ class RenderTree: #: The table of contents; empty unless the style enables it. contents: tuple[ContentsEntry, ...] = () contents_label: str = "" + #: Between a generated label and its value ("Name: ..."), per language. + colon: str = ": " # --------------------------------------------------------------------------- @@ -379,11 +386,10 @@ def iter_tree_inlines(tree: RenderTree) -> Iterator[Inline]: yield from iter_inlines(party.name) for _, value in party.details: yield from iter_inlines(value) - for name, title in party.representatives: - yield from iter_inlines(name) - yield from iter_inlines(title) for attachment in tree.attachments: yield from iter_inlines(attachment.title) + for entry in tree.contents: + yield from iter_inlines(entry.title) for signature in tree.signatures: yield from iter_inlines(signature.side) yield from iter_inlines(signature.name) @@ -438,16 +444,41 @@ def pair(value: tuple[Inline, ...]) -> tuple[Inline, ...]: header=tuple((label, pair(value)) for label, value in tree.header), sides=tuple(replace(side, label=pair(side.label), parties=tuple( replace(party, name=pair(party.name), - details=tuple((label, pair(value)) for label, value in party.details), - representatives=tuple((pair(name), pair(title)) for name, title in party.representatives)) + details=tuple((label, pair(value)) for label, value in party.details)) for party in side.parties)) for side in tree.sides), attachments=tuple(replace(attachment, title=pair(attachment.title)) for attachment in tree.attachments), + contents=tuple(replace(entry, title=pair(entry.title)) for entry in tree.contents), signatures=tuple(replace(signature, side=pair(signature.side), name=pair(signature.name), signatories=tuple((pair(name), pair(title)) for name, title in signature.signatories)) for signature in tree.signatures), ) +def unlinked(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: + """*inlines* as they read, without links or anchors: for text shown a + second time, such as a heading in the table of contents, which links + to the heading itself and must not repeat an anchor from the body.""" + out: list[Inline] = [] + for inline in inlines: + match inline: + case Link(children=children): + out.extend(unlinked(children)) + continue + case CrossRef(): + inline = replace(inline, target="") + case TermRef(): + inline = replace(inline, target=None) + case DefinedTerm(): + inline = replace(inline, anchor=None) + case Value(): + inline = replace(inline, href=None) + children = getattr(inline, "children", None) + if isinstance(children, tuple) and not isinstance(inline, Image): + inline = replace(inline, children=unlinked(children)) + out.append(inline) + return tuple(out) + + def assert_resolved(tree: RenderTree) -> None: """Raise :class:`InternalError` if *tree* still holds a source inline: a writer must never see LegalDown semantics (docs/decisions/0003).""" diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py index 83935d6..1d03075 100644 --- a/src/legaldown_render/writers/html.py +++ b/src/legaldown_render/writers/html.py @@ -102,7 +102,7 @@ def write(self, tree: RenderTree) -> str: if tree.attachments: parts.append(self.attachments(tree)) if tree.signatures: - parts.append(self.signatures(tree.signatures, tree.signature_labels)) + parts.append(self.signatures(tree.signatures, tree.signature_labels, tree.colon)) parts.append("
      ") body = "\n".join(part for part in parts if part) if not self.standalone: @@ -136,9 +136,6 @@ def title_block(self, tree: RenderTree) -> str: for label, value in party.details: parts.append(f"
      {_text(label)}
      {self.inlines(value)}
      ") parts.append("") - for name, title in party.representatives: - text = ", ".join(filter(None, (self.inlines(name), self.inlines(title)))) - parts.append(f'

      {text}

      ') parts.append("
      ") parts.append("") parts.append("") @@ -147,12 +144,17 @@ def title_block(self, tree: RenderTree) -> str: def contents(self, tree: RenderTree) -> str: """The table of contents: one link per entry, indented by level.""" - parts = [f'
    ") return "\n".join(parts) - def signatures(self, signatures: tuple[SignatureParty, ...], labels: dict[str, str]) -> str: + def signatures(self, signatures: tuple[SignatureParty, ...], labels: dict[str, str], colon: str) -> str: parts = [f'
    '] for signature in signatures: parts.append('
    ') @@ -214,9 +216,10 @@ def signatures(self, signatures: tuple[SignatureParty, ...], labels: dict[str, s parts.append(f'

    {self.inlines(signature.name)}

    ') for name, title in signature.signatories or (((), ()),): parts.append('
    ') - parts.append(f'

    {_text(labels["name"])}: {self.inlines(name)}

    ') - parts.append(f'

    {_text(labels["title"])}: {self.inlines(title)}

    ') - parts.append(f'

    {_text(labels["date"])}:

    \n

    {_text(labels["place"])}:

    ') + parts.append(f'

    {_text(labels["name"] + colon)}{self.inlines(name)}

    ') + parts.append(f'

    {_text(labels["title"] + colon)}{self.inlines(title)}

    ') + date, place = (_text((labels[key] + colon).rstrip()) for key in ("date", "place")) + parts.append(f"

    {date}

    \n

    {place}

    ") parts.append("") parts.append("
    ") return "\n".join(parts) @@ -371,7 +374,6 @@ def inline(self, inline: Inline) -> str: .ld-side-label { font-weight: 700; margin: 0 0 0.25em; } .ld-party-name { margin: 0; } .ld-party + .ld-party { margin-top: 1em; } -.ld-representative { margin: 0.25em 0 0; } .ld-heading { margin: 1.5em 0 0.5em; } .ld-heading .ld-number { display: inline-block; min-width: 2.5em; } .ld-p { margin: 0.6em 0; } @@ -405,14 +407,6 @@ def inline(self, inline: Inline) -> str: p.ld-condition { margin: 0 0 0.5em; } .ld-conditional { border-left: 2px dashed #b9a6e8; padding-left: 0.6em; } .ld-choice { background: #efe9fb; border-radius: 3px; padding: 0 0.2em; } -.ld-contents { margin: 2em 0; } -.ld-contents-list { list-style: none; padding-left: 0; } -.ld-contents-list li { margin: 0.2em 0; } -.ld-contents-list a { color: inherit; text-decoration: none; } -.ld-contents-level-2 { padding-left: 1.5em; } -.ld-contents-level-3 { padding-left: 3em; } -.ld-contents-level-4 { padding-left: 4.5em; } -.ld-contents-level-5 { padding-left: 6em; } .ld-attachments { margin-top: 3em; } .ld-attachment-file { color: var(--ld-muted); } .ld-signatures { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 2rem; margin-top: 3em; } @@ -429,6 +423,17 @@ def inline(self, inline: Inline) -> str: """ +_CONTENTS_CSS = """\ +.ld-contents { margin: 2em 0; } +.ld-contents-list { list-style: none; padding-left: 0; } +.ld-contents-list li { margin: 0.2em 0; } +.ld-contents-list a { color: inherit; text-decoration: none; } +.ld-contents-level-2 { padding-left: 1.5em; } +.ld-contents-level-3 { padding-left: 3em; } +.ld-contents-level-4 { padding-left: 4.5em; } +.ld-contents-level-5 { padding-left: 6em; }""" + + def stylesheet(style: Style) -> str: """The CSS for *style*: base rules plus everything the style sets.""" typography = style.typography @@ -449,6 +454,8 @@ def stylesheet(style: Style) -> str: elif heading.transform == "small-caps": rules.append("font-variant: small-caps") lines.append(f".ld-level-{level} > .ld-heading {{ {'; '.join(rules)}; }}") + if style.contents.enabled: + lines.append(_CONTENTS_CSS) if style.attachments.separator == "rule": lines.append(".ld-attachment { border-top: 1px solid var(--ld-rule); padding-top: 1em; }") lines.append(f"@page {{ size: {_css(style.page.size)}; margin: {_css(style.page.margin)}; }}") diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py index 5e58368..ac2dc6d 100644 --- a/src/legaldown_render/writers/text.py +++ b/src/legaldown_render/writers/text.py @@ -79,19 +79,17 @@ def write(self, tree: RenderTree) -> str: if subtitle: chunks.append(subtitle) if tree.header: - chunks.append("\n".join(f"{label}: {inline_text(value)}" for label, value in tree.header)) + chunks.append("\n".join(f"{label}{tree.colon}{inline_text(value)}" for label, value in tree.header)) for side in tree.sides: lines = [inline_text(side.label)] for party in side.parties: lines.append(_INDENT + inline_text(party.name)) - lines += [f"{_INDENT}{label}: {inline_text(value)}" for label, value in party.details] - for name, title in party.representatives: - lines.append(_INDENT + ", ".join(filter(None, (inline_text(name), inline_text(title))))) + lines += [f"{_INDENT}{label}{tree.colon}{inline_text(value)}" for label, value in party.details] chunks.append("\n".join(lines)) if tree.contents: - lines = [tree.contents_label] + lines = [tree.contents_label] if tree.contents_label else [] for entry in tree.contents: - line = _INDENT * (entry.level - 1) + " ".join(filter(None, (entry.label, entry.text))) + line = _INDENT * (entry.level - 1) + " ".join(filter(None, (entry.label, inline_text(entry.title)))) if entry.condition_label: line += f" [{entry.condition_label}]" lines.append(line) @@ -115,7 +113,7 @@ def write(self, tree: RenderTree) -> str: chunks.append("\n".join(lines)) if tree.signatures: chunks.append("---") - chunks += [self.signature(signature, tree.signature_labels) for signature in tree.signatures] + chunks += [self.signature(signature, tree.signature_labels, tree.colon) for signature in tree.signatures] return "\n\n".join(chunk for chunk in chunks if chunk) + "\n" def block(self, block: Block) -> str: @@ -174,17 +172,17 @@ def table(self, table: Table) -> str: lines += ["| " + " | ".join(row) + " |" for row in rows] return "\n".join(lines) - def signature(self, signature: SignatureParty, labels: dict[str, str]) -> str: + def signature(self, signature: SignatureParty, labels: dict[str, str], colon: str) -> str: lines = [inline_text(signature.side), inline_text(signature.name)] # With no declared representative, one blank signing line. for name, title in signature.signatories or (((), ()),): lines += [ "", "_" * 30, - f"{labels['name']}: {inline_text(name)}".rstrip(), - f"{labels['title']}: {inline_text(title)}".rstrip(), + f"{labels['name']}{colon}{inline_text(name)}".rstrip(), + f"{labels['title']}{colon}{inline_text(title)}".rstrip(), ] - lines += [f"{labels['date']}:", f"{labels['place']}:"] + lines += [f"{labels['date']}{colon}".rstrip(), f"{labels['place']}{colon}".rstrip()] return "\n".join(lines) diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt index 1303273..0e797a0 100644 --- a/tests/golden/features.continental.txt +++ b/tests/golden/features.continental.txt @@ -8,7 +8,7 @@ Provider Acme Corporation Registration No.: DE-12345678 Address: 123 Main Street, Dover, DE 19901 - John Smith, Chief Executive Officer + Represented by: John Smith, Chief Executive Officer Client Group Beta Industries Inc. diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html index 279fb30..8c36628 100644 --- a/tests/golden/features.default.html +++ b/tests/golden/features.default.html @@ -16,7 +16,6 @@ .ld-side-label { font-weight: 700; margin: 0 0 0.25em; } .ld-party-name { margin: 0; } .ld-party + .ld-party { margin-top: 1em; } -.ld-representative { margin: 0.25em 0 0; } .ld-heading { margin: 1.5em 0 0.5em; } .ld-heading .ld-number { display: inline-block; min-width: 2.5em; } .ld-p { margin: 0.6em 0; } @@ -50,14 +49,6 @@ p.ld-condition { margin: 0 0 0.5em; } .ld-conditional { border-left: 2px dashed #b9a6e8; padding-left: 0.6em; } .ld-choice { background: #efe9fb; border-radius: 3px; padding: 0 0.2em; } -.ld-contents { margin: 2em 0; } -.ld-contents-list { list-style: none; padding-left: 0; } -.ld-contents-list li { margin: 0.2em 0; } -.ld-contents-list a { color: inherit; text-decoration: none; } -.ld-contents-level-2 { padding-left: 1.5em; } -.ld-contents-level-3 { padding-left: 3em; } -.ld-contents-level-4 { padding-left: 4.5em; } -.ld-contents-level-5 { padding-left: 6em; } .ld-attachments { margin-top: 3em; } .ld-attachment-file { color: var(--ld-muted); } .ld-signatures { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 2rem; margin-top: 3em; } @@ -98,8 +89,8 @@

    Services Agreement

    Registration No.
    DE-12345678
    Address
    123 Main Street, Dover, DE 19901
    +
    Represented by
    John Smith, Chief Executive Officer
    -

    John Smith, Chief Executive Officer

    diff --git a/tests/golden/features.default.txt b/tests/golden/features.default.txt index 84c1c3f..d2e36ef 100644 --- a/tests/golden/features.default.txt +++ b/tests/golden/features.default.txt @@ -8,7 +8,7 @@ Provider Acme Corporation Registration No.: DE-12345678 Address: 123 Main Street, Dover, DE 19901 - John Smith, Chief Executive Officer + Represented by: John Smith, Chief Executive Officer Client Group Beta Industries Inc. diff --git a/tests/golden/features.outline.txt b/tests/golden/features.outline.txt index 81989f5..711a1d7 100644 --- a/tests/golden/features.outline.txt +++ b/tests/golden/features.outline.txt @@ -8,7 +8,7 @@ Provider Acme Corporation Registration No.: DE-12345678 Address: 123 Main Street, Dover, DE 19901 - John Smith, Chief Executive Officer + Represented by: John Smith, Chief Executive Officer Client Group Beta Industries Inc. diff --git a/tests/golden/template.default.html b/tests/golden/template.default.html index a6a5503..e59f922 100644 --- a/tests/golden/template.default.html +++ b/tests/golden/template.default.html @@ -16,7 +16,6 @@ .ld-side-label { font-weight: 700; margin: 0 0 0.25em; } .ld-party-name { margin: 0; } .ld-party + .ld-party { margin-top: 1em; } -.ld-representative { margin: 0.25em 0 0; } .ld-heading { margin: 1.5em 0 0.5em; } .ld-heading .ld-number { display: inline-block; min-width: 2.5em; } .ld-p { margin: 0.6em 0; } @@ -50,14 +49,6 @@ p.ld-condition { margin: 0 0 0.5em; } .ld-conditional { border-left: 2px dashed #b9a6e8; padding-left: 0.6em; } .ld-choice { background: #efe9fb; border-radius: 3px; padding: 0 0.2em; } -.ld-contents { margin: 2em 0; } -.ld-contents-list { list-style: none; padding-left: 0; } -.ld-contents-list li { margin: 0.2em 0; } -.ld-contents-list a { color: inherit; text-decoration: none; } -.ld-contents-level-2 { padding-left: 1.5em; } -.ld-contents-level-3 { padding-left: 3em; } -.ld-contents-level-4 { padding-left: 4.5em; } -.ld-contents-level-5 { padding-left: 6em; } .ld-attachments { margin-top: 3em; } .ld-attachment-file { color: var(--ld-muted); } .ld-signatures { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 2rem; margin-top: 3em; } diff --git a/tests/test_render.py b/tests/test_render.py index 51a18cb..02ff9b7 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -8,6 +8,7 @@ from conftest import DOCUMENTS from legaldown_render import DocumentError, RenderOptions, RenderRefused, render +from legaldown_render.style import StyleError FRONT = """--- title: T @@ -275,6 +276,56 @@ def test_contents_label_follows_the_language_and_marks_conditions() -> None: assert text("# A\n", overrides={"contents.enabled": True, "labels.contents": "Obsah"}).count("Obsah") == 1 +def test_contents_follow_the_numbering_depth_not_the_heading_level() -> None: + output = text("# A\n\n### B\n\n###### C\n", overrides={"contents.enabled": True}) + assert "Contents\n1. A\n 1.1 B\n\n" in output + + +def test_contents_group_attachments_under_their_heading() -> None: + output = render((DOCUMENTS / "features.lgd").read_text(encoding="utf-8"), format="text", + overrides={"contents.enabled": True}).output + contents = output[output.index("Contents\n"):].split("\n\n", 1)[0] + assert contents.endswith("\nAttachments\n Schedule A: Service Description\n Exhibit 1: Price List") + + +def test_contents_heading_reads_as_in_the_body_without_links() -> None: + body = "# See {{ref: nowhere}} and [site](https://example.com/x)\n" + html = render(FRONT + body, standalone=False, overrides={"contents.enabled": True}).output + nav = html[html.index("")] + assert 'class="ld-failure"' in nav + assert "example.com" not in nav + + +def test_an_empty_contents_label_leaves_out_the_heading() -> None: + settings = {"contents.enabled": True, "labels.contents": ""} + html = render(FRONT + "# A\n", standalone=False, overrides=settings).output + assert '
    ") parts.append("
") @@ -339,9 +340,10 @@ def inline(self, inline: Inline) -> str: if target is None or self._in_link: return f'{_text(text)}' return f'{_text(text)}' - case DefinedTerm(children=children, anchor=anchor, style=style): + case DefinedTerm(children=children, anchor=anchor, style=style, defining=defining): + tag = "dfn" if defining else "span" anchor_attr = f' id="{_attr(anchor)}"' if anchor else "" - return f'{self.inlines(children)}' + return f'<{tag} class="ld-defined ld-style-{style}"{anchor_attr}>{self.inlines(children)}' case Value(kind=kind, text=text, href=href): if href and is_safe_href(href) and not self._in_link: return f'{_text(text)}' diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py index ac2dc6d..9a4e665 100644 --- a/src/legaldown_render/writers/text.py +++ b/src/legaldown_render/writers/text.py @@ -182,7 +182,7 @@ def signature(self, signature: SignatureParty, labels: dict[str, str], colon: st f"{labels['name']}{colon}{inline_text(name)}".rstrip(), f"{labels['title']}{colon}{inline_text(title)}".rstrip(), ] - lines += [f"{labels['date']}{colon}".rstrip(), f"{labels['place']}{colon}".rstrip()] + lines += [f"{labels['date']}{colon}".rstrip(" "), f"{labels['place']}{colon}".rstrip(" ")] return "\n".join(lines) diff --git a/tests/test_render.py b/tests/test_render.py index 2c2cba1..e2a413e 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -352,8 +352,37 @@ def test_contents_link_the_attachments_heading_and_never_repeat_a_definition() - assert '
' in html html = render(FRONT + '# The "Term" {{def: term}}\n', standalone=False, overrides={"contents.enabled": True}).output nav = html[html.index("")] - assert "Term' in nav + assert "Term' in nav assert html.count("")] + assert 'Big Term' in nav + + +def test_an_identifier_with_a_colon_never_becomes_an_anchor() -> None: + source = (DOCUMENTS / "features.lgd").read_text(encoding="utf-8") + html = render(source + "\nText. {#ld:attachments}\n\n- item {#def:services}\n", standalone=False).output + assert html.count('id="ld:attachments"') == 1 + assert html.count('id="def:services"') == 1 + + +def test_an_empty_attachments_label_leaves_out_the_heading() -> None: + html = render((DOCUMENTS / "features.lgd").read_text(encoding="utf-8"), standalone=False, + overrides={"labels.attachments": ""}).output + assert "ld-attachments-heading" not in html + + +def test_a_representative_with_nothing_to_show_has_no_row() -> None: + front = FRONT.replace(" legal_name: Acme Corporation\n", + " legal_name: Acme Corporation\n representatives:\n - {}\n", 1) + assert "Represented by" not in render(front + "# A\n", format="text").output + + +def test_a_colon_ending_in_a_no_break_space_keeps_it_after_date_and_place() -> None: + output = render((DOCUMENTS / "features.lgd").read_text(encoding="utf-8"), format="text", + overrides={"labels.colon": " \u2013\xa0"}).output + assert "\nDate \u2013\xa0\n" in output and "\nPlace \u2013\xa0\n" in output def test_final_check_reports_blanks_and_template_constructs() -> None: From 3834f3fa6853b5837b3166584b1a34e1d3a546c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:10:02 +0000 Subject: [PATCH 22/22] README: install from GitHub until the first release Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFB1eaR4Fv8vUr2y8RXQt --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b4147fc..fdd7d0e 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,10 @@ This package never reimplements the LegalDown grammar. ## Install +`legaldown-render` is not on PyPI yet. Until its first release, install it from GitHub: + ```bash -pip install legaldown-render +pip install "legaldown-render @ git+https://github.com/ForLegalAI/legaldown-render" ``` Python 3.11 or newer. The dependencies are all pure Python: `legaldown-validator`,