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/.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/CONFORMANCE.md b/CONFORMANCE.md new file mode 100644 index 0000000..bf98253 --- /dev/null +++ b/CONFORMANCE.md @@ -0,0 +1,87 @@ +# 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) | ✅ 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 | + +## SHOULD and MAY features + +| Feature | Status | +|---|---| +| List enumeration (§13.2) | ✅ Configurable per depth; can be disabled. Ordered lists are always renumbered. Lists have one level until the validator keeps nesting (see below) | +| 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`, `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 | +| 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 + +- **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. +- **One parser.** The renderer builds from the validator's document model only + ([ADR 0007](docs/decisions/0007-one-parser-validator-model.md)). Anchors, markers, and the + template view therefore always agree with the validator's diagnostics. The price is that some + structure renders as the validator's model holds it, until that model keeps more + ([legaldown-validator#14](https://github.com/ForLegalAI/legaldown-validator/issues/14), + roadmap U1–U2): + - **Nested lists** render as one level of items, so an item is referenced as "2.1(c)", never + "2.1(b)(i)" ([#14](https://github.com/ForLegalAI/legaldown-validator/issues/14), [#16](https://github.com/ForLegalAI/legaldown-validator/issues/16)). The resolver and writers already handle nesting. + - **`*` 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, 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: 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)). + +## 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 new file mode 100644 index 0000000..fdd7d0e --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +
+ +# legaldown-render 🖨️ + +### The reference renderer for [LegalDown](https://github.com/ForLegalAI/LegalDown) + +**Turn a LegalDown document into HTML or plain text, with section numbers, cross-references, +defined terms, and formatted values all generated at render time.** + +Targets specification **v0.2** · Conformance level **Rendering** · DOCX and PDF planned + +
+ +--- + +## What it does + +[LegalDown](https://github.com/ForLegalAI/LegalDown) documents never hardcode section numbers, +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, list items, and paragraphs under a configurable scheme (§13.1, §13.2). +References to items resolve to designations such as "2.1(b)". + +🔗 **Resolves** cross-references, defined terms, parties, sides, and attachment references into +display text with links (§13.3–§13.5). + +🌍 **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 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 dropped +silently (§17.5). + +Parsing and Core validation come from +[`legaldown-validator`](https://github.com/ForLegalAI/legaldown-validator), the reference parser. +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 @ git+https://github.com/ForLegalAI/legaldown-render" +``` + +Python 3.11 or newer. The dependencies are all pure Python: `legaldown-validator`, +`markdown-it-py`, `babel`, and `pyyaml`. + +## Command line + +```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 --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 +``` + +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(), 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, 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). + +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) | 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 | + +## Development + +```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 + +[MIT](LICENSE) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d8c26f7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,243 @@ +# Architecture + +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 + │ 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) +``` + +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). + +`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. + +### 1. Parse and validate + +`legaldown.parse_document` and `legaldown.validate_document` provide: + +- 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 + +A document that cannot be read at all, such as one whose frontmatter is not a YAML mapping, +raises `DocumentError`. Every other problem renders. + +### 3. Build the render tree (`build.py`) + +There is one parser: the validator's ([ADR 0007](decisions/0007-one-parser-validator-model.md)). +The builder walks the validator's `Document` and turns each block into render-tree blocks. It +never decides a structural or LegalDown question itself: + +| Question | Answered by | +|---|---| +| Sections, headings, identifiers | `Document.sections`, `ValidationResult.sections` | +| Blocks: paragraphs, lists and items, quotes, tables, code, rules | `Document` blocks; quote content and code inside items are read by the validator's parser too | +| Where a marker (`{#id when=…}`) is placed, and what it means | The validator's `find_markers()`, with its own `placed(template)` | +| Whether the document is a template | The validator's own formula over those markers | +| Whether a quote is a drafting note | The validator's `block_quotes()` | +| A lifted definition, `{{ref:}}` or `{{term:}}` block's source | The validator's `render_block()` | + +The imports beyond the validator's public API are all in `validator_bridge.py`, which is the +list for roadmap item U3. + +Within one block's text, **markdown-it-py parses inline Markdown only**: emphasis, links, code +spans, inline HTML. Directives are protected first by **sentinels**. Every directive, and every +`"Term" {{def:}}` span, is replaced with a private-use token, `\ue000:\ue001`, so +Markdown cannot reinterpret directive syntax, such as an underscore in an identifier. The nonce is +random for each build, so no text in a document, whether written literally, as an entity, or +percent-encoded, can pass for a sentinel. Emphasis that wraps only a defined term is dropped with +it, because the style decides how defined terms look (§7.2). A `{{def:}}` becomes a sentinel +that renders nothing, led by a punctuation character so that an emphasis closer just before it +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), 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 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: +- 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/`) + +`Resolver.run()` works in three steps: + +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** share a + number (§15.8). For sections, which ones are alternatives is taken from the validator's own + numbering, so rendered numbers always match `ValidationResult.sections`. For paragraphs and + list items, the validator's rule is applied the same way: an alternative directly follows its + sibling, has the same identifier and a valid condition, and its full presence (its own condition + and every enclosing one) excludes that of every unit already holding the number (§15.4, the + validator's `exclusive()`). 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. A definition's anchor is given out in one place, when its defined term is +resolved. Terms in titles or alt text get none. After everything is resolved, one pass drops the +link of any `{{term:}}` whose definition got no anchor, so term links always have a target. + +Under the `none` scheme, a section's designation is its heading text, computed by a text-only +pass that gives out no anchors and leaves references out. Headings are numbered with their level +clamped to 1–5, as the validator numbers them. + +### 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 + +``` +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 +``` + +## Dependencies + +| Dependency | Why | +|---|---| +| `legaldown-validator>=0.2.0,<0.3` | The only LegalDown parser; Core validation | +| `markdown-it-py` | Inline Markdown inside one block's text (ADR 0007) | +| `babel` | CLDR locale data (ADR 0005) | +| `pyyaml` | Style templates | + +All of them are pure Python. The DOCX and PDF writers will bring their dependencies in as extras. + +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`, +`legaldown.validator.patterns`, `legaldown.validator.conditions`, and one private function, +`legaldown.validator.core._frontmatter_fields`. 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** 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 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 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 + +| Suite | What it checks | +|---|---| +| `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 new file mode 100644 index 0000000..9582ba4 --- /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, 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, 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/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..1ce1c80 --- /dev/null +++ b/docs/decisions/0002-one-parser.md @@ -0,0 +1,51 @@ +# 0002. One LegalDown parser + +- Status: Accepted. Point 2 (CommonMark structure from markdown-it-py) and point 3 (the outline + check) are superseded by [0007](0007-one-parser-validator-model.md) +- 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..cc1828e --- /dev/null +++ b/docs/decisions/0005-locale-formatting.md @@ -0,0 +1,39 @@ +# 0005. Locale formatting with Babel + +- Status: Accepted (implemented in 0.1.0) +- 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 + +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/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/0007-one-parser-validator-model.md b/docs/decisions/0007-one-parser-validator-model.md new file mode 100644 index 0000000..d135fca --- /dev/null +++ b/docs/decisions/0007-one-parser-validator-model.md @@ -0,0 +1,61 @@ +# 0007. One parser: build from the validator's document model + +- Status: Accepted. Supersedes the "CommonMark structure from markdown-it-py" part of + [0002](0002-one-parser.md) +- Date: 2026-09-23 + +## Context + +[ADR 0002](0002-one-parser.md) took the LegalDown layer from `legaldown-validator` and, as a +stopgap, the Markdown block structure from markdown-it-py. That way nested lists could render +before the validator kept them +([ForLegalAI/legaldown-validator#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)). + +In practice the two parsers disagreed wherever their readings differ: + +- a heading inside an HTML comment +- a `# Signature Block` heading +- the paragraphs of a multi-paragraph list item +- a list item that opens with a heading or a fenced code block + +Each disagreement became a crash of the outline check, or an anchor, marker or template decision +that contradicted the validator's own diagnostics. Five review rounds went mostly into patching +these. Each fix mirrored another of the validator's rules in the renderer, and the next review +found another edge case. + +## Decision + +The render tree is built **from the validator's `Document` only**: + +- **Block structure:** sections, blocks, list items, quotes, tables, and code all come from the + validator's model. +- **Markers:** placed exactly where the validator's own `find_markers()` places them. +- **Template decision:** the validator's own formula over those markers. +- **Quote content, and code inside list items:** read with the validator's parser too. + +markdown-it-py stays, in **inline mode only**, for the Markdown inside one block's text: emphasis, +links, code spans. It never decides what is a heading, a list, a paragraph, or a marker, so it +cannot disagree with the validator about any of them. + +Everything the renderer takes from the validator beyond its public API is imported in one +module, `validator_bridge.py`. That module is the list for roadmap item U3. + +## Consequences + +- A whole class of bugs is gone by construction. There is no outline check that can crash, and + rendered anchors, markers, and the template view always agree with the validator's + diagnostics. +- **Limitations** until the validator's model improves (roadmap U1 and U2), listed in + `CONFORMANCE.md`: + - lists render with one level of items, so references to them read "2.1(c)", never + "2.1(b)(i)" + - table column alignment is not kept + - 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/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..2f70eef --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,27 @@ +# 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; the markdown-it-py part superseded by 0007 | +| [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) | Accepted | +| [0006](0006-styles-and-options.md) | Style templates are data; render options are separate | Accepted | +| [0007](0007-one-parser-validator-model.md) | One parser: build from the validator's document model; markdown-it-py for inline text only | Accepted | + +## 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..9e29bb6 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,64 @@ +# Roadmap + +> Milestones are in order but have no dates. Each one ends with a release to PyPI and an updated +> [`CONFORMANCE.md`](../CONFORMANCE.md). + +## Upstream prerequisites + +These changes belong in `legaldown-validator` (the core package) or in the specification, not +here. + +| # | Where | Change | Status | +|---|---|---|---| +| 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)), 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 | + +## Milestones + +### v0.1 — HTML and text, specification 0.2 (this release) + +- The full pipeline: parse and validate, build, resolve, write +- All four numbering schemes; list enumeration; paragraph numbering; item and paragraph anchors + with designations ("2.1(b)"), one list level until U1 +- 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, 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 + +### v0.2 — Templates with answers + +- 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 +- 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)). 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 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.** 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 new file mode 100644 index 0000000..75c1b91 --- /dev/null +++ b/docs/style-templates.md @@ -0,0 +1,168 @@ +# Styles and preferences + +A style template holds everything about how a document looks and nothing about what it says +(§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, 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 | + +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**: + +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`. On the command line, text settings take the value as written + (`--set placeholders.blank=[__]`). A value wrapped in one pair of matching quotes that does + not contain that quote is unquoted; nothing else is interpreted, so `"Section" {designation}` + or `C:\new` stay exactly as written. To keep quotation marks + around a value, wrap it in the other kind (`--set 'references.format="{designation}"'` + gives `{designation}`, and `--set "references.format='\"{designation}\"'"` gives + `"{designation}"`). Other settings are read as YAML (`true`, `2`, `[...]`). + +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. + +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: + +``` +$ 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 # style format version +name: house +extends: continental # built-in name or relative path; default: default + +locale: cs-CZ # formatting locale (§10.1); unset: the document's language + +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 # 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: # §13.2, off by default + numbered: false + format: { counter: decimal, label: "{section}.{n}", ref: ".{n}" } + +references: + 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: + 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: "[_____]" + 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 + # 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: "" } + +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, 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' + font_size: 11pt + line_height: "1.5" + color: "#1a1a1a" + link_color: "#1f4e8c" + justify: false + +headings: # per level 1–5 + 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: + max_width: 46rem + extra_css: "" # appended to the stylesheet; trusted configuration only +``` + +### Level formats + +Headings, list items, and paragraphs are all numbered with the same building block: + +| Field | Meaning | +|---|---| +| `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`), 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 + +- 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..6c9fc6e --- /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", + # 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", + "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..ba3c542 --- /dev/null +++ b/src/legaldown_render/api.py @@ -0,0 +1,160 @@ +"""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, 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 + +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 + #: 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 + + +@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, final=options.final) + diagnostics = list(result.diagnostics) + if options.strict and any(d.level == "error" for d in diagnostics): + raise RenderRefused(diagnostics) + + tree, extra = _resolve(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(document: Document, result: ValidationResult, style: Style) -> tuple[RenderTree, list[Diagnostic]]: + diagnostics: list[Diagnostic] = [] + tree, raw_html = build_tree(document, result) + if raw_html: + diagnostics.append(Diagnostic( + rule="raw-html", + level="warning", + 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 + 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..f64154e --- /dev/null +++ b/src/legaldown_render/build.py @@ -0,0 +1,633 @@ +"""Stage 3: build the render tree from the validator's document model +(docs/architecture.md, docs/decisions/0002). + +There is one parser: ``legaldown-validator``'s. Its ``Document`` gives the +sections, their blocks, and each block's text, and its own findings say +where markers are placed and whether the document is a template +(:mod:`.validator_bridge`). The builder never decides a structural or +LegalDown question itself. + +Within one block's text it still needs inline Markdown — emphasis, links, +code spans — which markdown-it-py parses in inline mode only. Directives +are protected first by **sentinels**: every directive (and every +``"Term" {{def:}}`` span) is replaced by a private-use token, +``\\ue000:\\ue001``, so Markdown can never reinterpret directive +syntax, and put back as tree nodes afterwards. The nonce is random for every +build, so no text in a document — written out, as an entity, or +percent-encoded — can pass for a sentinel. + +Until the validator keeps nested lists (ForLegalAI/legaldown-validator#14), +lists render as the validator holds them: one level of items. +""" +from __future__ import annotations + +import re +import secrets +from dataclasses import dataclass, replace +from urllib.parse import unquote + +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 +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, +) +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 +# Unicode punctuation, so an emphasis closer just before it still counts as +# right-flanking and closes (CommonMark); a bare sentinel reads like a letter. +_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, and as a list. +_PARAGRAPH_KINDS = frozenset({"paragraph", "definition", "ref", "term"}) +_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: + """*source* with a byte-order mark removed and line endings unified.""" + return source.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n") + + +@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 source span the sentinel replaced, for restoring the text where + #: Markdown shows it literally (a code block, a link URL). + source: str + + +@dataclass(frozen=True, slots=True) +class _Hidden: + """Source that renders nothing — a {{def:}} and the spacing before it — + kept so that literal restoration gives back the source exactly.""" + + source: str + + +_Payload = DirectiveSource | _DefinitionPayload | _Hidden + + +@dataclass(frozen=True, slots=True) +class _DefinitionSpan: + """Where a defined term and its {{def:}} lie in the body. + + ``leading`` and ``trailing`` are the emphasis markers written before the + opening mark and after the closing one. When they wrap just the term, + they go with it — the style decides how a defined term looks (§7.2); + otherwise the trailing ones are *kept*, because they pair with text + beyond the term. + """ + + opening: int # offset of the opening quotation mark + closing: int # offset of the closing quotation mark + leading: str # emphasis markers just before the opening mark + trailing: str # emphasis markers between the closing mark and the spacing + term: str + + @property + def wraps(self) -> bool: + """True when the markers open before the term and close right after + it, as in ``**"Term"**``.""" + return bool(self.leading) and self.trailing == self.leading[::-1] + + @property + def start(self) -> int: + """Start of the term's sentinel.""" + return self.opening - len(self.leading) if self.wraps else self.opening + + @property + def end(self) -> int: + """End of the term's sentinel.""" + return self.hidden if self.wraps else self.closing + 1 + + @property + def kept(self) -> str: + return "" if self.wraps else self.trailing + + @property + def hidden(self) -> int: + """Start of the spacing and {{def:}}, which render nothing.""" + return self.closing + 1 + len(self.trailing) + + +class _Builder: + 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. + 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 + # taken only with one, never from the source before another sentinel. + self.sentinel_re = re.compile(f"(?:{_HIDDEN_LEAD}{_OPEN}{nonce}h|{_OPEN}{nonce}):(\\d+){_CLOSE}") + # How markdown-it percent-encodes a sentinel inside a URL. + self.encoded_sentinel_re = re.compile( + f"(?:%E2%B8%B1%EE%80%80{nonce}h|%EE%80%80{nonce}):(\\d+)%EE%80%81", re.IGNORECASE) + self.nonce = nonce + + # -- sentinels ------------------------------------------------------------ + + def _sentinel(self, payload: _Payload) -> str: + self.payloads.append(payload) + index = len(self.payloads) - 1 + if isinstance(payload, _Hidden): + return f"{_HIDDEN_LEAD}{_OPEN}{self.nonce}h:{index}{_CLOSE}" + return f"{_OPEN}{self.nonce}:{index}{_CLOSE}" + + def _protect(self, body: str) -> str: + """*body* — one block's text — with every directive replaced by a + sentinel, so that Markdown cannot reinterpret directive syntax. A defined term + becomes one sentinel and its {{def:}} another that renders nothing + (see _DefinitionSpan). One pass over the directives, in order.""" + lexed = self.markers.lex(body) + spans: dict[int, _DefinitionSpan] = {} + 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) + # Between the closing mark and the directive the validator allows + # only emphasis markers, then spacing (§7.2). + trailing = body[closing + 1:anchor.directive.start].rstrip() + spans[id(anchor.directive)] = _DefinitionSpan( + opening, closing, body[anchor.start:opening], trailing, anchor.term) + + out: list[str] = [] + cursor = 0 + pending: list[Directive] = [] # directives inside the next defined term + starts = sorted((span.start, span.end) for span in spans.values()) + next_span = 0 + for directive in lexed.directives: + span = spans.get(id(directive)) + if span is None: + while next_span < len(starts) and starts[next_span][1] <= directive.start: + next_span += 1 + if next_span < len(starts) and starts[next_span][0] <= directive.start: + pending.append(directive) # inside a defined term: protected with it + continue + out.append(body[cursor:directive.start]) + out.append(self._sentinel(DirectiveSource(directive))) + else: + inner = self._protect_inner(body, span.opening + 1, span.closing, pending) + pending = [] + out.append(body[cursor:span.start]) + out.append(self._sentinel(_DefinitionPayload(directive, inner, span.term, body[span.start:span.end]))) + out.append(span.kept) + out.append(self._sentinel(_Hidden(body[span.hidden:directive.end]))) + 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 *directives*, the ones + inside it, 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.directive.source if isinstance(payload, DirectiveSource) else payload.source + return self.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 = self.encoded_sentinel_re.sub(lambda match: unquote(match.group(0)), url) + return self.md.normalizeLink(self._restore(decoded)) if decoded != url else url + + # -- inlines -------------------------------------------------------------- + + 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.""" + 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).""" + 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), self._title(node))] + case "image": + src = self._restore_url(str(node.attrs.get("src", ""))) + return [Image(src, self._inline_children(node), self._title(node))] + case "html_inline": + if 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 + 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 {} + 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 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: + 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, 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) + view = lexed.view + at = 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: + 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)) if items else None), in_comment + + def quote(self, block: ModelBlock) -> Block: + """A block quote, its content read by the validator's parser. A + drafting note is decided by the validator's own test (§15.6).""" + text = block.text + quotes = block_quotes(block) + drafting = bool(quotes) and quotes[0].start == 0 and quotes[0].is_drafting_note + if drafting: + text = text.partition("\n")[2] + blocks = self.fragment(text) + return DraftingNote(blocks) if drafting else Quote(blocks) + + 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, 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 + + # -- document ------------------------------------------------------------- + + def tree(self) -> RenderTree: + document = self.document + sections = tuple( + Section( + level=section.level, + title=self.text(section.title), + identifier=self.result.sections[index].identifier, + condition=section.condition, + blocks=self.blocks(section.blocks, index), + ) + for index, section in enumerate(document.sections) + ) + metadata = document.metadata + return RenderTree( + title=plain_inlines(metadata.title), + subtitle=plain_inlines(metadata.subtitle), + language=self.language, + preamble=self.blocks(document.preamble, None), + sections=sections, + is_template=self.markers.template, + ) + + +def _strip_markers(block: ModelBlock, placed: dict[int, tuple[int, str, str]]) -> tuple[ModelBlock, str, str]: + """*block* without its placed marker, and the marker's identifier and + condition. Fragments are numbered as block_fragments numbers them.""" + fields = [name for name in ("text", "prefix", "suffix") if getattr(block, name)] + identifier = condition = "" + for fragment_index, (offset, marker_id, marker_condition) in placed.items(): + if fragment_index >= len(fields): + raise InternalError(f"the validator placed a marker in fragment {fragment_index} of a {block.kind} block") + name = fields[fragment_index] + block = replace(block, **{name: _cut_marker(getattr(block, name), offset)}) + identifier, condition = marker_id, marker_condition + return block, identifier, condition + + +def _cut_marker(fragment: str, offset: int) -> str: + """*fragment* without the marker at *offset* on its first line; a + comment after the marker stays (it renders nothing).""" + first, newline, rest = fragment.partition("\n") + end = first.index("}", offset) + 1 + # Only the spacing around the marker goes: text before it keeps its own + # leading space (a lifted {{ref:}}'s suffix begins with one). + before, after = first[:offset].rstrip(), first[end:].strip() + return (f"{before} {after}" if after else before) + newline + rest + + +def _paragraph_source(block: ModelBlock) -> str: + """A paragraph block's text as source. A definition, {{ref:}}, or + {{term:}} the parser lifted into fields is written back by the + validator's own serializer.""" + return block.text if block.kind == "paragraph" else render_block(block) + + +def _code_block(text: str) -> CodeBlock: + """A fenced code block from the validator's model, fences included.""" + lines = text.split("\n") + opening = _FENCE_RE.match(lines[0]) if lines else None + if opening is None: + return CodeBlock(text) + fence = opening.group(1) + body = lines[1:] + if body and body[-1].strip().startswith(fence[0] * len(fence)) and not body[-1].strip().strip(fence[0]): + body = body[:-1] + return CodeBlock("\n".join(body) + ("\n" if body else ""), opening.group(2).strip()) + + +def _trim(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: + """*inlines* without spacing at either end (left where a comment or a + marker was removed).""" + out = list(inlines) + if out and isinstance(out[-1], Text): + out[-1] = Text(out[-1].text.rstrip()) + if out and isinstance(out[0], Text): + out[0] = Text(out[0].text.lstrip()) + return tuple(inline for inline in out if not (isinstance(inline, Text) and not inline.text)) + + +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 dropped ``
`` between two words leaves one space.""" + merged: list[Inline] = [] + dropped = parted = False + for inline in inlines: + if inline is _DROPPED or inline is _DROPPED_BREAK: + dropped = True + parted = parted or inline is _DROPPED_BREAK + continue + 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) + return tuple(merged) + + +def build_tree(document: Document, result: ValidationResult) -> tuple[RenderTree, int]: + """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 + + +def plain_inlines(text: str) -> tuple[Inline, ...]: + """Inline nodes for a frontmatter string: plain text in which only + directives are recognised (placeholders, §3.10) — never Markdown.""" + out: list[Inline] = [] + cursor = 0 + for directive in lex(text).directives: + if directive.start > cursor: + out.append(Text(text[cursor:directive.start])) + out.append(DirectiveSource(directive)) + cursor = directive.end + if cursor < len(text): + out.append(Text(text[cursor:])) + return tuple(out) diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py new file mode 100644 index 0000000..a572acc --- /dev/null +++ b/src/legaldown_render/cli.py @@ -0,0 +1,123 @@ +"""The ``legaldown-render`` command. + +Exit codes: 0 — rendered (document errors, if any, are shown as markers and +listed on stderr); 1 — not rendered: refused under ``--strict``, or the +document cannot be read; 2 — a usage or style problem; 70 — an internal +error (a bug to report). +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +from . import __version__ +from .api import RenderOptions, render +from .errors import DocumentError, InternalError, RenderRefused +from .style import StyleError, builtin_styles, dump_style, load_style, parse_override +from .writers import FORMATS, format_for_path + + +def _setting(text: str) -> tuple[str, Any]: + key, separator, value = text.partition("=") + if not separator or not key.strip(): + raise argparse.ArgumentTypeError(f"expected KEY=VALUE, got '{text}'") + # Typed by the setting: text stays as written, anything else is YAML. + return key.strip(), parse_override(key.strip(), value) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="legaldown-render", + description="Render a LegalDown document to HTML or plain text.", + epilog="Style settings: --style picks a template (built-in: " + + ", ".join(builtin_styles()) + + ", or a .yaml file); --set overrides one setting, e.g. --set numbering.scheme=mixed. " + "See every setting with --print-style.", + ) + parser.add_argument("input", nargs="?", help="the .lgd file to render, or - for standard input") + parser.add_argument("-o", "--output", help="output file (default: standard output); its extension sets the format") + parser.add_argument("-f", "--format", choices=sorted(FORMATS), help="output format (default: from --output, else html)") + parser.add_argument("-s", "--style", help="style template: a built-in name or a path to a .yaml file") + parser.add_argument("--set", dest="overrides", action="append", default=[], type=_setting, metavar="KEY=VALUE", + help="override one style setting; repeatable. Text settings take the value as " + "written; one pair of matching outer quotes is removed (wrap the value in the " + "other kind of quote to keep quotation marks). Others read it as YAML, e.g. true or 2") + 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, 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") + parser.add_argument("--list-styles", action="store_true", help="list the built-in styles, and exit") + parser.add_argument("--version", action="version", version=f"legaldown-render {__version__}") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + overrides = dict(args.overrides) + if args.locale: + overrides["locale"] = args.locale + + if args.list_styles: + print("\n".join(builtin_styles())) + return 0 + if args.print_style: + try: + print(dump_style(load_style(args.style, overrides=overrides)), end="") + except StyleError as error: + print(f"legaldown-render: {error}", file=sys.stderr) + return 2 + return 0 + if not args.input: + parser.error("an input file is required") + + output_format = args.format or (format_for_path(args.output) if args.output else None) or "html" + name = "" if args.input == "-" else args.input + try: + source = sys.stdin.read() if args.input == "-" else Path(args.input).read_text(encoding="utf-8") + except OSError as error: + print(f"legaldown-render: cannot read {name}: {error.strerror or error}", file=sys.stderr) + return 1 + + options = RenderOptions(format=output_format, style=args.style, overrides=overrides, + strict=args.strict, final=args.final, standalone=not args.fragment) + try: + result = render(source, options) + except StyleError as error: + print(f"legaldown-render: {error}", file=sys.stderr) + return 2 + except RenderRefused as error: + _report(name, error.diagnostics, quiet=args.quiet) + print(f"legaldown-render: {error}", file=sys.stderr) + return 1 + except DocumentError as error: + print(f"{name}: error: {error}", file=sys.stderr) + return 1 + except InternalError as error: + print(f"legaldown-render: internal error: {error}", file=sys.stderr) + return 70 + + _report(name, result.diagnostics, quiet=args.quiet) + if args.output: + Path(args.output).write_text(result.output, encoding="utf-8") + else: + sys.stdout.write(result.output) + return 0 + + +def _report(name: str, diagnostics: list, *, quiet: bool) -> None: + if quiet: + return + for diagnostic in diagnostics: + print(f"{name}: {diagnostic.level}: [{diagnostic.rule}] {diagnostic.message}", file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/legaldown_render/errors.py b/src/legaldown_render/errors.py new file mode 100644 index 0000000..bb45ee7 --- /dev/null +++ b/src/legaldown_render/errors.py @@ -0,0 +1,31 @@ +"""Errors raised by the renderer. Problems in the *document* are never +raised: they render as visible failure markers and are reported as +diagnostics. These are for everything else.""" +from __future__ import annotations + +from legaldown import Diagnostic + + +class RenderError(Exception): + """Base class for every error the renderer raises.""" + + +class DocumentError(RenderError): + """The document cannot be read at all, e.g. its frontmatter is not a + YAML mapping.""" + + +class RenderRefused(RenderError): + """Rendering was refused because the document has errors and strict + mode is on. ``diagnostics`` holds every finding.""" + + def __init__(self, diagnostics: list[Diagnostic]) -> None: + self.diagnostics = diagnostics + errors = sum(1 for diagnostic in diagnostics if diagnostic.level == "error") + super().__init__(f"Rendering refused in strict mode: the document has {errors} error(s).") + + +class InternalError(RenderError): + """The renderer reached a state that is a bug, such as its parser and + legaldown-validator disagreeing about the document. It never produces + plausible-looking wrong output instead.""" diff --git a/src/legaldown_render/py.typed b/src/legaldown_render/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/legaldown_render/resolve/__init__.py b/src/legaldown_render/resolve/__init__.py new file mode 100644 index 0000000..24deb29 --- /dev/null +++ b/src/legaldown_render/resolve/__init__.py @@ -0,0 +1,5 @@ +"""Stage 4: resolution (docs/decisions/0003).""" +from .resolver import DEFINITION_ANCHOR_PREFIX, resolve_tree +from .values import DURATION_UNITS, Formatter, parse_locale + +__all__ = ["DEFINITION_ANCHOR_PREFIX", "DURATION_UNITS", "Formatter", "parse_locale", "resolve_tree"] diff --git a/src/legaldown_render/resolve/numbering.py b/src/legaldown_render/resolve/numbering.py new file mode 100644 index 0000000..61925fa --- /dev/null +++ b/src/legaldown_render/resolve/numbering.py @@ -0,0 +1,90 @@ +"""Counters, numbering schemes, and label templates (§13.1, §13.2).""" +from __future__ import annotations + +import re + +from ..style.model import LevelFormat, Numbering + +_ROMAN = ( + (1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), + (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"), +) + + +def format_counter(value: int, counter: str) -> str: + """*value* (1-based) written in the *counter* style.""" + match counter: + case "lower-alpha" | "upper-alpha": + # Bijective base 26: a … z, aa, ab, … + letters = "" + while value > 0: + value, remainder = divmod(value - 1, 26) + letters = chr(ord("a") + remainder) + letters + return letters.upper() if counter == "upper-alpha" else letters + case "lower-roman" | "upper-roman" if 0 < value < 4000: + numeral = "" + for amount, symbol in _ROMAN: + while value >= amount: + numeral += symbol + value -= amount + return numeral.upper() if counter == "upper-roman" else numeral + case _: + return str(value) + + +def _level(counter: str, label: str, ref: str) -> LevelFormat: + return LevelFormat(counter, label, ref) # type: ignore[arg-type] + + +#: The built-in heading schemes of §13.1, one format per heading level +#: (LegalDown has five, §4.1). +SCHEMES: dict[str, tuple[LevelFormat, ...]] = { + "decimal": ( + _level("decimal", "{path}.", "{n}"), + *(_level("decimal", "{path}", ".{n}") for _ in range(4)), + ), + "legal-outline": ( + _level("upper-roman", "{n}.", "{n}"), + _level("upper-alpha", "{n}.", ".{n}"), + _level("decimal", "{n}.", ".{n}"), + _level("lower-alpha", "{n}.", ".{n}"), + _level("lower-roman", "({n})", "({n})"), + ), + "mixed": ( + _level("decimal", "{n}.", "{n}"), + _level("lower-alpha", "({n})", "({n})"), + _level("lower-roman", "({n})", "({n})"), + _level("upper-alpha", "({n})", "({n})"), + _level("decimal", "({n})", "({n})"), + ), +} + +#: Ordered lists under ``enumeration.ordered: renumber``. +RENUMBERED = _level("decimal", "{n}.", "({n})") + + +def heading_levels(numbering: Numbering) -> tuple[LevelFormat, ...]: + """The five level formats for *numbering*: the style's ``levels`` first, + the scheme's for the rest.""" + preset = SCHEMES.get(numbering.scheme, SCHEMES["decimal"]) + return tuple(numbering.levels[:5]) + preset[len(numbering.levels[:5]):] + + +_FIELD_RE = re.compile(r"\{(n|path|section)\}") + + +def fill(template: str, **values: str) -> str: + """*template* with ``{n}``, ``{path}``, and ``{section}`` replaced. Any + other braces are kept as written.""" + return _FIELD_RE.sub(lambda match: values.get(match.group(1), ""), template) + + +def extend(designation: str, piece: str, *, textual: bool) -> str: + """*designation* followed by *piece*. Under the ``none`` scheme the + designation is heading text, so the piece is set off by a space + ("Termination (a)", §13.3).""" + if not designation: + return piece.lstrip(".") + if textual: + return f"{designation} {piece.lstrip('.')}" + return designation + piece diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py new file mode 100644 index 0000000..4476984 --- /dev/null +++ b/src/legaldown_render/resolve/resolver.py @@ -0,0 +1,809 @@ +"""Stage 4: resolve the render tree (docs/architecture.md, docs/decisions/0003). + +Resolution computes everything the specification leaves to render time and +writes it into the tree, so that writers only lay it out: + +* **structure** — section numbers and designations (§13.1), list and + paragraph labels (§13.2), and a collision-free output anchor for every + section, item, paragraph, definition, and attachment (§5.6); +* **inlines** — every directive becomes display text: references (§13.3), + terms (§13.4), field specs (§13.5), parties and sides (§3.6), attachments + (§6.4), and the template view of ``{{choose:}}`` (§15.8). Anything that + does not resolve becomes its bracketed failure marker (§11.5); +* **frontmatter** — the title block, attachment placeholders (§13.8), and + signature blocks (§2.2). + +The validator has already reported every document-level Error for the +failures shown here, so the resolver only adds the diagnostics that need a +style template or a renderer: ``ref-not-enumerated``, ``raw-html``, and +constructs beyond the Rendering level (§17.5). +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import Any + +from legaldown import Diagnostic, Directive, Document, ValidationResult, slugify_identifier +from legaldown.validator import KNOWN_CURRENCIES +from legaldown.validator.conditions import ALWAYS, Presence, condition_problem, exclusive, parse_condition +from legaldown.validator.helpers import is_positive_numeric, is_valid_iso_date, is_valid_money_amount +from legaldown.validator.patterns import IDENTIFIER_RE + +from ..build import plain_inlines +from ..style import Style, effective_labels +from ..style.model import LevelFormat +from ..tree import ( + AttachmentPart, + Blank, + Block, + Choice, + CodeBlock, + ContentsEntry, + CrossRef, + DefinedTerm, + DefinitionSource, + DirectiveSource, + DraftingNote, + Emphasis, + FailureMarker, + Image, + Inline, + Link, + List, + ListItem, + Paragraph, + PartyInfo, + Quote, + RenderTree, + Section, + SideInfo, + SignatureParty, + 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 + +#: Definition anchors are prefixed with a character no identifier can hold, +#: so they can never collide with a section or item anchor (§5.6). +DEFINITION_ANCHOR_PREFIX = "def:" +#: The attachments heading's anchor, with a colon for the same reason. +ATTACHMENTS_ANCHOR = "ld:attachments" + +_VALUE_TYPES = ("text", "date", "money", "duration") +_DECISION_TYPES = ("boolean", "choice") + +#: Markers for a directive that breaks the §11.2 grammar: it carries no +#: arguments, so its type's marker is shown without a value. +_MALFORMED = { + "ref": "[BROKEN REF]", + "term": "[UNDEFINED]", + "date": "[INVALID DATE]", + "money": "[INVALID AMOUNT]", + "party": "[INVALID PARTY]", + "side": "[INVALID SIDE]", + "duration": "[INVALID DURATION]", + "field": "[INVALID FIELD]", + "placeholder": "[INVALID PLACEHOLDER]", + "attach": "[UNKNOWN ATTACHMENT]", + "choose": "[INVALID CHOICE]", + "include": "[NOT PROCESSED: include]", +} + + +@dataclass(frozen=True, slots=True) +class _Target: + """What a ``{{ref:}}`` to an anchor resolves to.""" + + designation: str + anchor: str | None + #: False for an item or paragraph whose list or paragraphs the style does + #: not number: the designation is then its section's (§6.3). + enumerated: bool = True + + +class Resolver: + def __init__( + self, + tree: RenderTree, + document: Document, + result: ValidationResult, + style: Style, + formatter: Formatter, + ) -> None: + self.tree = tree + self.document = document + self.metadata = document.metadata + self.result = result + self.style = style + self.formatter = formatter + self.labels = effective_labels(style.labels, tree.language) + self.textual = style.numbering.scheme == "none" + self.diagnostics: list[Diagnostic] = [] + self.targets: dict[str, _Target] = {} + self.used_anchors: set[str] = set() + questions = self.metadata.questions + self.questions: dict[str, Any] = questions if isinstance(questions, dict) else {} + self.inconsistent_placeholders: set[str] = set() + self.attachment_anchors: dict[str, str | None] = {} + self.section_presences: list[Presence] = [] + + # -- entry point ---------------------------------------------------------- + + def run(self) -> RenderTree: + self._survey() + attachments = self._attachments() + sections = self._number_sections() + preamble = tuple(self._structure(block, section=None, depth=0, presence=ALWAYS) for block in self.tree.preamble) + sections = tuple( + replace(section, blocks=self._structure_section(section, presence)) + for section, presence in zip(sections, self.section_presences, strict=True) + ) + resolve = self._resolve_inlines + # In document order, so that the first of several definitions of a + # term is the one that keeps its anchor. + preamble = tuple(self._resolve_block(block) for block in preamble) + sections = tuple( + replace( + section, + title=resolve(section.title), + blocks=tuple(self._resolve_block(block) for block in section.blocks), + condition_label=self._condition(section.condition), + ) + 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), + subtitle=resolve(self.tree.subtitle), + locale=self.formatter.tag, + preamble=preamble, + sections=sections, + header=self._header(), + sides=self._sides() if self.style.title_block.parties else (), + attachments=attachments, + attachments_label=self.labels.attachments or "", + contents=self._contents(sections, attachments), + contents_label=self.labels.contents or "", + colon=self.labels.colon or "", + attachments_anchor=ATTACHMENTS_ANCHOR if attachments else "", + signatures=self._signatures(), + signature_labels={ + "date": self.labels.signature_date or "", + "place": self.labels.signature_place or "", + "name": self.labels.signature_name or "", + "title": self.labels.signature_title or "", + "heading": self.labels.signatures or "", + }, + ) + # A {{term:}} links to its definition only if that definition was + # given an anchor: definitions in titles or alt text are not, and a + # 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 + 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.depth, section.label, unlinked(section.title), section.anchor, + section.condition_label) + for section in sections + if section.depth <= settings.depth + ] + if settings.attachments and attachments: + # Under the attachments heading, unless a style blanks its label. + level = 1 + if self.labels.attachments: + entries.append(ContentsEntry(1, None, (Text(self.labels.attachments),), ATTACHMENTS_ANCHOR)) + level = 2 + entries += [ + ContentsEntry(level, None, unlinked(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) + return inline + + def _survey(self) -> None: + """One pass over the tree before resolving: which placeholder ids + are used with conflicting types (§10.7).""" + types: dict[str, set[str]] = {} + for inline in iter_tree_inlines(self.tree): + if isinstance(inline, DirectiveSource): + directive = inline.directive + if directive.name == "placeholder" and directive.positional and not directive.malformed: + types.setdefault(directive.positional, set()).add(self._placeholder_type(directive)) + self.inconsistent_placeholders = {pid for pid, found in types.items() if len(found) > 1} + + # -- anchors -------------------------------------------------------------- + + def _anchor(self, name: str, *, generated: bool = False) -> str | None: + """An output anchor for *name*, or None when an earlier unit already + took it — alternatives in a template view share an identifier + (§15.4), and the first keeps the anchor. A name from the document + never holds a colon (an Error the validator reports): that form is + kept for the renderer's own *generated* anchors, so none can clash.""" + if not name or name in self.used_anchors or (":" in name and not generated): + return None + self.used_anchors.add(name) + return name + + def _register(self, identifier: str, target: _Target) -> None: + if identifier and identifier not in self.targets: + self.targets[identifier] = target + + # -- structure: numbering, labels, anchors --------------------------------- + + def _number_sections(self) -> list[Section]: + """Number the sections, and record each one's presence (§15.3). + + Which sections are alternatives, sharing their previous sibling's + number (§15.8), is taken from the validator: it numbers them itself, + with the full presence of each, so the rendered numbers always + agree with ``ValidationResult.sections``. + """ + levels = heading_levels(self.style.numbering) + counters = [0] * 6 # index 1-5: one counter per heading level + previous: dict[int, int] = {} # level -> index of the previous sibling + presences: dict[int, Presence] = {} # level -> presence of the open section + indexed = self.result.sections + out: list[Section] = [] + for index, section in enumerate(self.tree.sections): + # Numbered with its level clamped to 1-5, as the validator numbers it. + level = min(max(section.level, 1), 5) + sibling = previous.get(level) + alternative = ( + sibling is not None + and self.tree.sections[sibling].identifier == section.identifier + and indexed[sibling].number == indexed[index].number + ) + if not alternative: + counters[level] += 1 + counters[level + 1:] = [0] * (5 - level) + previous = {lvl: i for lvl, i in previous.items() if lvl < level} + previous[level] = index + enclosing = max((lvl for lvl in presences if lvl < level), default=None) + presences = {lvl: p for lvl, p in presences.items() if lvl < level} + presences[level] = (presences[enclosing] if enclosing is not None else ALWAYS) | self._presence(section.condition) + self.section_presences.append(presences[level]) + if self.textual: + label, designation = None, self._title_text(section.title) + else: + designation = "" + for depth in range(1, level + 1): + if counters[depth]: + fmt = levels[depth - 1] + designation = extend(designation, fill(fmt.ref, n=format_counter(counters[depth], fmt.counter)), + textual=False) + fmt = levels[level - 1] + 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)) + # How deep the validator's own number goes ("2.1" is 2). + depth = indexed[index].number.count(".") + 1 + # 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, + depth=depth)) + return out + + def _presence(self, condition: str) -> Presence: + """The presence a unit's own *condition* adds: empty when there is + none, or when it is not a valid test of a declared decision question + (condition-invalid, which the validator reports).""" + if not condition or condition_problem(condition, self.questions) is not None: + return ALWAYS + parsed = parse_condition(condition) + return frozenset({parsed}) if parsed else ALWAYS + + def _exclusive(self, first: Presence, second: Presence) -> bool: + """True if units with these presences can never both appear (§15.4).""" + return exclusive(first, second, self.questions) + + def _title_text(self, title: tuple[Inline, ...]) -> str: + """A heading's text, as the ``none`` scheme designates it (§13.3). + + A text-only pass: it gives out no anchors, and it leaves references + out wherever they are, since their targets are not all known yet. + Other directives show their display text; a defined term its term. + """ + def text(inlines: tuple[Inline, ...]) -> str: + parts: list[str] = [] + for inline in inlines: + match inline: + case DirectiveSource(directive=directive): + if directive.name != "ref": + resolved = self._directive(directive) + parts.append(plain_text((resolved,)) if resolved is not None else "") + case DefinitionSource(children=children) | Emphasis(children=children) \ + | Strong(children=children) | Link(children=children) | Image(children=children): + parts.append(text(children)) + case _: + parts.append(plain_text((inline,))) + return "".join(parts) + + return " ".join(text(title).split()) + + def _structure_section(self, section: Section, presence: Presence) -> tuple[Block, ...]: + paragraphs = _Counter(self._exclusive) + out: list[Block] = [] + for block in section.blocks: + if isinstance(block, Paragraph) and block.top_level: + # Alternative paragraphs share a number, as sections do (§15.8). + number = paragraphs.next(block.anchor_id, self._own_presence(block.condition, presence)) + block = self._number_paragraph(block, section, number) + out.append(self._structure(block, section=section, depth=0, presence=presence)) + return tuple(out) + + def _own_presence(self, condition: str, enclosing: Presence) -> Presence | None: + """A conditional unit's full presence, or None when it has no valid + condition of its own (and so cannot be an alternative).""" + own = self._presence(condition) + return enclosing | own if own else None + + def _number_paragraph(self, block: Paragraph, section: Section, number: int) -> Paragraph: + numbering = self.style.paragraphs + base = section.designation or "" + if numbering.numbered: + fmt = numbering.format + n = format_counter(number, fmt.counter) + designation = extend(base, fill(fmt.ref, n=n, section=base), textual=self.textual) + label = fill(fmt.label, n=n, section=base, path=designation) + target = _Target(designation, None) + else: + label, target = None, _Target(base, None, enumerated=False) + anchor = self._anchor(block.anchor_id) + if block.anchor_id: + self._register(block.anchor_id, replace(target, anchor=anchor)) + return replace(block, label=label, anchor=anchor) + + def _structure(self, block: Block, *, section: Section | None, depth: int, presence: Presence) -> Block: + match block: + case List(): + return self._structure_list(block, section=section, depth=depth, + parent=section.designation if section else "", presence=presence) + case Quote(blocks=blocks): + return Quote(tuple(self._structure(child, section=section, depth=depth, presence=presence) + for child in blocks)) + case DraftingNote(blocks=blocks): + return DraftingNote(tuple(self._structure(child, section=section, depth=depth, presence=presence) + for child in blocks), label=self.labels.drafting_note) + case _: + return block + + def _structure_list(self, block: List, *, section: Section | None, depth: int, parent: str, + presence: Presence) -> List: + enumeration = self.style.enumeration + fmt: LevelFormat | None + if block.ordered and enumeration.ordered == "renumber": + fmt = RENUMBERED + elif block.ordered or enumeration.enabled: + fmt = enumeration.levels[depth % len(enumeration.levels)] if enumeration.levels else None + else: + fmt = None + base = section.designation if section else "" + items: list[ListItem] = [] + counter = _Counter(self._exclusive) + for item in block.items: + index = counter.next(item.anchor_id, self._own_presence(item.condition, presence)) + # An item's nested blocks are present only when the item is. + inner = presence | self._presence(item.condition) + if fmt is not None: + n = format_counter(index, fmt.counter) + designation = extend(parent, fill(fmt.ref, n=n, section=base), textual=self.textual) + label = fill(fmt.label, n=n, section=base, path=designation) + target = _Target(designation, None) + else: + designation, label, target = parent, None, _Target(base, None, enumerated=False) + anchor = self._anchor(item.anchor_id) + if item.anchor_id and section is not None: + self._register(item.anchor_id, replace(target, anchor=anchor)) + children = tuple( + self._structure_list(child, section=section, depth=depth + 1, parent=designation, presence=inner) + if isinstance(child, List) + else self._structure(child, section=section, depth=depth + 1, presence=inner) + for child in item.blocks + ) + items.append(replace(item, blocks=children, label=label, anchor=anchor)) + return List(block.ordered, tuple(items), enumerated=fmt is not None) + + # -- blocks --------------------------------------------------------------- + + def _resolve_block(self, block: Block) -> Block: + resolve = self._resolve_inlines + match block: + case Paragraph(): + return replace(block, inlines=resolve(block.inlines), condition_label=self._condition(block.condition)) + case List(items=items): + return replace(block, items=tuple( + replace(item, blocks=tuple(self._resolve_block(child) for child in item.blocks), + condition_label=self._condition(item.condition)) + for item in items + )) + case Quote(blocks=blocks): + return Quote(tuple(self._resolve_block(child) for child in blocks)) + case DraftingNote(blocks=blocks): + return replace(block, blocks=tuple(self._resolve_block(child) for child in blocks)) + case Table(header=header, rows=rows, align=align): + return Table(tuple(resolve(cell) for cell in header), + tuple(tuple(resolve(cell) for cell in row) for row in rows), align) + case CodeBlock(): + return block + case _: + return block + + def _condition(self, condition: str) -> str | None: + if not condition: + return None + return fill_condition(self.labels.condition or "{condition}", condition) + + # -- inlines -------------------------------------------------------------- + + def _resolve_inlines(self, inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: + out: list[Inline] = [] + for inline in inlines: + resolved = self._resolve_inline(inline) + if resolved is not None: + out.append(resolved) + return tuple(out) + + def _resolve_inline(self, inline: Inline) -> Inline | None: + match inline: + case DirectiveSource(directive=directive): + return self._directive(directive) + case DefinitionSource(children=children): + identifier = self._definition_id(inline) + return DefinedTerm(self._resolve_inlines(children), + self._anchor(DEFINITION_ANCHOR_PREFIX + identifier, generated=True), + self.style.definitions.style) + case Emphasis(children=children): + return Emphasis(self._resolve_inlines(children)) + case Strong(children=children): + return Strong(self._resolve_inlines(children)) + case Link(children=children, title=title): + return replace(inline, children=self._resolve_inlines(children), + title=self._resolve_inlines(_attribute_text(title))) + case Image(children=children, title=title): + return replace(inline, children=self._resolve_inlines(_attribute_text(children)), + title=self._resolve_inlines(_attribute_text(title))) + case _: + return inline + + def _definition_id(self, source: DefinitionSource) -> str: + return source.directive.positional or slugify_identifier(source.term) + + def _directive(self, directive: Directive) -> Inline | None: + name = directive.name + if name not in _HANDLERS: + return FailureMarker(f"[UNKNOWN DIRECTIVE: {name}]") + if directive.malformed: + return FailureMarker(_MALFORMED[name]) if name in _MALFORMED else None + return _HANDLERS[name](self, directive, directive.positional or "") + + def _ref(self, directive: Directive, target_id: str) -> Inline: + target = self.targets.get(target_id) + if target is None: + return FailureMarker(f"[BROKEN REF: {target_id}]") + if not target.enumerated: + self._warn( + "ref-not-enumerated", + f"'{{{{ref: {target_id}}}}}' targets an item or paragraph that the style does not number; " + f"it renders as its section's designation, '{target.designation}' (§6.3).", + ) + text = self.style.references.format.replace("{designation}", target.designation) + return CrossRef(text, target.anchor or "") + + def _term(self, directive: Directive, definition_id: str) -> Inline: + term = self.result.definition_lookup.get(definition_id) + if term is None: + return FailureMarker(f"[UNDEFINED: {definition_id}]") + text = directive.params.get("label") or self._plain_value(term) + # Settled once everything is resolved (see _settle_term_target). + target = DEFINITION_ANCHOR_PREFIX + definition_id + return TermRef(text, target, self.style.definitions.term_style) + + def _date(self, directive: Directive, value: str) -> Inline: + if not is_valid_iso_date(value): + return FailureMarker(f"[INVALID DATE: {value}]") + return Value("date", self.formatter.date(value)) + + def _money(self, directive: Directive, amount: str) -> Inline: + currency = directive.params.get("currency") + if not is_valid_money_amount(amount): + return FailureMarker(f"[INVALID AMOUNT: {amount}]") + if currency is not None and currency not in KNOWN_CURRENCIES: + return FailureMarker(f"[UNKNOWN CURRENCY: {currency}]") + return Value("money", self.formatter.money(amount, currency)) + + def _party(self, directive: Directive, name: str) -> Inline: + return self._named("party", name, self.result.party_lookup, directive) + + def _side(self, directive: Directive, name: str) -> Inline: + return self._named("side", name, self.result.side_lookup, directive) + + def _named(self, kind: str, name: str, lookup: dict[str, str], directive: Directive) -> Inline: + """``{{party:}}`` and ``{{side:}}`` (§13.5): failure markers first — a + label never hides one (§11.5) — then the label, then the display name + the validator resolved (§3.6).""" + if not IDENTIFIER_RE.fullmatch(name): + return FailureMarker(f"[INVALID {kind.upper()}: {name}]") + if name not in lookup: + return FailureMarker(f"[UNKNOWN {kind.upper()}: {name}]") + return Value(kind, directive.params.get("label") or self._plain_value(lookup[name])) + + def _duration(self, directive: Directive, value: str) -> Inline: + unit = directive.params.get("unit") + if not is_positive_numeric(value): + return FailureMarker(f"[INVALID DURATION: {value}]") + if unit not in DURATION_UNITS: + return FailureMarker(f"[INVALID DURATION UNIT: {unit}]" if unit else "[INVALID DURATION UNIT]") + return Value("duration", self.formatter.duration(value, unit)) + + def _field(self, directive: Directive, value: str) -> Inline: + field_type = directive.params.get("type") + if not field_type or not IDENTIFIER_RE.fullmatch(field_type): + return FailureMarker(f"[INVALID FIELD: {value}]" if value else "[INVALID FIELD]") + return Value("field", value) + + def _placeholder_type(self, directive: Directive) -> str: + """The effective type of a placeholder (§10.7): as written, else its + declared question's, else ``text``.""" + declared = self.questions.get(directive.positional or "") + declared_type = declared.get("type") if isinstance(declared, dict) else None + return directive.params.get("type") or (declared_type if isinstance(declared_type, str) else None) or "text" + + def _placeholder(self, directive: Directive, placeholder_id: str) -> Inline: + invalid = FailureMarker(f"[INVALID PLACEHOLDER: {placeholder_id}]" if placeholder_id else "[INVALID PLACEHOLDER]") + if not IDENTIFIER_RE.fullmatch(placeholder_id): + return invalid + placeholder_type = self._placeholder_type(directive) + declared = self.questions.get(placeholder_id) + if isinstance(declared, dict) and declared.get("type") in _DECISION_TYPES: + return invalid # a decision question's id is not a blank (§15.2) + if placeholder_type not in _VALUE_TYPES or placeholder_id in self.inconsistent_placeholders: + return invalid + currency = directive.params.get("currency") + if placeholder_type == "money" and currency is not None and currency not in KNOWN_CURRENCIES: + return FailureMarker(f"[UNKNOWN CURRENCY: {currency}]") + unit = directive.params.get("unit") + if placeholder_type == "duration" and unit is not None and unit not in DURATION_UNITS: + return FailureMarker(f"[INVALID DURATION UNIT: {unit}]") + prompt = "" + if self.style.placeholders.show_prompt and isinstance(declared, dict): + prompt = str(declared.get("prompt") or "") + return Blank(placeholder_id, self.style.placeholders.blank, prompt) + + def _attach(self, directive: Directive, attachment_id: str) -> Inline: + title = self.result.attachment_lookup.get(attachment_id) + if title is None: + return FailureMarker(f"[UNKNOWN ATTACHMENT: {attachment_id}]") + anchor = self.attachment_anchors.get(attachment_id) + if anchor is not None: + href = "#" + anchor + else: + declared = next((a for a in self.metadata.attachments if a.id == attachment_id), None) + href = declared.file if declared and declared.file else None + return Value("attach", directive.params.get("label") or self._plain_value(title), href) + + def _choose(self, directive: Directive, question_id: str) -> Inline: + invalid = FailureMarker(f"[INVALID CHOICE: {question_id}]" if question_id else "[INVALID CHOICE]") + question = self.questions.get(question_id) + if not isinstance(question, dict): + return invalid + if question.get("type") == "boolean": + expected = {"true", "false"} + elif question.get("type") == "choice" and isinstance(question.get("choices"), dict): + expected = {str(value) for value in question["choices"]} + else: + return invalid + if directive.duplicates or set(directive.params) != expected: + return invalid + empty = self.style.template_view.empty_choice + options = tuple(value if value else empty for value in directive.params.values()) + return Choice(question_id, options, self.style.template_view.choice_separator) + + def _include(self, directive: Directive, path: str) -> Inline: + # Includes are a Full-level capability (§17.4): never dropped silently. + self._warn( + "render-not-processed", + f"'{{{{include: {path}}}}}' was not processed: file inclusion is beyond the Rendering " + f"conformance level (§17.5).", + ) + return FailureMarker(f"[NOT PROCESSED: include {path}]") + + def _def(self, directive: Directive, identifier: str) -> Inline | None: + return None # a {{def:}} with no quoted term: an Error; it shows nothing (§7.2) + + # -- frontmatter ---------------------------------------------------------- + + def _plain_value(self, text: str) -> str: + """A frontmatter string with its directives (placeholders, §3.10) + resolved, as plain text.""" + if "{{" not in text: + return text + return plain_text(self._resolve_inlines(plain_inlines(text))) + + def _frontmatter(self, text: str) -> tuple[Inline, ...]: + return self._resolve_inlines(plain_inlines(text)) + + def _header(self) -> tuple[tuple[str, tuple[Inline, ...]], ...]: + rows: list[tuple[str, tuple[Inline, ...]]] = [] + metadata = self.metadata + if self.style.title_block.effective_date and metadata.effective_date: + rows.append((self.labels.effective_date or "", self._date_or_text(metadata.effective_date))) + if self.style.title_block.version and metadata.version: + rows.append((self.labels.version or "", self._frontmatter(metadata.version))) + return tuple(rows) + + def _date_or_text(self, value: str) -> tuple[Inline, ...]: + if is_valid_iso_date(value): + return (Value("date", self.formatter.date(value)),) + return self._frontmatter(value) + + def _sides(self) -> tuple[SideInfo, ...]: + sides: list[SideInfo] = [] + for side in self.metadata.sides: + parties: list[PartyInfo] = [] + for party in side.parties: + details: list[tuple[str, tuple[Inline, ...]]] = [] + if party.identification_number: + details.append((self.labels.identification_number or "", self._frontmatter(party.identification_number))) + if party.date_of_birth: + details.append((self.labels.date_of_birth or "", self._date_or_text(party.date_of_birth))) + if party.address: + details.append((self.labels.address or "", self._frontmatter(party.address))) + for custom in party.custom_fields: + details.append((custom.label, self._frontmatter(custom.value))) + for rep in party.representatives: + name, title = self._frontmatter(rep.name), self._frontmatter(rep.title) + value = name + (Text(", "),) + title if name and title else name or title + if value: + details.append((self.labels.represented_by or "", value)) + parties.append(PartyInfo( + name=self._frontmatter(party.legal_name or party.label or party.name), + details=tuple(details), + )) + label = self.result.side_lookup.get(side.name) or side.label or side.name + sides.append(SideInfo(self._frontmatter(label), tuple(parties))) + return tuple(sides) + + def _attachments(self) -> tuple[AttachmentPart, ...]: + """Attachment placeholders (§13.8), in declared order. At the + Rendering level the files are not read, so each shows its title.""" + parts: list[AttachmentPart] = [] + for attachment in self.metadata.attachments: + if self.style.attachments.render == "omit": + continue + anchor = self._anchor(attachment.id) + self.attachment_anchors[attachment.id] = anchor + parts.append(AttachmentPart( + id=attachment.id, + title=plain_inlines(attachment.title or attachment.id), + file=fill_condition(self.labels.attachment_file or "{file}", attachment.file, key="file") + if attachment.file else "", + anchor=anchor, + condition_label=self._condition(attachment.when), + )) + return tuple(parts) + + def _signatures(self) -> tuple[SignatureParty, ...]: + if not (self.style.signatures.enabled and self.metadata.include_signatures): + return () + blocks: list[SignatureParty] = [] + for side in self.metadata.sides: + label = self._frontmatter(self.result.side_lookup.get(side.name) or side.label or side.name) + for party in side.parties: + # Where signature blocks are generated, legal_name MUST appear (§3.6). + blocks.append(SignatureParty( + side=label, + name=self._frontmatter(party.legal_name or party.label or party.name), + signatories=tuple( + (self._frontmatter(rep.name), self._frontmatter(rep.title)) for rep in party.representatives + ), + )) + return tuple(blocks) + + # -- diagnostics ---------------------------------------------------------- + + def _warn(self, rule: str, message: str) -> None: + diagnostic = Diagnostic(rule=rule, level="warning", message=message) + if diagnostic not in self.diagnostics: + self.diagnostics.append(diagnostic) + + +_HANDLERS: dict[str, Callable[[Resolver, Directive, str], Inline | None]] = { + "ref": Resolver._ref, + "term": Resolver._term, + "def": Resolver._def, + "date": Resolver._date, + "money": Resolver._money, + "party": Resolver._party, + "side": Resolver._side, + "duration": Resolver._duration, + "field": Resolver._field, + "placeholder": Resolver._placeholder, + "attach": Resolver._attach, + "choose": Resolver._choose, + "include": Resolver._include, +} + + +class _Counter: + """Numbers sibling units in order (§13.2, §15.8). A unit joins the + previous unit's number when it is an alternative to it: the same + identifier, and a presence that excludes the presence of every unit + already holding that number (§15.4). Only adjacent units are compared, + as the validator does for sections.""" + + def __init__(self, exclusive: Callable[[Presence, Presence], bool]) -> None: + self.exclusive = exclusive + self.count = 0 + self.identifier = "" + self.holders: list[Presence] = [] + + def next(self, identifier: str, presence: Presence | None) -> int: + """The number for the next unit. *presence* is None for a unit with + no valid condition of its own, which is never an alternative.""" + if (presence is not None and identifier and identifier == self.identifier + and all(self.exclusive(holder, presence) for holder in self.holders)): + self.holders.append(presence) + return self.count + self.count += 1 + self.identifier = identifier if presence is not None else "" + self.holders = [presence] if presence is not None else [] + return self.count + + +def fill_condition(template: str, value: str, *, key: str = "condition") -> str: + return template.replace("{" + key + "}", value) + + +def _attribute_text(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: + """*inlines* for a plain-text attribute (a title, alt text): a defined + term there shows its term but is not the definition's anchor, since an + attribute cannot hold one — however deeply it is nested.""" + out: list[Inline] = [] + for inline in inlines: + if isinstance(inline, DefinitionSource): + out.extend(_attribute_text(inline.children)) + continue + children = getattr(inline, "children", None) + if isinstance(children, tuple): + inline = replace(inline, children=_attribute_text(children)) + out.append(inline) + return tuple(out) + + +def resolve_tree( + tree: RenderTree, + document: Document, + result: ValidationResult, + style: Style, + formatter: Formatter, +) -> tuple[RenderTree, list[Diagnostic]]: + """The resolved tree, and the diagnostics only rendering can produce.""" + resolver = Resolver(tree, document, result, style, formatter) + resolved = resolver.run() + return resolved, resolver.diagnostics + + +__all__ = ["DEFINITION_ANCHOR_PREFIX", "Resolver", "resolve_tree"] + diff --git a/src/legaldown_render/resolve/values.py b/src/legaldown_render/resolve/values.py new file mode 100644 index 0000000..6e96538 --- /dev/null +++ b/src/legaldown_render/resolve/values.py @@ -0,0 +1,87 @@ +"""Locale formatting of field specs (§10, §13.5) with Babel's CLDR data +(docs/decisions/0005). + +Values are stored canonically in the source — ISO dates, decimal amounts with +a period — and only their display varies by locale. Amounts are handled as +``Decimal`` from the source text and are never rounded or truncated (§10.3): +money is padded to its currency's minor units, and any further digits written +in the source are kept. +""" +from __future__ import annotations + +import datetime +import re +from decimal import Decimal + +from babel import Locale, UnknownLocaleError +from babel.dates import format_date +from babel.numbers import format_currency, parse_pattern +from babel.units import format_unit + +from ..style.model import Values + +#: §10.5 duration units → CLDR unit ids. +DURATION_UNITS: dict[str, str] = { + "S": "duration-second", + "MIN": "duration-minute", + "H": "duration-hour", + "D": "duration-day", + "W": "duration-week", + "MO": "duration-month", + "Y": "duration-year", +} + + +def parse_locale(tag: str) -> Locale | None: + """The Babel locale for a BCP 47 *tag* (``cs-CZ``, ``en``), or None.""" + try: + return Locale.parse(tag.replace("-", "_")) + except (UnknownLocaleError, ValueError, TypeError): + return None + + +class Formatter: + """Formats values for one locale and one style's ``values`` settings.""" + + def __init__(self, locale: Locale, values: Values) -> None: + self.locale = locale + self.values = values + + @property + def tag(self) -> str: + return str(self.locale).replace("_", "-") + + def date(self, value: str) -> str: + """An ISO 8601 date (already validated) for display.""" + return format_date(datetime.date.fromisoformat(value), format=self.values.date, locale=self.locale) + + def number(self, value: str) -> str: + """A decimal amount with the locale's separators, keeping every + digit written after the decimal point.""" + decimals = len(value.partition(".")[2]) + pattern = parse_pattern(self.locale.decimal_formats[None].pattern) + pattern.frac_prec = (decimals, decimals) + return pattern.apply(Decimal(value), self.locale, decimal_quantization=False) + + def money(self, amount: str, currency: str | None) -> str: + """A money amount (already validated), with its currency when given.""" + if not currency: + return self.number(amount) + value = Decimal(amount) + if self.values.money == "name": + return format_currency(value, currency, locale=self.locale, format_type="name", + decimal_quantization=False) + pattern = None + if self.values.money == "code": + standard = self.locale.currency_formats["standard"].pattern + pattern = standard.replace("¤", "¤¤") if "¤¤" not in standard else standard + text = format_currency(value, currency, format=pattern, locale=self.locale, decimal_quantization=False) + if self.values.money == "code": + # CLDR spaces a letter code from the digits ("USD 10,000.00"); + # Babel does not apply that rule, so it is applied here. + text = re.sub(rf"^{currency}(?=\d)", f"{currency}\u00a0", text) + return text + + def duration(self, value: str, unit: str) -> str: + """A duration (already validated) such as "12 months" or "12 měsíců".""" + return format_unit(Decimal(value), DURATION_UNITS[unit], length=self.values.duration, locale=self.locale) diff --git a/src/legaldown_render/style/__init__.py b/src/legaldown_render/style/__init__.py new file mode 100644 index 0000000..bbee595 --- /dev/null +++ b/src/legaldown_render/style/__init__.py @@ -0,0 +1,54 @@ +"""Style templates (§13.7): the model, the built-in templates, and loading.""" +from .labels import BUILTIN_LABELS, effective_labels +from .loader import StyleError, builtin_styles, dump_style, load_style, parse_override, style_to_dict +from .model import ( + Attachments, + Contents, + Definitions, + Enumeration, + HeadingStyle, + Html, + Labels, + LevelFormat, + Numbering, + Page, + Paragraphs, + Placeholders, + References, + Signatures, + Style, + TemplateView, + TitleBlock, + Typography, + Values, +) + +__all__ = [ + "BUILTIN_LABELS", + "Attachments", + "Contents", + "Definitions", + "Enumeration", + "HeadingStyle", + "Html", + "Labels", + "LevelFormat", + "Numbering", + "Page", + "Paragraphs", + "Placeholders", + "References", + "Signatures", + "Style", + "StyleError", + "TemplateView", + "TitleBlock", + "Typography", + "Values", + "builtin_styles", + "dump_style", + "effective_labels", + "load_style", + "parse_override", + "style_to_dict", +] diff --git a/src/legaldown_render/style/builtin/continental.yaml b/src/legaldown_render/style/builtin/continental.yaml new file mode 100644 index 0000000..348815a --- /dev/null +++ b/src/legaldown_render/style/builtin/continental.yaml @@ -0,0 +1,24 @@ +# Continental drafting: numbered articles, section-qualified list items +# (5.1, 5.2, ...), and numbered paragraphs, so that "Article 5(2)"-style +# references can target untitled provisions (§13.2). +version: 1 +name: continental +extends: default + +numbering: + scheme: decimal + levels: + - { counter: decimal, label: "{n}.", ref: "{n}" } + +enumeration: + levels: + - { counter: decimal, label: "{section}.{n}", ref: ".{n}" } + - { counter: lower-alpha, label: "{n})", ref: "({n})" } + - { counter: lower-roman, label: "{n})", ref: "({n})" } + +paragraphs: + numbered: true + format: { counter: decimal, label: "({n})", ref: "({n})" } + +headings: + 1: { size: 1.1em, transform: none, align: center } diff --git a/src/legaldown_render/style/builtin/default.yaml b/src/legaldown_render/style/builtin/default.yaml new file mode 100644 index 0000000..a621b58 --- /dev/null +++ b/src/legaldown_render/style/builtin/default.yaml @@ -0,0 +1,7 @@ +# The default LegalDown style: decimal section numbering, (a) / (i) / (A) +# list enumeration, bold defined terms, and a neutral serif page. +# +# Every setting has a built-in default, so this file only names the style. +# Run `legaldown-render --print-style default` to see every setting. +version: 1 +name: default diff --git a/src/legaldown_render/style/builtin/outline.yaml b/src/legaldown_render/style/builtin/outline.yaml new file mode 100644 index 0000000..153c917 --- /dev/null +++ b/src/legaldown_render/style/builtin/outline.yaml @@ -0,0 +1,7 @@ +# Legal outline numbering: I. / A. / 1. / a. (§13.1). +version: 1 +name: outline +extends: default + +numbering: + scheme: legal-outline diff --git a/src/legaldown_render/style/labels.py b/src/legaldown_render/style/labels.py new file mode 100644 index 0000000..19a432a --- /dev/null +++ b/src/legaldown_render/style/labels.py @@ -0,0 +1,141 @@ +"""Built-in labels per document language (§13.7: generated words follow the +document's language). A style's ``labels`` override these one by one.""" +from __future__ import annotations + +from dataclasses import fields, replace + +from .model import Labels + +BUILTIN_LABELS: dict[str, Labels] = { + "en": Labels( + drafting_note="Drafting note", + condition="Only if: {condition}", + identification_number="Registration No.", + date_of_birth="Date of birth", + address="Address", + represented_by="Represented by", + effective_date="Effective date", + version="Version", + attachments="Attachments", + contents="Contents", + colon=": ", + attachment_file="File: {file}", + signatures="Signatures", + signature_date="Date", + signature_place="Place", + signature_name="Name", + signature_title="Title", + ), + "cs": Labels( + drafting_note="Poznámka pro zpracovatele", + condition="Pouze pokud: {condition}", + identification_number="IČO", + date_of_birth="Datum narození", + address="Adresa", + represented_by="Zastoupení", + effective_date="Datum účinnosti", + version="Verze", + attachments="Přílohy", + contents="Obsah", + colon=": ", + attachment_file="Soubor: {file}", + signatures="Podpisy", + signature_date="Datum", + signature_place="Místo", + 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", + colon=": ", + 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", + colon="\u00a0: ", + 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", + colon=": ", + 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", + colon=": ", + attachment_file="Súbor: {file}", + signatures="Podpisy", + signature_date="Dátum", + signature_place="Miesto", + signature_name="Meno", + signature_title="Funkcia", + ), +} + + +def effective_labels(labels: Labels, language: str) -> Labels: + """*labels* with every unset label taken from the built-in labels for + *language* (its primary subtag, e.g. ``cs`` for ``cs-CZ``), falling back + to English.""" + primary = (language or "en").replace("_", "-").split("-")[0].lower() + builtin = BUILTIN_LABELS.get(primary, BUILTIN_LABELS["en"]) + english = BUILTIN_LABELS["en"] + values = {} + for item in fields(Labels): + value = getattr(labels, item.name) + if value is None: + value = getattr(builtin, item.name) or getattr(english, item.name) + values[item.name] = value + return replace(labels, **values) diff --git a/src/legaldown_render/style/loader.py b/src/legaldown_render/style/loader.py new file mode 100644 index 0000000..c3239ac --- /dev/null +++ b/src/legaldown_render/style/loader.py @@ -0,0 +1,360 @@ +"""Loading, layering, and validating style templates. + +Values are layered, each layer overriding the one before (docs/style-templates.md): + +1. the defaults in :class:`~.model.Style` +2. the chain of templates named by ``extends``, base first +3. the chosen template +4. per-job overrides (``--set key=value`` or ``RenderOptions.overrides``) + +The merged mapping is then checked against the dataclasses in :mod:`.model`. +Every problem is collected, with its key path, before :class:`StyleError` is +raised, so one run reports everything that is wrong with a template. +""" +from __future__ import annotations + +import copy +import dataclasses +import difflib +import types +from collections.abc import Mapping +from importlib import resources +from pathlib import Path +from typing import Any, Literal, Union, get_args, get_origin, get_type_hints + +import yaml + +from . import model +from .model import Style + +STYLE_FORMAT_VERSION = 1 + + +class StyleError(ValueError): + """A style template or override is invalid. ``problems`` lists each + problem with its key path.""" + + def __init__(self, source: str, problems: list[str]) -> None: + self.source = source + self.problems = problems + details = "\n".join(f" - {problem}" for problem in problems) + super().__init__(f"Invalid style {source}:\n{details}") + + +def builtin_styles() -> list[str]: + """Names of the style templates shipped with the package.""" + folder = resources.files(__package__).joinpath("builtin") + return sorted(item.name[: -len(".yaml")] for item in folder.iterdir() if item.name.endswith(".yaml")) + + +def load_style( + style: str | Path | Style | None = None, + *, + overrides: Mapping[str, Any] | None = None, +) -> Style: + """The validated :class:`Style` for *style* — a built-in name, a path to + a YAML file, or a :class:`Style` — with *overrides* applied. + + *overrides* maps dotted keys to values, e.g. + ``{"numbering.scheme": "mixed", "locale": "cs-CZ"}``; nested mappings are + accepted too. + """ + if isinstance(style, Style): + data, source = style_to_dict(style), f"'{style.name}'" + else: + data, source = _load_chain(style if style is not None else "default", seen=()) + if overrides: + data = _merge(data, _expand_dotted(overrides)) + return _build(data, source) + + +def style_to_dict(style: Style) -> dict[str, Any]: + """*style* as plain data, in the shape a YAML style template is written.""" + return _plain(style) + + +def dump_style(style: Style) -> str: + """*style* as a complete YAML style template.""" + return yaml.safe_dump(style_to_dict(style), sort_keys=False, allow_unicode=True) + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def _load_chain(spec: str | Path, *, seen: tuple[str, ...], relative_to: Path | None = None) -> tuple[dict, str]: + """The merged raw mapping for *spec* and every template it extends.""" + raw, source, folder = _read(spec, relative_to) + if source in seen: + chain = " -> ".join((*seen, source)) + raise StyleError(source, [f"extends: circular chain {chain}"]) + parent = raw.get("extends") + if parent is None: + if source == "'default'": + # Start from every built-in default, so that a partial mapping — + # one heading level's alignment, say — merges field by field. + return _merge(style_to_dict(Style()), raw), source + base, _ = _load_chain("default", seen=(*seen, source)) + elif isinstance(parent, str) and parent: + base, _ = _load_chain(parent, seen=(*seen, source), relative_to=folder) + else: + raise StyleError(source, ["extends: must be a style name or a path"]) + return _merge(base, raw), source + + +def _read(spec: str | Path, relative_to: Path | None) -> tuple[dict, str, Path | None]: + """The raw mapping in *spec*, a label for messages, and the folder that + relative ``extends`` paths are resolved against.""" + name = str(spec) + is_path = isinstance(spec, Path) or name.endswith((".yaml", ".yml")) or "/" in name or "\\" in name + if not is_path: + if name not in builtin_styles(): + known = ", ".join(builtin_styles()) + raise StyleError(f"'{name}'", [f"no built-in style named '{name}' (built-in: {known}); " + f"to load a file, give a path ending in .yaml"]) + text = resources.files(__package__).joinpath("builtin", f"{name}.yaml").read_text("utf-8") + return _parse(text, f"'{name}'"), f"'{name}'", None + path = Path(spec) + if relative_to is not None and not path.is_absolute(): + path = relative_to / path + try: + text = path.read_text("utf-8") + except OSError as error: + raise StyleError(str(path), [f"cannot read the file: {error.strerror or error}"]) from None + raw = _parse(text, str(path)) + raw.setdefault("name", path.stem) + return raw, str(path), path.parent + + +def _parse(text: str, source: str) -> dict: + try: + raw = yaml.safe_load(text) + except yaml.YAMLError as error: + raise StyleError(source, [f"not valid YAML: {error}"]) from None + if raw is None: + return {} + if not isinstance(raw, dict): + raise StyleError(source, ["a style template must be a YAML mapping"]) + version = raw.get("version", STYLE_FORMAT_VERSION) + if version != STYLE_FORMAT_VERSION: + raise StyleError(source, [f"version: this renderer reads style format {STYLE_FORMAT_VERSION}, got {version!r}"]) + return raw + + +def _merge(base: Mapping, override: Mapping) -> dict: + """*override* deep-merged onto *base*; mappings merge, anything else + (lists included) replaces.""" + merged = {str(key): value for key, value in base.items()} + for key, value in override.items(): + key = str(key) + if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping): + merged[key] = _merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _expand_dotted(overrides: Mapping[str, Any]) -> dict: + """*overrides* as one nested mapping. Dotted and nested keys for the + same setting merge in order, and the caller's values are copied, never + modified.""" + expanded: dict = {} + for key, value in overrides.items(): + target = expanded + *parents, last = str(key).split(".") + for part in parents: + target = target.setdefault(part, {}) + if not isinstance(target, dict): + raise StyleError("overrides", [f"{key}: '{part}' is set to a value and cannot hold keys"]) + value = _copy(value) + if isinstance(value, dict) and isinstance(target.get(last), dict): + value = _merge(target[last], value) + target[last] = value + return expanded + + +def _copy(value: Any) -> Any: + """A deep copy of *value* with every mapping made a plain dict.""" + if isinstance(value, Mapping): + return {str(key): _copy(item) for key, item in value.items()} + return copy.deepcopy(value) + + +def parse_override(key: str, text: str) -> Any: + """The value for setting *key* written as *text* on a command line. + + Text settings take *text* as written, so ``[_____]``, ``{designation}`` + or ``C:\\docs`` stay text. A value wrapped in one pair of matching quotes + that does not contain that quote character (``"none"``, ``' | '``) is + unquoted — nothing else is interpreted, no escapes and no comments. To + keep quotation marks around a value, wrap it in the other kind + (``'"{designation}"'``). Other settings read it as YAML, so that + ``true``, ``2``, or ``[...]`` mean what they say. ``null`` clears a + setting that may be unset, such as ``locale``. + """ + hint = _hint_for(key.split(".")) + if hint is not None and _is_text(hint): + stripped = text.strip() + if type(None) in get_args(hint) and stripped in ("null", "~"): + return None + quote = stripped[:1] + if len(stripped) >= 2 and quote in "'\"" and stripped[-1] == quote and quote not in stripped[1:-1]: + return stripped[1:-1] + return text + try: + return yaml.safe_load(text) if text.strip() else "" + except yaml.YAMLError: + return text + + +def _hint_for(parts: list[str]) -> Any: + """The declared type of the setting at the dotted path *parts*, or None.""" + hint: Any = Style + for part in parts: + if dataclasses.is_dataclass(hint): + hints = get_type_hints(hint, vars(model)) + if part not in hints: + return None + hint = hints[part] + elif get_origin(hint) is dict: + hint = get_args(hint)[1] + else: + return None + return hint + + +def _is_text(hint: Any) -> bool: + if hint is str: + return True + origin = get_origin(hint) + if origin is Literal: + return all(isinstance(arg, str) for arg in get_args(hint)) + if origin in (Union, types.UnionType): + options = [arg for arg in get_args(hint) if arg is not type(None)] + return bool(options) and all(_is_text(arg) for arg in options) + return False + + +# --------------------------------------------------------------------------- +# Validation: raw data -> dataclasses +# --------------------------------------------------------------------------- + + +def _build(data: Mapping, source: str) -> Style: + problems: list[str] = [] + style = _convert(data, Style, "", problems) + if problems: + raise StyleError(source, problems) + return style + + +def _convert(value: Any, hint: Any, path: str, problems: list[str]) -> Any: + label = path or "(root)" + origin = get_origin(hint) + if dataclasses.is_dataclass(hint): + return _convert_dataclass(value, hint, path, problems) + if origin is Literal: + allowed = get_args(hint) + # 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 + if origin in (Union, types.UnionType): + options = [arg for arg in get_args(hint) if arg is not type(None)] + if value is None and type(None) in get_args(hint): + return None + return _convert(value, options[0], path, problems) + if origin is tuple: + item_hint = get_args(hint)[0] + if not isinstance(value, (list, tuple)): + problems.append(f"{label}: must be a list") + return () + return tuple(_convert(item, item_hint, f"{path}[{index}]", problems) for index, item in enumerate(value)) + if origin is dict: + key_hint, item_hint = get_args(hint) + if not isinstance(value, Mapping): + problems.append(f"{label}: must be a mapping") + return {} + converted = {} + for key, item in value.items(): + if key_hint is int: + try: + key = int(key) + except (TypeError, ValueError): + problems.append(f"{path}.{key}: key must be a number") + continue + if not 1 <= key <= 5: + problems.append(f"{path}.{key}: heading levels are 1 to 5 (§4.1)") + continue + converted[key] = _convert(item, item_hint, f"{path}.{key}", problems) + return converted + if hint is bool: + if not isinstance(value, bool): + problems.append(f"{label}: must be true or false (got {value!r})") + return False + return value + if hint is int: + if isinstance(value, bool) or not isinstance(value, int): + problems.append(f"{label}: must be a whole number (got {value!r})") + return 0 + return value + if hint is str: + # YAML reads 700 or 1.5 as numbers; they are fine as text here. + if isinstance(value, (int, float)) and not isinstance(value, bool): + return str(value) + if not isinstance(value, str): + problems.append(f"{label}: must be text (got {value!r})") + return "" + return value + raise TypeError(f"unsupported style field type {hint!r} at {label}") # a bug in model.py + + +def _convert_dataclass(value: Any, cls: type, path: str, problems: list[str]) -> Any: + label = path or "(root)" + if isinstance(value, cls): + return value + if cls is model.Numbering and isinstance(value, Mapping) and isinstance(value.get("levels"), (list, tuple)) \ + and len(value["levels"]) > 5: + problems.append(f"{path}.levels: at most 5 formats, one per heading level (§4.1); got {len(value['levels'])}") + if not isinstance(value, Mapping): + problems.append(f"{label}: must be a mapping") + return cls() + hints = get_type_hints(cls, vars(model)) + known = {item.name: item for item in dataclasses.fields(cls)} + values = {} + for key, item in value.items(): + key = str(key) + where = f"{path}.{key}" if path else key + if key not in known: + suggestion = _closest(key, list(known)) + problems.append(f"{where}: unknown key" + (f" (did you mean '{suggestion}'?)" if suggestion else "")) + continue + values[key] = _convert(item, hints[key], where, problems) + return cls(**values) + + +def _closest(key: str, candidates: list[str]) -> str | None: + matches = difflib.get_close_matches(key, candidates, n=1, cutoff=0.6) + return matches[0] if matches else None + + +def _plain(value: Any) -> Any: + if dataclasses.is_dataclass(value): + return {item.name: _plain(getattr(value, item.name)) for item in dataclasses.fields(value)} + if isinstance(value, tuple): + return [_plain(item) for item in value] + if isinstance(value, dict): + return {key: _plain(item) for key, item in value.items()} + return value + + +__all__ = [ + "StyleError", + "builtin_styles", + "dump_style", + "load_style", + "parse_override", + "style_to_dict", +] diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py new file mode 100644 index 0000000..b7e7600 --- /dev/null +++ b/src/legaldown_render/style/model.py @@ -0,0 +1,284 @@ +"""The style template model (specification §13.7). + +A style template holds everything about how a document looks and nothing +about what it says. It is split in two: + +* the **semantic** part — numbering, enumeration, references, values, + labels — drives resolution, so it applies identically to every output + format; +* the **presentation** part — typography, headings, page, html — is read only + by writers. + +Every field has a default, so the built-in ``default`` template is simply +``Style()``. Templates are loaded from YAML by :mod:`.loader`, which checks +every key and value against these dataclasses. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +Counter = Literal["decimal", "lower-alpha", "upper-alpha", "lower-roman", "upper-roman"] + + +@dataclass(frozen=True, slots=True) +class LevelFormat: + """How one level of headings, list items, or paragraphs is numbered. + + ``label`` is what the output shows next to the heading or item; + ``ref`` is this level's piece of a designation, the text ``{{ref:}}`` + renders (§13.3). Both are templates: + + * ``{n}`` — this level's counter, formatted by ``counter`` + * ``{path}`` — the full designation up to and including this level + * ``{section}`` — the designation of the containing section (list items + and paragraphs only) + + For example, decimal headings use ``label: "{path}"`` and ``ref: ".{n}"`` + so that the third subsection of Article 2 shows ``2.3`` and is referenced + as ``2.3``; legal enumeration uses ``label: "({n})"`` and ``ref: "({n})"`` + so an item is referenced as ``2.3(b)``. + """ + + counter: Counter = "decimal" + label: str = "{n}." + ref: str = "{n}" + + +@dataclass(frozen=True, slots=True) +class Numbering: + """Section numbering (§13.1).""" + + #: A built-in scheme. ``none`` shows no numbers, and references render + #: the target's heading text instead (§13.3). + scheme: Literal["decimal", "legal-outline", "mixed", "none"] = "decimal" + #: Overrides the scheme's formats, level by level from level 1 (at most + #: five, one per heading level, §4.1). Levels not listed keep the + #: scheme's format. + levels: tuple[LevelFormat, ...] = () + + +@dataclass(frozen=True, slots=True) +class Enumeration: + """List enumeration (§13.2).""" + + #: False keeps plain bullets; items then have no designation, and a + #: ``{{ref:}}`` to an item anchor falls back to its section's number with + #: a ``ref-not-enumerated`` Warning (§6.3). + enabled: bool = True + #: Formats by nesting depth, cycling when a list is nested deeper. + levels: tuple[LevelFormat, ...] = ( + LevelFormat("lower-alpha", "({n})", "({n})"), + LevelFormat("lower-roman", "({n})", "({n})"), + LevelFormat("upper-alpha", "({n})", "({n})"), + ) + #: Ordered lists are always renumbered — numbers in the source are not + #: authoritative (§13.2). ``renumber`` shows ``1.``, ``2.``, …; + #: ``enumerate`` applies ``levels`` as for unordered lists. + ordered: Literal["renumber", "enumerate"] = "renumber" + + +@dataclass(frozen=True, slots=True) +class Paragraphs: + """Numbering of the top-level paragraphs in a section (§13.2).""" + + numbered: bool = False + format: LevelFormat = LevelFormat("decimal", "{section}.{n}", ".{n}") + + +@dataclass(frozen=True, slots=True) +class References: + """How ``{{ref:}}`` is displayed (§13.3).""" + + #: ``{designation}`` is the target's number ("4.2", "4.2(b)"), or its + #: heading text under the ``none`` scheme. + format: str = "{designation}" + + +TextStyle = Literal["bold", "italic", "underline", "small-caps", "plain"] + + +@dataclass(frozen=True, slots=True) +class Definitions: + """How defined terms look (§7.2). Quotation marks are never rendered.""" + + #: The defining occurrence, ``"Services" {{def:}}``. + style: TextStyle = "bold" + #: Every ``{{term:}}`` reference. + term_style: TextStyle = "plain" + + +@dataclass(frozen=True, slots=True) +class Values: + """Formatting of field specs (§10, §13.5).""" + + #: ``short``, ``medium``, ``long``, ``full``, or a CLDR date pattern. + date: str = "long" + #: ``symbol`` ($10,000.00), ``code`` (USD 10,000.00), or ``name`` + #: (10,000.00 US dollars). + money: Literal["symbol", "code", "name"] = "symbol" + #: ``long`` (12 months), ``short`` (12 mths), ``narrow`` (12m). + duration: Literal["long", "short", "narrow"] = "long" + + +@dataclass(frozen=True, slots=True) +class Placeholders: + """How fillable blanks render (§10.7, §13.5).""" + + blank: str = "[_____]" + #: Show the question's ``prompt`` next to the blank (§15.8). + show_prompt: bool = False + + +@dataclass(frozen=True, slots=True) +class TitleBlock: + """The block under the document title.""" + + parties: bool = True + effective_date: bool = True + version: bool = False + + +@dataclass(frozen=True, slots=True) +class Attachments: + """Attachments rendered after the body (§13.8). At the Rendering level + their files are not read, so each renders as a titled placeholder.""" + + render: Literal["placeholder", "omit"] = "placeholder" + 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 + ``include_signatures: false`` always wins.""" + + enabled: bool = True + + +@dataclass(frozen=True, slots=True) +class TemplateView: + """How a template renders without answers (§15.8).""" + + #: Between the phrases of a ``{{choose:}}``: ``[a / b]``. + choice_separator: str = " / " + #: Shown for an empty phrase (``""``), which otherwise shows nothing. + empty_choice: str = "" + + +@dataclass(frozen=True, slots=True) +class Labels: + """Every word the renderer generates itself. ``None`` takes the value + from the built-in labels for the document's ``language``, so a Czech + document gets Czech labels without a style saying so (§13.7).""" + + drafting_note: str | None = None + #: ``{condition}`` is the condition as written, e.g. ``forum:courts``. + condition: str | None = None + identification_number: str | None = None + date_of_birth: str | None = None + address: str | None = None + represented_by: str | None = None + effective_date: str | None = None + 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 + signature_date: str | None = None + signature_place: str | None = None + signature_name: str | None = None + signature_title: str | None = None + + +@dataclass(frozen=True, slots=True) +class HeadingStyle: + """Presentation of one heading level.""" + + size: str = "1em" + weight: str = "700" + italic: bool = False + transform: Literal["none", "uppercase", "small-caps"] = "none" + align: Literal["left", "center"] = "left" + + +@dataclass(frozen=True, slots=True) +class Typography: + font_family: str = 'Georgia, "Times New Roman", serif' + font_size: str = "11pt" + line_height: str = "1.5" + color: str = "#1a1a1a" + link_color: str = "#1f4e8c" + justify: bool = False + + +@dataclass(frozen=True, slots=True) +class Page: + """Print layout. Used by the HTML writer's print stylesheet now, and by + the PDF writer later.""" + + size: str = "A4" + margin: str = "25mm" + + +@dataclass(frozen=True, slots=True) +class Html: + max_width: str = "46rem" + #: Appended to the generated stylesheet. Trusted configuration: a hosted + #: service must not accept it from untrusted users. + extra_css: str = "" + + +def _default_headings() -> dict[int, HeadingStyle]: + return { + 1: HeadingStyle(size="1.15em", transform="uppercase"), + 2: HeadingStyle(size="1em"), + 3: HeadingStyle(size="1em", weight="600", italic=True), + 4: HeadingStyle(size="1em", weight="600", italic=True), + 5: HeadingStyle(size="1em", weight="400", italic=True), + } + + +@dataclass(frozen=True, slots=True) +class Style: + """A complete, validated style template.""" + + version: int = 1 + name: str = "default" + #: The template this one builds on; resolved by the loader. + extends: str | None = None + #: The formatting locale (§10.1), e.g. ``en-US`` or ``cs-CZ``. ``None`` + #: uses the document's ``language`` as a hint, as §10.1 allows. + locale: str | None = None + numbering: Numbering = field(default_factory=Numbering) + enumeration: Enumeration = field(default_factory=Enumeration) + paragraphs: Paragraphs = field(default_factory=Paragraphs) + references: References = field(default_factory=References) + definitions: Definitions = field(default_factory=Definitions) + 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) + labels: Labels = field(default_factory=Labels) + typography: Typography = field(default_factory=Typography) + headings: dict[int, HeadingStyle] = field(default_factory=_default_headings) + page: Page = field(default_factory=Page) + html: Html = field(default_factory=Html) diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py new file mode 100644 index 0000000..0dd74b7 --- /dev/null +++ b/src/legaldown_render/tree.py @@ -0,0 +1,512 @@ +"""The render tree: a format-neutral tree of blocks and inlines. + +The build stage (:mod:`.build`) produces the tree with **source** inline +nodes — :class:`DirectiveSource` and :class:`DefinitionSource` — that still +carry LegalDown semantics. The resolve stage (:mod:`.resolve`) replaces every +one of them with a **resolved** node that holds only display text and link +targets, and fills in numbers, labels, and anchors. Writers accept only the +resolved tree (docs/decisions/0003): :func:`assert_resolved` rejects a tree +that still holds a source node. + +All nodes are frozen; stages build new trees rather than mutate. +""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field, replace + +from legaldown import Directive + +from .errors import InternalError + +# --------------------------------------------------------------------------- +# Inlines +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class Text: + text: str + + +@dataclass(frozen=True, slots=True) +class SoftBreak: + """A line break in the source that is not a hard break.""" + + +@dataclass(frozen=True, slots=True) +class HardBreak: + pass + + +@dataclass(frozen=True, slots=True) +class Emphasis: + children: tuple[Inline, ...] + + +@dataclass(frozen=True, slots=True) +class Strong: + children: tuple[Inline, ...] + + +@dataclass(frozen=True, slots=True) +class Code: + text: str + + +@dataclass(frozen=True, slots=True) +class Link: + href: str + children: tuple[Inline, ...] + #: The link's title, which may hold directives like any other text. + title: tuple[Inline, ...] = () + + +@dataclass(frozen=True, slots=True) +class Image: + src: str + #: The alt text, which may hold directives like any other text. + children: tuple[Inline, ...] + title: tuple[Inline, ...] = () + + +# Source inlines (build stage only) ----------------------------------------- + + +@dataclass(frozen=True, slots=True) +class DirectiveSource: + """A directive as lexed by ``legaldown.directives``, not yet resolved.""" + + directive: Directive + + +@dataclass(frozen=True, slots=True) +class DefinitionSource: + """``"Term" {{def: id}}``: the quoted term and its anchor (§7.2). The + term's own inline content is in ``children``; the quotation marks are + source-only and are gone.""" + + directive: Directive + children: tuple[Inline, ...] + term: str + + +# Resolved inlines ----------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class CrossRef: + """A resolved ``{{ref:}}``: display text and the output anchor it links to.""" + + text: str + target: str + + +@dataclass(frozen=True, slots=True) +class TermRef: + """A resolved ``{{term:}}``. ``target`` is ``None`` when the definition is + known but not in this document's body (e.g. imported by an amendment).""" + + text: str + target: str | None + style: str + + +@dataclass(frozen=True, slots=True) +class DefinedTerm: + """The defining occurrence of a term, with its anchor.""" + + children: tuple[Inline, ...] + anchor: str | None + style: str + #: False for a copy shown elsewhere, such as in the table of contents: + #: it looks the same but does not define the term. + defining: bool = True + + +@dataclass(frozen=True, slots=True) +class Value: + """A formatted field spec or name: ``kind`` is the directive name + (date, money, duration, field, party, side, attach).""" + + kind: str + text: str + href: str | None = None + + +@dataclass(frozen=True, slots=True) +class Blank: + """A fillable placeholder (§10.7, §13.5).""" + + id: str + text: str + prompt: str = "" + + +@dataclass(frozen=True, slots=True) +class Choice: + """A ``{{choose:}}`` in the template view (§15.8): every phrase, in the + order written.""" + + question: str + options: tuple[str, ...] + separator: str + + +@dataclass(frozen=True, slots=True) +class FailureMarker: + """A bracketed failure marker such as ``[BROKEN REF: id]`` (§11.5, §13).""" + + text: str + + +Inline = ( + Text | SoftBreak | HardBreak | Emphasis | Strong | Code | Link | Image + | DirectiveSource | DefinitionSource + | CrossRef | TermRef | DefinedTerm | Value | Blank | Choice | FailureMarker +) + +SOURCE_INLINES = (DirectiveSource, DefinitionSource) + +# --------------------------------------------------------------------------- +# Blocks +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class Paragraph: + inlines: tuple[Inline, ...] + #: From the paragraph's marker (§5.7, §15.3), as written. + anchor_id: str = "" + condition: str = "" + #: True for a top-level paragraph in a section (numbered when the style + #: numbers paragraphs). + top_level: bool = False + # Filled in by the resolve stage: + label: str | None = None + anchor: str | None = None + condition_label: str | None = None + + +@dataclass(frozen=True, slots=True) +class ListItem: + blocks: tuple[Block, ...] + anchor_id: str = "" + condition: str = "" + label: str | None = None + anchor: str | None = None + condition_label: str | None = None + + +@dataclass(frozen=True, slots=True) +class List: + ordered: bool + items: tuple[ListItem, ...] + #: Set by the resolve stage: whether items carry generated labels. + enumerated: bool = False + + +@dataclass(frozen=True, slots=True) +class Quote: + blocks: tuple[Block, ...] + + +@dataclass(frozen=True, slots=True) +class DraftingNote: + """A ``> [!DRAFTING]`` block quote (§15.6).""" + + blocks: tuple[Block, ...] + label: str | None = None + + +@dataclass(frozen=True, slots=True) +class CodeBlock: + text: str + info: str = "" + + +@dataclass(frozen=True, slots=True) +class Table: + header: tuple[tuple[Inline, ...], ...] + rows: tuple[tuple[tuple[Inline, ...], ...], ...] + #: Per column: "left", "center", "right", or "". + align: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class Rule: + pass + + +Block = Paragraph | List | Quote | DraftingNote | CodeBlock | Table | Rule + +# --------------------------------------------------------------------------- +# Document +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class Section: + """One heading and the blocks up to the next heading. Sections are kept + flat, in document order; ``level`` gives the hierarchy.""" + + level: int + title: tuple[Inline, ...] + #: The identifier the validator resolved (explicit, or generated by §5.3). + identifier: str + condition: str + blocks: tuple[Block, ...] + # Filled in by the resolve stage: + label: str | None = None + 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, ...]], ...] + + +@dataclass(frozen=True, slots=True) +class SideInfo: + label: tuple[Inline, ...] + parties: tuple[PartyInfo, ...] + + +@dataclass(frozen=True, slots=True) +class AttachmentPart: + id: str + title: tuple[Inline, ...] + file: str + anchor: str | None + condition_label: str | None = None + + +@dataclass(frozen=True, slots=True) +class SignatureParty: + side: tuple[Inline, ...] + name: tuple[Inline, ...] + #: (name, title) per representative; empty when none are declared. + signatories: tuple[tuple[tuple[Inline, ...], tuple[Inline, ...]], ...] + + +@dataclass(frozen=True, slots=True) +class ContentsEntry: + """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 + title: tuple[Inline, ...] + 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 + them by :mod:`.resolve`.""" + + title: tuple[Inline, ...] + subtitle: tuple[Inline, ...] + language: str + preamble: tuple[Block, ...] + sections: tuple[Section, ...] + #: True when the document declares ``questions`` or uses template + #: constructs, so it renders as a template view (§15.8). + is_template: bool = False + # Filled in by the resolve stage: + locale: str = "" + header: tuple[tuple[str, tuple[Inline, ...]], ...] = () + sides: tuple[SideInfo, ...] = () + attachments: tuple[AttachmentPart, ...] = () + 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 = "" + #: The attachments heading's anchor, when there are attachments. + attachments_anchor: str = "" + #: Between a generated label and its value ("Name: ..."), per language. + colon: str = ": " + + +# --------------------------------------------------------------------------- +# Traversal +# --------------------------------------------------------------------------- + + +def iter_inlines(inlines: tuple[Inline, ...]) -> Iterator[Inline]: + """Every inline in *inlines*, depth first.""" + for inline in inlines: + yield inline + for nested in (getattr(inline, "children", None), getattr(inline, "title", None)): + if isinstance(nested, tuple): + yield from iter_inlines(nested) + + +def iter_block_inlines(block: Block) -> Iterator[Inline]: + """Every inline inside *block*, depth first.""" + match block: + case Paragraph(inlines=inlines): + yield from iter_inlines(inlines) + case List(items=items): + for item in items: + for child in item.blocks: + yield from iter_block_inlines(child) + case Quote(blocks=blocks) | DraftingNote(blocks=blocks): + for child in blocks: + yield from iter_block_inlines(child) + case Table(header=header, rows=rows): + for cell in header: + yield from iter_inlines(cell) + for row in rows: + for cell in row: + yield from iter_inlines(cell) + + +def iter_tree_inlines(tree: RenderTree) -> Iterator[Inline]: + yield from iter_inlines(tree.title) + yield from iter_inlines(tree.subtitle) + for block in tree.preamble: + yield from iter_block_inlines(block) + for section in tree.sections: + yield from iter_inlines(section.title) + for block in section.blocks: + yield from iter_block_inlines(block) + for _, value in tree.header: + yield from iter_inlines(value) + for side in tree.sides: + yield from iter_inlines(side.label) + for party in side.parties: + yield from iter_inlines(party.name) + for _, value in party.details: + yield from iter_inlines(value) + 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) + for name, title in signature.signatories: + yield from iter_inlines(name) + yield from iter_inlines(title) + + +def map_inlines(inlines: tuple[Inline, ...], fn: Callable[[Inline], Inline]) -> tuple[Inline, ...]: + """*inlines* with *fn* applied to every inline, innermost first.""" + out: list[Inline] = [] + for inline in inlines: + children = getattr(inline, "children", None) + if isinstance(children, tuple): + inline = replace(inline, children=map_inlines(children, fn)) + title = getattr(inline, "title", None) + if isinstance(title, tuple): + inline = replace(inline, title=map_inlines(title, fn)) + out.append(fn(inline)) + return tuple(out) + + +def map_block_inlines(block: Block, fn: Callable[[Inline], Inline]) -> Block: + """*block* with *fn* applied to every inline inside it.""" + match block: + case Paragraph(inlines=inlines): + return replace(block, inlines=map_inlines(inlines, fn)) + case List(items=items): + return replace(block, items=tuple( + replace(item, blocks=tuple(map_block_inlines(child, fn) for child in item.blocks)) for item in items)) + case Quote(blocks=blocks) | DraftingNote(blocks=blocks): + return replace(block, blocks=tuple(map_block_inlines(child, fn) for child in blocks)) + case Table(header=header, rows=rows): + return replace(block, header=tuple(map_inlines(cell, fn) for cell in header), + rows=tuple(tuple(map_inlines(cell, fn) for cell in row) for row in rows)) + return block + + +def map_tree_inlines(tree: RenderTree, fn: Callable[[Inline], Inline]) -> RenderTree: + """*tree* with *fn* applied to every inline it holds, wherever it is.""" + def pair(value: tuple[Inline, ...]) -> tuple[Inline, ...]: + return map_inlines(value, fn) + + return replace( + tree, + title=pair(tree.title), + subtitle=pair(tree.subtitle), + preamble=tuple(map_block_inlines(block, fn) for block in tree.preamble), + sections=tuple(replace(section, title=pair(section.title), + blocks=tuple(map_block_inlines(block, fn) for block in section.blocks)) + for section in tree.sections), + 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)) + 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, defining=False) + 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).""" + for inline in iter_tree_inlines(tree): + if isinstance(inline, SOURCE_INLINES): + raise InternalError(f"an unresolved {type(inline).__name__} reached a writer. This is a bug; " + "please report it with the document that triggers it.") + + +def plain_text(inlines: tuple[Inline, ...]) -> str: + """The visible text of resolved *inlines*, without formatting.""" + parts: list[str] = [] + for inline in inlines: + match inline: + case Text(text=text) | Code(text=text) | CrossRef(text=text) | TermRef(text=text) \ + | Value(text=text) | Blank(text=text) | FailureMarker(text=text): + parts.append(text) + case SoftBreak() | HardBreak(): + parts.append(" ") + case Choice(options=options, separator=separator): + parts.append("[" + separator.join(options) + "]") + case DirectiveSource(directive=directive): + parts.append(directive.source) + case _ if getattr(inline, "children", None) is not None: + parts.append(plain_text(inline.children)) + return "".join(parts) diff --git a/src/legaldown_render/validator_bridge.py b/src/legaldown_render/validator_bridge.py new file mode 100644 index 0000000..ab725a7 --- /dev/null +++ b/src/legaldown_render/validator_bridge.py @@ -0,0 +1,84 @@ +"""Everything the renderer takes from ``legaldown-validator`` beyond its +top-level public API, in one place. + +The renderer never re-derives a LegalDown rule (docs/decisions/0002): where +the validator has decided something — where a marker is placed, whether a +document is a template — the renderer asks it. Some of those answers are not +part of the validator's public API yet, so they are imported here from its +modules. This module is the complete list of such imports, the surface that +roadmap item U3 asks the validator to make public; the dependency is pinned +to one minor version until it does. +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import cache + +from legaldown import Document +from legaldown.definitions import block_fragments, text_fragments +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", "is_escaped", "lex", "placed_markers"] + + +@dataclass(frozen=True, slots=True) +class PlacedMarkers: + """The validator's reading of a document's markers (§5.7, §15.3).""" + + #: Whether the document is a template (§15.1), decided by the + #: validator's own formula. + template: bool + #: The placed markers, by (section index or None for the preamble, + #: block index, fragment index in block_fragments(block)). + placed: dict[tuple[int | None, int, int], FoundMarker] + #: The lexer, cached for this one render: each text is lexed once. + lex: Callable[[str], Lexed] + + +def placed_markers(document: Document) -> PlacedMarkers: + """Which markers the validator places, and whether *document* is a + template — the two decisions the validator makes together in + ``validate_document`` (legaldown.validator.core, §15.1).""" + lexer = cache(lex) + markers = find_markers(document, lexer) + template = _is_template(document, markers, lexer) + placed = { + (found.section, found.block, found.fragment): found + for found in markers + if found.marker is not None and found.placed(template) + } + return PlacedMarkers(template, placed, lexer) + + +def _is_template(document: Document, markers: list[FoundMarker], lexer: Callable[[str], Lexed]) -> bool: + """The validator's template test (legaldown.validator.core, + validate_document), over the validator's own findings. A copy, until the + validator exposes the decision (roadmap U3); the tests compare it with + the value validate_document itself computes, on every document.""" + metadata = document.metadata + + def names(texts: list[str]) -> set[str]: + return {directive.name for text in texts for directive in lexer(text or "").directives} + + return ( + metadata.questions is not None + or any(attachment.when for attachment in metadata.attachments) + or any(section.condition for section in document.sections) + or any(found.marker and found.marker.condition and not found.misplaced for found in markers) + or "choose" in names(_body_texts(document)) + or "choose" in names(_frontmatter_texts(document) + [section.title for section in document.sections]) + ) + + +def _body_texts(document: Document) -> list[str]: + return [text for _section, _index, block in document.iter_indexed_blocks() for text in text_fragments(block)] + + +def _frontmatter_texts(document: Document) -> list[str]: + structural, values = _frontmatter_fields(document.metadata) + return [text for _label, text in structural] + list(values) + diff --git a/src/legaldown_render/writers/__init__.py b/src/legaldown_render/writers/__init__.py new file mode 100644 index 0000000..a3d25b0 --- /dev/null +++ b/src/legaldown_render/writers/__init__.py @@ -0,0 +1,21 @@ +"""Writers: resolved tree → file format. A writer contains no LegalDown rules +(docs/decisions/0003); it only lays out what resolution computed.""" +from __future__ import annotations + +from .html import HtmlWriter, stylesheet +from .text import TextWriter + +#: Output formats by name, with the file extensions that select them. +FORMATS: dict[str, tuple[str, ...]] = { + "html": ("html", "htm"), + "text": ("txt", "text"), +} + + +def format_for_path(path: str) -> str | None: + """The output format a file name implies, or None.""" + extension = path.rsplit(".", 1)[-1].lower() if "." in path else "" + return next((name for name, extensions in FORMATS.items() if extension in extensions), None) + + +__all__ = ["FORMATS", "HtmlWriter", "TextWriter", "format_for_path", "stylesheet"] diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py new file mode 100644 index 0000000..d72f276 --- /dev/null +++ b/src/legaldown_render/writers/html.py @@ -0,0 +1,472 @@ +"""The HTML writer (§13.6). + +It writes one self-contained HTML5 file: semantic markup (``
``, +``
    ``, ````, ````) and a stylesheet generated from the style +template's presentation settings. Every label a reader sees — "4.2", "(b)" — +is real text in the markup rather than a CSS counter, so the numbers are the +ones resolution computed and survive copying and printing. + +Safety (docs/architecture.md): every text node and attribute is escaped, raw +HTML from the source never reaches this writer (§8.7), and a link whose URL +uses a scheme outside :data:`SAFE_SCHEMES` renders as plain text. +""" +from __future__ import annotations + +import re +from html import escape + +from ..style.model import Style +from ..tree import ( + AttachmentPart, + Blank, + Block, + Choice, + Code, + CodeBlock, + CrossRef, + DefinedTerm, + DraftingNote, + Emphasis, + FailureMarker, + HardBreak, + Image, + Inline, + Link, + List, + ListItem, + Paragraph, + Quote, + RenderTree, + Rule, + Section, + SignatureParty, + SoftBreak, + Strong, + Table, + TermRef, + Text, + Value, + plain_text, +) + +SAFE_SCHEMES = frozenset({"http", "https", "mailto", "tel"}) +_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):") +# What the WHATWG URL parser ignores before it reads a scheme: tabs and +# newlines anywhere, and C0 controls and spaces at either end. Checking the +# URL as a browser will read it means "java\tscript:" cannot slip through. +_URL_IGNORED_RE = re.compile(r"[\t\n\r]") +_URL_TRIMMED = "".join(chr(code) for code in range(0x21)) + + +def _text(value: str) -> str: + return escape(value, quote=False) + + +def _attr(value: str) -> str: + return escape(value, quote=True) + + +def is_safe_href(href: str) -> bool: + """True for relative URLs, fragments, and the schemes in SAFE_SCHEMES, + judged as a browser parses the URL.""" + scheme = _SCHEME_RE.match(_URL_IGNORED_RE.sub("", href).strip(_URL_TRIMMED)) + return scheme is None or scheme.group(1).lower() in SAFE_SCHEMES + + +class HtmlWriter: + """Writes a resolved tree as HTML.""" + + extension = "html" + media_type = "text/html" + + def __init__(self, style: Style, *, standalone: bool = True, generator: str = "") -> None: + self.style = style + self.standalone = standalone + self.generator = generator + # Inside a link, references render as plain spans: HTML forbids + # nested links. + self._in_link = False + + # -- document ------------------------------------------------------------- + + 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] + parts.append("
    ") + parts += self.sections(tree.sections) + if tree.attachments: + parts.append(self.attachments(tree)) + if tree.signatures: + 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: + return body + "\n" + generator = f'\n' if self.generator else "" + return ( + "\n" + f'\n\n\n' + '\n' + f"{_text(plain_text(tree.title))}{generator}\n" + f"\n\n\n{body}\n\n\n" + ) + + def title_block(self, tree: RenderTree) -> str: + parts = ['
    ', f'

    {self.inlines(tree.title)}

    '] + if tree.subtitle: + parts.append(f'

    {self.inlines(tree.subtitle)}

    ') + if tree.header: + parts.append('
    ') + for label, value in tree.header: + parts.append(f"
    {_text(label)}
    {self.inlines(value)}
    ") + parts.append("
    ") + if tree.sides: + parts.append('
    ') + for side in tree.sides: + parts.append(f'
    \n

    {self.inlines(side.label)}

    ') + for party in side.parties: + parts.append(f'
    \n

    {self.inlines(party.name)}

    ') + if party.details: + parts.append('
    ') + for label, value in party.details: + parts.append(f"
    {_text(label)}
    {self.inlines(value)}
    ") + parts.append("
    ") + parts.append("
    ") + parts.append("
    ") + parts.append("
    ") + parts.append("
    ") + return "\n".join(parts) + + def contents(self, tree: RenderTree) -> str: + """The table of contents: one link per entry, indented by level.""" + label = tree.contents_label + parts = [f'