diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md new file mode 100644 index 0000000..aa0adc4 --- /dev/null +++ b/.github/PUBLISHING.md @@ -0,0 +1,81 @@ +# Publishing (maintainers) + +Release mechanics for this repository. Nothing here is needed to *use* the +renderer — see the [README](../README.md) for that. + +`legaldown-render` is published to PyPI by the [`publish`](workflows/publish.yml) +workflow using **Trusted Publishing** (OIDC). No API token is stored in this repository — PyPI +verifies the identity of the workflow run itself. + +## One-time PyPI setup + +This must exist before the first publish, and it must be done by a PyPI account that will own the +project. Because the project does not exist on PyPI yet, register it as a **pending** publisher. + +1. Sign in to → **Your account** → **Publishing** → + *Add a new pending publisher*. +2. Fill in exactly: + + | Field | Value | + |---|---| + | PyPI project name | `legaldown-render` | + | Owner | `ForLegalAI` | + | Repository name | `legaldown-render` | + | Workflow name | `publish.yml` | + | Environment name | `pypi` | + +3. Repeat on with environment name `testpypi` if you want the dry-run path. + +The environment names must match, or PyPI rejects the upload. + +## One-time GitHub setup + +Create the two environments under **Settings → Environments**: + +| Environment | Purpose | Suggested protection | +|---|---|---| +| `pypi` | Real releases | Required reviewers, and restrict to the `main` branch / tags | +| `testpypi` | Dry runs | None needed | + +Adding a required reviewer to `pypi` means a human approves each upload — worth it, since a PyPI +version number can never be reused once published. + +## Cutting a release + +1. Bump `__version__` in [`src/legaldown_render/__init__.py`](../src/legaldown_render/__init__.py) — it is the single + source of truth; `pyproject.toml` reads it via `[tool.hatch.version]`. +2. Commit, then publish a GitHub Release with the tag `vX.Y.Z`. Its notes are where this + project records what changed in a release. + +The workflow builds the sdist and wheel, runs `twine check --strict`, **verifies the tag matches +`__version__`** (a mismatch fails the run rather than publishing the wrong version), smoke-tests the +built wheel by installing it and invoking the CLI, and uploads to PyPI. + +## Dry run + +Before a first real release, exercise the whole path against TestPyPI: + +``` +Actions → Publish → Run workflow → target: testpypi +``` + +Then verify the result installs cleanly: + +```bash +pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ legaldown-render +legaldown-render --version +``` + +The extra index is needed because the dependencies (`legaldown-validator`, Babel, PyYAML, +markdown-it-py) come from PyPI. + +## Notes + +- **Version numbers are permanent.** PyPI does not allow re-uploading a version, even after + deletion. Use TestPyPI, or a `.devN`/`.rcN` suffix, to rehearse. +- The distribution is named `legaldown-render`; the import name is `legaldown_render` + (`pip install legaldown-render` → `import legaldown_render`). +- A release of `legaldown-validator` with a new minor version needs a renderer release too: the + dependency is pinned to one minor version (`pyproject.toml`). +- The package ships a `py.typed` marker, so type checkers use its annotations directly. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..42f9a3d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,120 @@ +name: Publish + +# Publishes the distribution to PyPI via Trusted Publishing (OIDC) — no API +# token is stored in this repository. See .github/PUBLISHING.md for the one-time PyPI +# setup that must exist before the first run. +# +# Triggers: +# - publishing a GitHub Release → build, verify, upload to PyPI +# - manual dispatch → build, verify, upload to TestPyPI + +on: + release: + types: [published] + workflow_dispatch: + inputs: + target: + description: "Where to upload the built distribution" + required: true + default: testpypi + type: choice + options: [testpypi, pypi] + +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build and verify distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build tooling + run: pip install build twine + + - name: Build sdist and wheel + run: python -m build + + - name: Check package metadata renders on PyPI + run: twine check --strict dist/* + + - name: Verify the version matches the release tag + if: github.event_name == 'release' + env: + TAG: ${{ github.event.release.tag_name }} + run: | + python - <<'PY' + import os, pathlib, re, sys + + tag = os.environ["TAG"].lstrip("v") + init = pathlib.Path("src/legaldown_render/__init__.py").read_text(encoding="utf-8") + version = re.search(r'__version__ = "([^"]+)"', init).group(1) + if version != tag: + sys.exit( + f"::error::Release tag {tag!r} does not match __version__ {version!r}. " + "Bump src/legaldown_render/__init__.py before tagging." + ) + print(f"version {version} matches tag {tag}") + PY + + - name: Smoke-test the built wheel + run: | + python -m venv /tmp/smoke + /tmp/smoke/bin/pip install --quiet dist/*.whl + /tmp/smoke/bin/legaldown-render --version + /tmp/smoke/bin/python -c "import legaldown_render as r; print(r.__version__, r.SPEC_VERSION, r.CONFORMANCE_LEVEL)" + # A real render: the built-in styles, labels, and locale data are in the wheel. + printf -- '---\ntitle: T\nlanguage: cs\n---\n\n# A\n\nText {{date: 2026-07-01}}.\n' > /tmp/smoke/t.lgd + /tmp/smoke/bin/legaldown-render /tmp/smoke/t.lgd -f text --style continental -o /tmp/smoke/t.txt + cat /tmp/smoke/t.txt + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + testpypi: + name: Upload to TestPyPI + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/legaldown-render + permissions: + id-token: write # required for Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi: + name: Upload to PyPI + needs: build + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/legaldown-render + permissions: + id-token: write # required for Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONFORMANCE.md b/CONFORMANCE.md index bf98253..e1c369f 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -1,7 +1,7 @@ # 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 +`legaldown-render` 0.2.0 targets **Level 2 — Rendering** of the LegalDown specification **0.2** +(§17.3). Core parsing and validation come from `legaldown-validator` 0.3.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. @@ -20,7 +20,9 @@ It is checked against the specification's own examples and fixtures corpus (`tes | `{{include:}}` below Full (§17.5) | ✅ Renders `[NOT PROCESSED: include path]` with a Warning (`render-not-processed`) | | Party and side display rules (§3.6) | ✅ | | At least one output format (§13.6) | ✅ HTML (recommended) and plain text (optional) | -| Comment stripping (§8.6) | ✅ Including a comment across blank lines, up to the end of its section (see below) | +| A heading in a list item or a quote | ✅ Shown as a bold line: it is not a section (§4.1), so it has no number or anchor | +| Comment stripping (§8.6) | ✅ Inline comments, and comment blocks across any number of lines. As in CommonMark, a comment block left unclosed runs to the end of the document | +| Templates with answers (§15.8) | ✅ Assembled first with `legaldown-validator`'s Assembly capability (§15.7), then rendered: `answers=` / `--answers answers.yaml`. A template that needs other files is refused (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 | @@ -28,52 +30,50 @@ It is checked against the specification's own examples and fixtures corpus (`tes | 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) | +| List enumeration (§13.2) | ✅ Configurable per depth; can be disabled. Ordered lists are always renumbered. Nested lists keep their structure, so a nested item is referenced as "2.1(b)(i)" | | 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) | +| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ Nothing is emitted: an HTML block is dropped whole, and an inline tag is dropped with its text kept. The Warning is the validator's, once per block or text that holds raw HTML other than comments | | 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 | +| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning | | 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. +- **Assembly reads no other files.** A template with include fragments, LegalDown attachment + files, or `translations` is refused when rendered with answers, as §17.6 requires below Full. + A template with Errors is refused too, since §15.7.2 defines no output for one. +- **Line numbers.** The validator's diagnostics name their line, and the CLI prints it. The + renderer's own (`render-ref-ambiguous`, `ref-not-enumerated`, `render-not-processed`) do not + yet ([validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)). +- **Nesting limit.** The validator reads lists 64 levels deep and quotes 16; deeper, an item's or + quote's content is its text. The renderer follows it, and refuses (`DocumentError`) a document + whose lists, quotes, and inline formatting (emphasis, links) together nest more than 100 levels, + while building it and before resolving or writing it, so no document can exhaust the stack. +- **Ambiguous references.** Each list starts again at its first number, as contracts are usually + drafted, so two lists in one section can both have an item "(a)", and a style can number a + paragraph or an item like a subsection ("1.1"). A `{{ref:}}` to a section, paragraph, or list + item whose designation another of them also reads as renders with a `render-ref-ambiguous` + Warning. It is a hint: it is left out when the two can never appear together (alternatives, + exclusive conditions), but the condition the reference itself stands under is not considered. + Lists in quotes and drafting notes are not counted, and a second use of an identifier is left + to the validator's `anchor-duplicate`. +- **Labels under the `none` scheme.** A style label that shows `{section}` (such as the default + paragraph label `{section}.{n}`) shows the section's heading text there, while `{{ref:}}` + writes the §13.3 designation ("Termination 1"), so the two can differ. - **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)). + structure renders as the validator's model holds it, until that model keeps more (roadmap + U1b): + - **Link reference definitions** (`[label]: url`) render as paragraphs, and the links that use + them as text ([#92](https://github.com/ForLegalAI/legaldown-validator/issues/92)). ## Beyond this level (Full, §17.4) diff --git a/README.md b/README.md index fdd7d0e..3cb878a 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ gives "1. června 2026", "10 000,00 Kč", and "12 měsíců", and amounts are ne 🎨 **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). +📝 **Shows templates as templates, or fills them in.** Without answers, conditional clauses are +marked, every `{{choose:}}` phrase is shown, drafting notes are styled, and placeholders render as +blanks. With an answers set, the template is assembled and the finished document rendered (§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 @@ -44,10 +45,8 @@ 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" +pip install legaldown-render ``` Python 3.11 or newer. The dependencies are all pure Python: `legaldown-validator`, @@ -63,12 +62,14 @@ legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumera 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 template.lgd --answers answers.yaml -o nda.html # fill in a template, then render it 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. +rendered, even if it has errors (they show as markers), 1 when rendering was refused (strict mode, +or a template that cannot be assembled with its answers) or a file cannot be read, and 2 for an +invalid style or setting. ## Python @@ -81,8 +82,9 @@ for diagnostic in result.diagnostics: 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 +`render()` also accepts a `RenderOptions`. Pass `answers={...}` to render a template filled in. +Problems in the document never raise. Only an invalid style (`StyleError`), an unreadable document +(`DocumentError`), strict mode, or a template that cannot be assembled with its answers (`RenderRefused`) do. ## Styles and preferences @@ -101,8 +103,8 @@ 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 +**Render options** say what one job does: the output format, strict mode, the final check, the +answers to a template, 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). @@ -130,6 +132,8 @@ LEGALDOWN_SPEC_DIR=../LegalDown pytest -m conformance # the specification's ex ruff check . ``` +Releases go to PyPI from a GitHub Release; see [PUBLISHING.md](.github/PUBLISHING.md). + ## License [MIT](LICENSE) diff --git a/docs/architecture.md b/docs/architecture.md index d8c26f7..0d227ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,10 @@ This page describes how the renderer is built. The decisions behind it are in │ normalize_source(): strip BOM, unify line endings ▼ ┌─────────────────────────┐ + │ 2. Assemble (answers) │ only with an answers set: legaldown-validator's assemble() turns + └───────────┬─────────────┘ the template into its assembled document, which the rest renders + ▼ + ┌─────────────────────────┐ │ 1. Parse & validate │ legaldown-validator: Document, ValidationResult (Core diagnostics, └───────────┬─────────────┘ resolved identifiers, party/side/definition/attachment lookups) ▼ @@ -31,9 +35,20 @@ This page describes how the renderer is built. The decisions behind it are in 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). +Stage 2, **assembly** with an answers set (§15.7), runs only when the job has answers, before +the document is parsed for rendering. A template rendered with answers is assembled first and the +assembled document rendered; without answers, it renders as its **template view** (§15.8). +Assembly is the core package's (`legaldown.assemble`), exact to the byte as §15.7.2 requires; the +renderer only decides when to refuse: + +- the template has Errors: §15.7.2 defines assembly only for a template without them +- the answers do (`answer-invalid`, `answer-missing`) +- the template needs other files (include fragments, LegalDown attachment files, translations), + which the renderer does not read below the Full level (§17.6) + +A refusal raises `RenderRefused` with the findings. Assembly Warnings, such as `answer-unknown`, +are reported with the rest. Blanks left unanswered stay, and render as blanks: the result is a +draft (§15.1), which `--final` reports. `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. @@ -62,14 +77,15 @@ 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()` | +| Blocks: paragraphs, lists and items, quotes, tables, code, rules | `Document` blocks. List items hold blocks (nested lists, code, quotes, tables), and a quote's content is the validator's `quote_content()` | +| Where a marker (`{#id when=…}`) is placed, and what it means | `ValidationResult.placed_markers`: each marker's block, field, offset, identifier, and condition | +| Whether the document is a template | `ValidationResult.is_template` | +| Whether a quote is a drafting note | The validator's `is_drafting_note()` | +| Which list item a marker belongs to | `PlacedMarker.item`: the item's number among the list's items, in pre-order | | 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. +The few imports beyond the validator's public API (quote content, fence helpers, the +answers-file reader) are all in `validator_bridge.py`, 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 @@ -82,28 +98,15 @@ that renders nothing, led by a punctuation character so that an emphasis closer still closes. A defined term in a link title or alt text shows its term but is not the definition's anchor. -HTML comments are dropped (§8.6), 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 +An HTML block in the model (`kind="html"`), a comment block included, renders nothing (§8.6). +Within a block's text, inline comments and tags are dropped; where one stood between two spaces, +one space stays. The validator reports raw HTML other than comments (`raw-html`, §8.7). A heading +in a list item or a quote (`kind="heading"`) is not a section (§4.1) and shows as a bold line. + +The builder never works around the validator's model. Where the model loses or misreads +something, the output follows the model, and the gap is listed in `CONFORMANCE.md` and filed on +the validator: +- link reference definitions (`[label]: url`) render as paragraphs Guessing at lost structure was tried, and it traded each gap for new bugs. @@ -113,10 +116,11 @@ Guessing at lost structure was tried, and it traded each gap for new bugs. 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 +2. **Structure.** Section numbers are the validator's own (`ValidationResult.sections`), so a + rendered number always matches the validator's: **alternatives** share a number (§15.8), and + a skipped heading level counts as 1. The style only formats them, the n-th part of a number + with the n-th level format. For paragraphs and list items, **alternatives** follow the + validator's rule, 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 @@ -164,7 +168,7 @@ clamped to 1–5, as the validator numbers them. | 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 | +| `TextWriter` | Plain text (§13.6). A soft line break inside a paragraph is a space, a hard break a new line, 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)) | @@ -194,19 +198,18 @@ src/legaldown_render/ | Dependency | Why | |---|---| -| `legaldown-validator>=0.2.0,<0.3` | The only LegalDown parser; Core validation | +| `legaldown-validator>=0.3.0,<0.4` | 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. +The renderer uses the validator's public API (`legaldown`, `legaldown.validator`), except for +the few names in `validator_bridge.py`: `quote_content` and `MAX_QUOTE_DEPTH`, the fence +helpers, and the answers-file reader. The pin to one minor version exists because of them. +Asking the validator to export them publicly is roadmap item U3 +([validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)). ## Errors and diagnostics @@ -217,9 +220,10 @@ validator to export them publicly is roadmap item U3. - **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. +`ref-not-enumerated`, a specification rule id, plus renderer-specific ids prefixed `render-`: +`render-not-processed`, `render-locale-fallback`, and `render-ref-ambiguous`. The validator's +diagnostics carry their line (§16.9), and the CLI prints it as `file:line:`. The renderer's own +have none yet, because a block's line is not public in the validator (U2). ## Security diff --git a/docs/concepts.md b/docs/concepts.md index 9582ba4..e3492ee 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -44,7 +44,8 @@ At Rendering level (§17.3) the renderer MUST: - 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) +- render the template view when a template is rendered without answers, and assemble it first + when it is rendered with 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. diff --git a/docs/decisions/0007-one-parser-validator-model.md b/docs/decisions/0007-one-parser-validator-model.md index d135fca..1463f4b 100644 --- a/docs/decisions/0007-one-parser-validator-model.md +++ b/docs/decisions/0007-one-parser-validator-model.md @@ -29,8 +29,9 @@ 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. +- **Markers:** placed exactly where the validator places them. Since validator 0.3.0 the + renderer reads them from `ValidationResult.placed_markers`. +- **Template decision:** the validator's own, `ValidationResult.is_template`. - **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, @@ -53,9 +54,13 @@ module, `validator_bridge.py`. That module is the list for roadmap item U3. - a paragraph's line breaks are joined - constructs the validator reads unusually, such as a heading inside an HTML comment, render as the validator reads them -- One deliberate exception: a comment left open in a paragraph runs on to its `-->` across the - following blocks, because §8.6 requires every comment to be stripped. It never crosses a - section boundary (docs/architecture.md, stage 3). +- One deliberate exception was added later: a comment left open in a paragraph ran on to its + `-->` across the following blocks, because §8.6 requires every comment to be stripped. It was + removed once the validator's model kept HTML blocks (validator #23), which is the path this + decision expects: the fix lands in the validator, and the renderer drops its workaround. + Table alignment came with the same change. - 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. + own. The resolver and writers already handle nested lists. This happened with validator #61: + the builder turns its listed items and their depths into nested lists, and "2.1(b)(i)" + replaced "2.1(c)". - The build stage is smaller: about 100 lines of position rules and special cases are gone. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e29bb6..04468b4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,21 +10,21 @@ 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` | +| U1 | validator | **Keep nested list structure** in the model ([#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)) | Done in [validator#61](https://github.com/ForLegalAI/legaldown-validator/pull/61); the renderer nests lists from it (v0.2) | +| U2 | validator | **Source positions** (line numbers) on sections, blocks, and diagnostics ([#27](https://github.com/ForLegalAI/legaldown-validator/issues/27)) | Done for the validator's diagnostics in 0.3.0 ([validator#85](https://github.com/ForLegalAI/legaldown-validator/pull/85)); the CLI prints them. Still open: a block's line, for the renderer's own diagnostics ([#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)) | +| U3 | validator | **Public API** ([#26](https://github.com/ForLegalAI/legaldown-validator/issues/26)) for what the renderer imports from submodules, and the validator's own **placed markers** and **template decision** | Mostly done in 0.3.0 ([validator#90](https://github.com/ForLegalAI/legaldown-validator/pull/90)): the renderer reads `placed_markers` and `is_template` from the result. Left in `validator_bridge.py`: `quote_content`, the fence helpers, the answers-file reader ([#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)), and a drafting note's content ([#88](https://github.com/ForLegalAI/legaldown-validator/issues/88)). Until then, the dependency is pinned to one minor version | +| U1b | validator | **Parser gaps** the renderer inherits. Fixed in validator `main`: list markers (#21), tables (#22, #44), comments and HTML blocks (#23, #59), the signature-block cutoff (#24), empty comments (#28), indented code (#9, #41), empty list items (#46), content after a nested list (#64), nested marker changes (#65), headings in quotes and items (#78), raw-html (#40). hard breaks (#25). Still open: link reference definitions ([#92](https://github.com/ForLegalAI/legaldown-validator/issues/92)) | Mostly done; adopted with 0.3.0. 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 | +| U4b | validator | Template **assembly** with an answers set (§15.7, [#30](https://github.com/ForLegalAI/legaldown-validator/issues/30)) | Done in [validator#55](https://github.com/ForLegalAI/legaldown-validator/pull/55); the renderer uses it (v0.2) | | 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) +### v0.1 — HTML and text, specification 0.2 - 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 + with designations ("2.1(b)") - Every directive, with every failure marker - Template view: conditions, `{{choose:}}`, drafting notes, alternatives sharing a number - Title block, attachment placeholders, signature blocks @@ -34,10 +34,14 @@ here. - `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 +### v0.2 — Templates with answers (this release) - Rendering with an answers set, through the core package's assembly (U4b) - `--answers answers.yaml` on the CLI +- Nested lists, with designations such as "2.1(b)(i)" (U1) +- The validator's CommonMark block model: headings in items and quotes, hard line breaks, and + diagnostics with line numbers +- Built on `legaldown-validator` 0.3.0; the first release on PyPI ### v0.3 — DOCX diff --git a/docs/style-templates.md b/docs/style-templates.md index 75c1b91..88ee6f5 100644 --- a/docs/style-templates.md +++ b/docs/style-templates.md @@ -72,7 +72,7 @@ locale: cs-CZ # formatting locale (§10.1); unset: the document's numbering: # §13.1 scheme: decimal # decimal | legal-outline | mixed | none - levels: # optional; overrides the scheme level by level from level 1 + levels: # optional; overrides the scheme by number part: the first format for "2", the second for "2.1" - { counter: upper-roman, label: "Article {n}", ref: "{n}" } enumeration: # §13.2 diff --git a/pyproject.toml b/pyproject.toml index 6c9fc6e..6394fe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ 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", + "legaldown-validator>=0.3.0,<0.4", # 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", diff --git a/src/legaldown_render/__init__.py b/src/legaldown_render/__init__.py index 48adf90..2342fc3 100644 --- a/src/legaldown_render/__init__.py +++ b/src/legaldown_render/__init__.py @@ -21,7 +21,7 @@ from .errors import DocumentError, InternalError, RenderError, RenderRefused from .style import Style, StyleError, builtin_styles, dump_style, load_style -__version__ = "0.1.0" +__version__ = "0.2.0" #: The LegalDown specification version this renderer targets. SPEC_VERSION = "0.2" diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py index ba3c542..e58ac79 100644 --- a/src/legaldown_render/api.py +++ b/src/legaldown_render/api.py @@ -6,7 +6,8 @@ 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 + mode, the final check, the answers to a template, 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 @@ -17,7 +18,7 @@ from typing import Any import yaml -from legaldown import Diagnostic, Document, ValidationResult, parse_document, validate_document +from legaldown import AssemblyError, Diagnostic, Document, ValidationResult, assemble, parse_document, validate_document from .build import build_tree, normalize_source from .errors import DocumentError, RenderRefused @@ -47,6 +48,11 @@ class RenderOptions: #: (``template-construct-present``) is an Error. With ``strict``, such a #: document is refused. final: bool = False + #: An answers set (§15.7.1): question id to answer, as YAML or JSON + #: would load it. With answers, a template is assembled first and the + #: assembled document is rendered (§15.8); without, a template renders + #: as its template view. + answers: Mapping[str, Any] | None = None #: HTML only: a complete page (True) or just the ``
`` (False). standalone: bool = True @@ -80,7 +86,7 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any 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. + errors, or when a template cannot be assembled with ``answers``. """ if options is None: options = RenderOptions(**settings) @@ -94,9 +100,12 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any style = load_style(options.style, overrides=overrides) source = normalize_source(source) + assembled: list[Diagnostic] = [] + if options.answers is not None: + source, assembled = _assemble(source, options.answers) document = _parse(source) result = validate_document(document, final=options.final) - diagnostics = list(result.diagnostics) + diagnostics = assembled + list(result.diagnostics) if options.strict and any(d.level == "error" for d in diagnostics): raise RenderRefused(diagnostics) @@ -115,6 +124,30 @@ def render_file(path: str | Path, options: RenderOptions | None = None, /, **set return render(Path(path).read_text(encoding="utf-8"), options, **settings) +def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagnostic]]: + """*source* assembled with *answers* (§15.7), and the assembly's + Warnings (such as ``answer-unknown``). Raises RenderRefused when it + cannot be assembled: the template has Errors, for which §15.7.2 defines + no output, or the answers do (``answer-invalid``, ``answer-missing``), + or the template needs files a renderer below Full does not read + (includes, LegalDown attachments, translations; §17.6).""" + findings = validate_document(_parse(source)).diagnostics + errors = sum(1 for d in findings if d.level == "error") + if errors: + raise RenderRefused(findings, f"Assembly refused: the template has {errors} error(s), " + "and only a template without errors can be assembled (§15.7.2).") + try: + result = assemble(source, answers) + except AssemblyError as error: + raise DocumentError(f"The template cannot be assembled: {error}") from error + if not result.ok: + errors = [d for d in result.diagnostics if d.level == "error"] + raise RenderRefused(result.diagnostics, f"Assembly refused: {len(errors)} error(s) in the answers or " + "the template (§15.7).") + # The source was normalized, and assembly keeps its line breaks. + return result.output, list(result.diagnostics) + + def _parse(source: str) -> Document: try: return parse_document(source) @@ -124,14 +157,8 @@ def _parse(source: str) -> Document: 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).", - )) + # Raw HTML is never emitted; the validator reports it (raw-html, §8.7). + tree = build_tree(document, result) 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)"]) diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py index f64154e..3a90891 100644 --- a/src/legaldown_render/build.py +++ b/src/legaldown_render/build.py @@ -2,10 +2,10 @@ (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. +sections, their blocks, and each block's text, and its ``ValidationResult`` +says where markers are placed and whether the document is a template +(``placed_markers``, ``is_template``). 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 @@ -16,23 +16,36 @@ 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. +Lists and quotes are built as the validator's model holds them: list items +hold blocks, nested lists included, and a quote's content is the blocks the +validator reads in it (ForLegalAI/legaldown-validator#71, #72). """ from __future__ import annotations import re import secrets +from collections.abc import Iterator from dataclasses import dataclass, replace +from functools import cache +from itertools import count 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 legaldown import ( + Directive, + Document, + PlacedMarker, + ValidationResult, + find_definition_anchors, + is_drafting_note, + lex, + list_items, + render_block, +) from markdown_it import MarkdownIt from markdown_it.tree import SyntaxTreeNode -from .errors import InternalError +from .errors import DocumentError, InternalError from .tree import ( Block, Code, @@ -57,23 +70,28 @@ Table, Text, ) -from .validator_bridge import block_fragments, block_quotes, is_escaped, lex, placed_markers +from .validator_bridge import FENCE_OPEN_RE, MAX_QUOTE_DEPTH, closes_fence, dedent, indent_width, quote_content _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) +#: How deep lists, quotes, and inline formatting (emphasis, links) may nest, +#: together. Far beyond any real document, and far enough below Python's +#: recursion limit that building, resolving, and writing the tree never +#: reach it. The builder refuses a deeper document as it reaches the limit, +#: before resolving or writing it. (The validator caps lists at 64 levels +#: and quotes at 16 itself.) +MAX_NESTING = 100 +# A drafting note's first line (§15.6). Which quotes are drafting notes is +# the validator's decision (is_drafting_note); this text is taken off. +DRAFTING_MARKER = "[!DRAFTING]" def normalize_source(source: str) -> str: @@ -156,10 +174,14 @@ def __init__(self, document: Document, result: ValidationResult) -> None: 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) + # The markers the validator placed, by (section index or None for + # the preamble, block index). + self.placed: dict[tuple[int | None, int], list[PlacedMarker]] = {} + for marker in result.placed_markers: + self.placed.setdefault((marker.section, marker.block), []).append(marker) + # Each text is lexed once in a render. + self.lex = cache(lex) + self.depth = 0 # quotes and list items the builder is inside 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. @@ -183,7 +205,7 @@ def _protect(self, body: str) -> str: 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) + lexed = self.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: @@ -255,17 +277,14 @@ def _restore_url(self, url: str) -> str: def text(self, source: str) -> tuple[Inline, ...]: """Inline nodes for *source*, one block's text as the validator holds it: directives protected, then parsed as inline Markdown.""" - self._dropped_html = False - inlines = _trim(self.inlines(self._protect(source))) - if self._dropped_html: - self.raw_html += 1 - return inlines + return _trim(self.inlines(self._protect(source))) 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 () + self._check_depth(max(token.level for token in tokens[0].children)) root = SyntaxTreeNode(tokens[0].children, create_root=True) return self._inline_children(root) @@ -298,7 +317,6 @@ def _inline(self, node: SyntaxTreeNode) -> list[Inline]: 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*.""" + validator reads inside a quote or an item — no marker is placed.""" 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 block.kind == "html": + continue # never emitted: a comment (§8.6) or raw HTML (§8.7) + placed = self.placed.get((section, index), []) if markers else [] + built = self.block(block, placed, top_level=top_level) 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 list(self, block: ModelBlock, placed: list[PlacedMarker]) -> List: + """A list, nested as the validator's model holds it: items that hold + blocks. A placed marker names the item it marks by its pre-order + number among all the list's items, as the validator numbers them.""" + markers = {marker.item: marker for marker in placed} + return self._list(block, markers, count()) + + def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numbers: Iterator[int]) -> List: + """*block*'s items, numbered from *numbers* in pre-order, to find the + items *markers* belong to.""" + self._check_depth(1) + self.depth += 1 + try: + items: list[ListItem] = [] + for item in list_items(block): + marker = markers.get(next(numbers)) + children = list(item.blocks) + identifier = condition = "" + if marker is not None: + identifier, condition = marker.identifier, marker.condition + children[0] = replace(children[0], text=_cut_marker(children[0].text, marker)) + blocks: list[Block] = [] + for child in children: + if child.kind in ("ordered_list", "unordered_list"): + blocks.append(self._list(child, markers, numbers)) + elif child.kind == "html": + continue # never emitted (§8.6, §8.7) + elif (built := self.block(child, [], top_level=False)) is not None: + blocks.append(built) + items.append(ListItem(blocks=tuple(blocks), anchor_id=identifier, condition=condition)) + finally: + self.depth -= 1 + return List(ordered=block.kind == "ordered_list", items=tuple(items)) + + def _check_depth(self, more: int) -> None: + """Refuse a document that nests lists, quotes, and inline formatting + deeper than MAX_NESTING, *more* levels below where the builder is.""" + if self.depth + more > MAX_NESTING: + raise DocumentError(f"The document nests lists, quotes, and inline formatting more than " + f"{MAX_NESTING} levels deep, which is not rendered.") 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) + """A block quote, its content the blocks the validator reads in it + (``quote_content``). Past the validator's quote depth, it reads the + quote's text as one, and so does the builder. A drafting note is + decided by the validator's own test (§15.6); its ``[!DRAFTING]`` + marker, which starts its first paragraph or heading in the + validator's reading, is not shown.""" + drafting = is_drafting_note(block) + self._check_depth(1) + self.depth += 1 + try: + if self.depth <= MAX_QUOTE_DEPTH: + children = list(quote_content(block.text, self.depth)[0]) + if drafting: + unmarked = _without_drafting_marker(children) + # When the marker does not start the first paragraph or + # heading (an indented marker line reads as code), the + # note is the text after its marker line. + children = unmarked if unmarked is not None else list( + quote_content(block.text.partition("\n")[2], self.depth)[0]) + blocks = self.blocks(children, None, markers=False) + else: + text = block.text.partition("\n")[2] if drafting else block.text + blocks = (Paragraph(self.text(text)),) if text.strip() else () + finally: + self.depth -= 1 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: @@ -519,33 +483,44 @@ def tree(self) -> RenderTree: language=self.language, preamble=self.blocks(document.preamble, None), sections=sections, - is_template=self.markers.template, + is_template=self.result.is_template, ) -def _strip_markers(block: ModelBlock, placed: dict[int, tuple[int, str, str]]) -> tuple[ModelBlock, str, str]: +def _without_drafting_marker(children: list[ModelBlock]) -> list[ModelBlock] | None: + """A drafting note's blocks, as the validator reads them, without the + ``[!DRAFTING]`` marker that starts the first of them (its first line is + the marker, §15.6); a block that held only the marker goes. None when + the marker does not start a first paragraph or heading. To be replaced by + the validator's own reading (ForLegalAI/legaldown-validator#88).""" + first = children[0] if children else None + if first is None or first.kind not in ("paragraph", "heading") \ + or not first.text.upper().startswith(DRAFTING_MARKER): + return None + rest = first.text[len(DRAFTING_MARKER):].lstrip() + return ([replace(first, text=rest)] if rest else []) + children[1:] + + +def _strip_markers(block: ModelBlock, placed: list[PlacedMarker]) -> 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)] + condition. The validator says which field holds the marker.""" 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 + for marker in placed: + block = replace(block, **{marker.field: _cut_marker(getattr(block, marker.field), marker)}) + identifier, condition = marker.identifier, 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 +def _cut_marker(fragment: str, marker: PlacedMarker) -> str: + """*fragment* without *marker*, which ends it; a comment after the + marker stays (it renders nothing).""" + end = marker.offset + len(marker.source) + if fragment[marker.offset:end] != marker.source: + raise InternalError(f"the validator placed '{marker.source}' where the renderer cannot find it") # 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 + before, after = fragment[:marker.offset].rstrip(), fragment[end:].strip() + return f"{before} {after}" if after else before def _paragraph_source(block: ModelBlock) -> str: @@ -556,16 +531,20 @@ def _paragraph_source(block: ModelBlock) -> str: def _code_block(text: str) -> CodeBlock: - """A fenced code block from the validator's model, fences included.""" + """A code block from the validator's model, read by the validator's + CommonMark rules: an indented block loses four columns from each line; + a fenced one loses its fences, and from each line as much indentation + as its opening fence had.""" lines = text.split("\n") - opening = _FENCE_RE.match(lines[0]) if lines else None + opening = FENCE_OPEN_RE.match(lines[0]) if lines else None if opening is None: - return CodeBlock(text) - fence = opening.group(1) + return CodeBlock("\n".join(dedent(line, 4) for line in lines) + "\n") body = lines[1:] - if body and body[-1].strip().startswith(fence[0] * len(fence)) and not body[-1].strip().strip(fence[0]): + if body and closes_fence(body[-1], opening.group("fence")): body = body[:-1] - return CodeBlock("\n".join(body) + ("\n" if body else ""), opening.group(2).strip()) + indent = indent_width(lines[0]) + body = [dedent(line, indent) for line in body] + return CodeBlock("\n".join(body) + ("\n" if body else ""), lines[0][opening.end():].strip()) def _trim(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: @@ -610,12 +589,9 @@ def _merge_text(inlines: list[Inline]) -> tuple[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 build_tree(document: Document, result: ValidationResult) -> RenderTree: + """The render tree for *document*.""" + return _Builder(document, result).tree() def plain_inlines(text: str) -> tuple[Inline, ...]: diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py index a572acc..1931fdd 100644 --- a/src/legaldown_render/cli.py +++ b/src/legaldown_render/cli.py @@ -16,6 +16,7 @@ from .api import RenderOptions, render from .errors import DocumentError, InternalError, RenderRefused from .style import StyleError, builtin_styles, dump_style, load_style, parse_override +from .validator_bridge import read_answers from .writers import FORMATS, format_for_path @@ -47,6 +48,9 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--locale", help="formatting locale, e.g. en-US or cs-CZ (default: the style's, else the document language)") parser.add_argument("--fragment", action="store_true", help="HTML: write only the
, without page and stylesheet") parser.add_argument("--strict", action="store_true", help="refuse to render a document that has errors") + parser.add_argument("--answers", metavar="FILE", + help="a YAML or JSON answers set (§15.7.1): the template is assembled with it and the " + "assembled document rendered") 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") @@ -86,8 +90,16 @@ def main(argv: list[str] | None = None) -> int: print(f"legaldown-render: cannot read {name}: {error.strerror or error}", file=sys.stderr) return 1 + answers = None + if args.answers: + # Read as legaldown-validator's `legaldown assemble` reads it (§15.7.1). + answers, failure = read_answers(Path(args.answers)) + if failure: + print(f"legaldown-render: {failure}", 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) + strict=args.strict, final=args.final, answers=answers, standalone=not args.fragment) try: result = render(source, options) except StyleError as error: @@ -116,7 +128,9 @@ 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) + # As the validator's CLI prints them: the line when the finding has one. + where = f"{name}:{diagnostic.line}" if diagnostic.line else name + print(f"{where}: {diagnostic.level}: [{diagnostic.rule}] {diagnostic.message}", file=sys.stderr) if __name__ == "__main__": diff --git a/src/legaldown_render/errors.py b/src/legaldown_render/errors.py index bb45ee7..5355828 100644 --- a/src/legaldown_render/errors.py +++ b/src/legaldown_render/errors.py @@ -11,18 +11,19 @@ class RenderError(Exception): class DocumentError(RenderError): - """The document cannot be read at all, e.g. its frontmatter is not a - YAML mapping.""" + """The document cannot be read at all, e.g. its frontmatter is not + valid YAML.""" class RenderRefused(RenderError): - """Rendering was refused because the document has errors and strict - mode is on. ``diagnostics`` holds every finding.""" + """Rendering was refused: the document has errors and strict mode is on, + or a template could not be assembled with its answers (§15.7). + ``diagnostics`` holds every finding.""" - def __init__(self, diagnostics: list[Diagnostic]) -> None: + def __init__(self, diagnostics: list[Diagnostic], reason: str | None = None) -> 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).") + super().__init__(reason or f"Rendering refused in strict mode: the document has {errors} error(s).") class InternalError(RenderError): diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py index 4476984..105903f 100644 --- a/src/legaldown_render/resolve/resolver.py +++ b/src/legaldown_render/resolve/resolver.py @@ -15,20 +15,28 @@ 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). +style template or a renderer: ``ref-not-enumerated``, ``render-ref-ambiguous``, +and ``render-not-processed`` for 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 typing import Any, NamedTuple 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 legaldown.validator import ( + ALWAYS, + IDENTIFIER_RE, + KNOWN_CURRENCIES, + Presence, + condition_problem, + exclusive, + is_positive_numeric, + is_valid_iso_date, + is_valid_money_amount, + parse_condition, +) from ..build import plain_inlines from ..style import Style, effective_labels @@ -108,6 +116,8 @@ class _Target: #: 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 + #: The numbered unit the anchor is on, for render-ref-ambiguous. + slot: _Slot | None = None class Resolver: @@ -130,6 +140,9 @@ def __init__( self.diagnostics: list[Diagnostic] = [] self.targets: dict[str, _Target] = {} self.used_anchors: set[str] = set() + # For render-ref-ambiguous: every designation a numbered unit — a + # section, a paragraph, a list item — reads as, with each unit's slot. + self.designation_slots: dict[str, list[_Slot]] = {} questions = self.metadata.questions self.questions: dict[str, Any] = questions if isinstance(questions, dict) else {} self.inconsistent_placeholders: set[str] = set() @@ -200,10 +213,10 @@ def _contents( if not settings.enabled: return () entries = [ - ContentsEntry(section.depth, section.label, unlinked(section.title), section.anchor, + ContentsEntry(section.level, section.label, unlinked(section.title), section.anchor, section.condition_label) for section in sections - if section.depth <= settings.depth + if section.level <= settings.depth ] if settings.attachments and attachments: # Under the attachments heading, unless a style blanks its label. @@ -255,54 +268,43 @@ def _register(self, identifier: str, target: _Target) -> None: 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``. + The numbers are the validator's own (``ValidationResult.sections``), + so a rendered number and a reference to it always agree with the + validator: alternatives share a number (§15.8), and a skipped + heading level counts as 1. The style only formats them, the n-th + part of a number ("2.1" has two) with the n-th level format. A + section's rendered level is how many parts its number has, so its + number format, heading, and heading style always go together. """ 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. + for section, indexed in zip(self.tree.sections, self.result.sections, strict=True): + # The heading's own level, clamped to 1-5 (§4.1), decides which + # sections enclose it, and so its presence (§15.3). 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]) + parts = [int(part) for part in indexed.number.split(".")] 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) + for position, counter in enumerate(parts): + fmt = levels[min(position, len(levels) - 1)] + designation = extend(designation, fill(fmt.ref, n=format_counter(counter, fmt.counter)), + textual=False) + fmt = levels[min(len(parts), len(levels)) - 1] + label = fill(fmt.label, n=format_counter(parts[-1], 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)) + # Sections with one number are alternatives (the validator's). + slot = _Slot(_SECTIONS, indexed.number, presences[level], "section", section.identifier) + self._record(designation, slot) + self._register(section.identifier, _Target(designation, anchor, slot=slot)) + out.append(replace(section, level=min(len(parts), 5), label=label, designation=designation, + anchor=anchor)) return out def _presence(self, condition: str) -> Presence: @@ -348,8 +350,10 @@ def _structure_section(self, section: Section, presence: Presence) -> tuple[Bloc 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) + own = self._own_presence(block.condition, presence) + number = paragraphs.next(block.anchor_id, own) + block = self._number_paragraph(block, section, paragraphs, number, + presence if own is None else own) out.append(self._structure(block, section=section, depth=0, presence=presence)) return tuple(out) @@ -359,7 +363,8 @@ def _own_presence(self, condition: str, enclosing: Presence) -> Presence | None: own = self._presence(condition) return enclosing | own if own else None - def _number_paragraph(self, block: Paragraph, section: Section, number: int) -> Paragraph: + def _number_paragraph(self, block: Paragraph, section: Section, counter: _Counter, number: int, + presence: Presence) -> Paragraph: numbering = self.style.paragraphs base = section.designation or "" if numbering.numbered: @@ -367,7 +372,9 @@ def _number_paragraph(self, block: Paragraph, section: Section, number: int) -> 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) + slot = _Slot(counter, number, presence, "paragraph", block.anchor_id) + target = _Target(designation, None, slot=slot) + self._record(designation, slot) else: label, target = None, _Target(base, None, enumerated=False) anchor = self._anchor(block.anchor_id) @@ -375,22 +382,32 @@ def _number_paragraph(self, block: Paragraph, section: Section, number: int) -> 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: + def _structure(self, block: Block, *, section: Section | None, depth: int, presence: Presence, + quoted: bool = False) -> Block: + """*block* numbered and labelled. *quoted*: inside a quote, whose + items the validator does not treat as units (they hold no marker).""" match block: case List(): return self._structure_list(block, section=section, depth=depth, - parent=section.designation if section else "", presence=presence) + parent=section.designation if section else "", presence=presence, + quoted=quoted) case Quote(blocks=blocks): - return Quote(tuple(self._structure(child, section=section, depth=depth, presence=presence) - for child in blocks)) + return Quote(tuple(self._structure(child, section=section, depth=depth, presence=presence, + quoted=True) 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) + return DraftingNote(tuple(self._structure(child, section=section, depth=depth, presence=presence, + quoted=True) 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: + presence: Presence, counters: dict[tuple[str, str] | None, _Counter] | None = None, + quoted: bool = False) -> List: + """*block* numbered under *parent*. Sibling lists nested in the same + item share *counters*, one per way of writing a designation (counter + style and reference form): a second list whose designations would + read like the first's goes on from it, so none repeats.""" enumeration = self.style.enumeration fmt: LevelFormat | None if block.ordered and enumeration.ordered == "renumber": @@ -401,7 +418,12 @@ def _structure_list(self, block: List, *, section: Section | None, depth: int, p fmt = None base = section.designation if section else "" items: list[ListItem] = [] - counter = _Counter(self._exclusive) + if counters is None: + counter = _Counter(self._exclusive) + else: + key = (fmt.counter, fmt.ref) if fmt is not None else None + counter = counters.setdefault(key, _Counter(self._exclusive)) + counter.new_list() 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. @@ -411,15 +433,21 @@ def _structure_list(self, block: List, *, section: Section | None, depth: int, p 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) + if not quoted: + slot = _Slot(counter, index, inner, "item", item.anchor_id) + target = replace(target, slot=slot) + self._record(designation, slot) 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)) + siblings: dict[tuple[str, str] | None, _Counter] = {} children = tuple( - self._structure_list(child, section=section, depth=depth + 1, parent=designation, presence=inner) + self._structure_list(child, section=section, depth=depth + 1, parent=designation, presence=inner, + counters=siblings, quoted=quoted) if isinstance(child, List) - else self._structure(child, section=section, depth=depth + 1, presence=inner) + else self._structure(child, section=section, depth=depth + 1, presence=inner, quoted=quoted) for child in item.blocks ) items.append(replace(item, blocks=children, label=label, anchor=anchor)) @@ -508,9 +536,45 @@ def _ref(self, directive: Directive, target_id: str) -> Inline: 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).", ) + elif target.slot is not None and (clashes := self._clashes(target.slot, target.designation)): + self._warn("render-ref-ambiguous", self._ambiguity_message(target_id, target, clashes)) text = self.style.references.format.replace("{designation}", target.designation) return CrossRef(text, target.anchor or "") + def _record(self, designation: str, slot: _Slot) -> None: + """Note that a numbered unit reads as *designation* (render-ref-ambiguous).""" + self.designation_slots.setdefault(designation, []).append(slot) + + def _clashes(self, slot: _Slot, designation: str) -> list[_Slot]: + """The other numbered units that may read as *designation*, the one + *slot*'s unit reads as, in a document where both appear. Its own + alternatives share its number and are not others; a second use of + its identifier is left to the validator (anchor-duplicate). A hint, + not a proof: units under exclusive conditions never appear together + (§15.4), but where the reference itself stands is not considered.""" + slots = self.designation_slots.get(designation, []) + own = [other.presence for other in slots if other.key == slot.key] + return [ + other for other in slots + if other.key != slot.key and not (slot.anchor and other.anchor == slot.anchor) + and any(not self._exclusive(presence, other.presence) for presence in own) + ] + + def _ambiguity_message(self, target_id: str, target: _Target, clashes: list[_Slot]) -> str: + """render-ref-ambiguous, with advice for what clashes.""" + start = f"'{{{{ref: {target_id}}}}}' renders as '{target.designation}', which " + kinds = {target.slot.kind} | {other.kind for other in clashes} if target.slot else set() + if kinds == {"item"}: + return (start + "another list item also reads as: each list starts again at its first number. " + "Make them one list, or refer to the item in words.") + if "section" in kinds and self.textual: + return (start + "another heading also reads as: the none scheme refers to a section by its " + "heading text. Rename one, or refer to it in words.") + names = sorted({{"section": "a section", "paragraph": "a numbered paragraph", "item": "a list item"}[kind] + for kind in (other.kind for other in clashes)}) + return (start + f"{' or '.join(names)} also reads as. Refer to it in words, or change the style's " + "numbering so that they differ.") + def _term(self, directive: Directive, definition_id: str) -> Inline: term = self.result.definition_lookup.get(definition_id) if term is None: @@ -746,6 +810,10 @@ def _warn(self, rule: str, message: str) -> None: } +#: What numbers sections, in a unit's slot: the validator (its numbers). +_SECTIONS = object() + + 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 @@ -759,6 +827,12 @@ def __init__(self, exclusive: Callable[[Presence, Presence], bool]) -> None: self.identifier = "" self.holders: list[Presence] = [] + def new_list(self) -> None: + """Go on counting in another list: its first unit is no alternative + to the last unit of the list before, which is not its sibling.""" + self.identifier = "" + self.holders = [] + 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.""" @@ -772,6 +846,27 @@ def next(self, identifier: str, presence: Presence | None) -> int: return self.count +class _Slot(NamedTuple): + """Where a numbered unit's designation comes from, for render-ref-ambiguous.""" + + #: What numbers the unit: its list's or paragraphs' _Counter, or + #: _SECTIONS for a section (the validator numbers those). + source: object + #: Its number there; alternatives share one (§15.8). + number: object + #: When the unit appears (§15.3). + presence: Presence + #: "section", "paragraph", or "item". + kind: str + #: The identifier it anchors, or "". + anchor: str + + @property + def key(self) -> tuple[object, object]: + """The unit and its alternatives: its source and number.""" + return (self.source, self.number) + + def fill_condition(template: str, value: str, *, key: str = "condition") -> str: return template.replace("{" + key + "}", value) diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py index b7e7600..1a0f464 100644 --- a/src/legaldown_render/style/model.py +++ b/src/legaldown_render/style/model.py @@ -52,8 +52,9 @@ class Numbering: #: 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 + #: Overrides the scheme's formats by part of a section number: the first + #: format for "2", the second for the ".1" of "2.1" (at most five, as + #: headings have five levels, §4.1). Levels not listed keep the #: scheme's format. levels: tuple[LevelFormat, ...] = () diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py index 0dd74b7..6e15f78 100644 --- a/src/legaldown_render/tree.py +++ b/src/legaldown_render/tree.py @@ -250,6 +250,8 @@ class Section: """One heading and the blocks up to the next heading. Sections are kept flat, in document order; ``level`` gives the hierarchy.""" + #: The heading's level as written; after resolution, how many parts + #: its number has (1 for "2", 2 for "2.1"), clamped to 1-5. level: int title: tuple[Inline, ...] #: The identifier the validator resolved (explicit, or generated by §5.3). @@ -261,9 +263,6 @@ class Section: designation: str | None = None anchor: str | None = None condition_label: str | None = None - #: How deep its number goes: 1 for "2", 2 for "2.1". Less than - #: ``level`` when a heading level is skipped. - depth: int = 0 @dataclass(frozen=True, slots=True) diff --git a/src/legaldown_render/validator_bridge.py b/src/legaldown_render/validator_bridge.py index ab725a7..9032d89 100644 --- a/src/legaldown_render/validator_bridge.py +++ b/src/legaldown_render/validator_bridge.py @@ -1,84 +1,28 @@ """Everything the renderer takes from ``legaldown-validator`` beyond its -top-level public API, in one place. +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. +the validator has decided something, the renderer asks it. Since +legaldown-validator 0.3.0 nearly all of it is public — where markers are +placed and whether a document is a template come with the +``ValidationResult`` (``placed_markers``, ``is_template``), and the model +helpers are exported from ``legaldown``. What is left here is how the +validator reads a block quote's content and a fenced code block, and how +its CLI reads an answers file. The dependency stays pinned to one minor +version while this list is not empty. """ 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) - +from legaldown.cli import _read_answers as read_answers +from legaldown.markdown import FENCE_OPEN_RE, closes_fence, dedent, indent_width +from legaldown.parser import MAX_QUOTE_DEPTH, quote_content + +__all__ = [ + "FENCE_OPEN_RE", + "MAX_QUOTE_DEPTH", + "closes_fence", + "dedent", + "indent_width", + "quote_content", + "read_answers", +] diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py index 9a4e665..40167d4 100644 --- a/src/legaldown_render/writers/text.py +++ b/src/legaldown_render/writers/text.py @@ -124,7 +124,7 @@ def block(self, block: Block) -> str: text = f"{label} {text}" return f"[{condition}] {text}" if condition else text case List(): - return "\n".join(self.list(block, 0)) + return "\n".join(self.list(block, "")) case Quote(blocks=blocks): return _prefix("\n\n".join(self.block(child) for child in blocks), "> ") case DraftingNote(blocks=blocks, label=label): @@ -138,13 +138,15 @@ def block(self, block: Block) -> str: return "---" return "" - def list(self, block: List, depth: int) -> list[str]: + def list(self, block: List, indent: str) -> list[str]: + """The lines of *block*, each item's label at *indent*. Everything + else in an item, a nested list included, lines up with its text.""" lines: list[str] = [] - indent = _INDENT * depth for item in block.items: marker = item.label if item.label else "-" if item.condition_label: marker = f"{marker} [{item.condition_label}]" + content = indent + " " * (len(marker) + 1) first = True for child in item.blocks: if isinstance(child, List): @@ -153,14 +155,14 @@ def list(self, block: List, depth: int) -> list[str]: # still comes first, on a line of its own. lines.append(indent + marker) first = False - lines += self.list(child, depth + 1) + lines += self.list(child, content) continue text = self.block(child) if first: lines.append(_prefix(f"{marker} {text}", indent, hanging=" " * (len(marker) + 1))) first = False else: - lines.append(_prefix(text, indent + " " * (len(marker) + 1))) + lines.append(_prefix(text, content)) if first: lines.append(indent + marker) return lines diff --git a/tests/conformance/test_spec_corpus.py b/tests/conformance/test_spec_corpus.py index 430cf5e..93dfb79 100644 --- a/tests/conformance/test_spec_corpus.py +++ b/tests/conformance/test_spec_corpus.py @@ -90,17 +90,27 @@ def test_raw_html_fixture_emits_no_html() -> None: assert "raw-html" in {d.rule for d in result.diagnostics} -@pytest.mark.parametrize("path", EXAMPLES + FIXTURES, ids=_name) -def test_template_decision_is_the_validators(path: Path) -> None: - """The renderer's copy of the validator's template formula must give the - validator's own answer (validator_bridge._is_template, roadmap U3).""" - from legaldown import parse_document - from validator_spy import validator_template +ASSEMBLY = sorted(path for path in (SPEC / "fixtures" / "assembly").iterdir() if path.is_dir()) \ + if (SPEC / "fixtures" / "assembly").is_dir() else [] - from legaldown_render.validator_bridge import placed_markers - try: - document = parse_document(path.read_text(encoding="utf-8")) - except Exception: - pytest.skip("not a readable document") - assert placed_markers(document).template == validator_template(document) +@pytest.mark.parametrize("case", ASSEMBLY, ids=lambda case: case.name) +def test_rendering_with_answers_renders_the_assembled_document(case: Path) -> None: + """A template rendered with answers is its assembled document rendered + (§15.8); a case that needs Full, reading other files, is refused.""" + import yaml + + from legaldown_render import RenderRefused + + template = (case / "template.lgd").read_text(encoding="utf-8") + answers = yaml.safe_load((case / "answers.yaml").read_text(encoding="utf-8")) or {} + level = json.loads((case / "case.json").read_text(encoding="utf-8")).get("requires_level") \ + if (case / "case.json").exists() else "core" + if level == "full": + with pytest.raises(RenderRefused): + render(template, answers=answers) + return + expected = (case / "expected.lgd").read_text(encoding="utf-8") + for output_format in ("text", "html"): + assert render(template, answers=answers, format=output_format).output == \ + render(expected, format=output_format).output diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt index 0e797a0..5c995c3 100644 --- a/tests/golden/features.continental.txt +++ b/tests/golden/features.continental.txt @@ -29,9 +29,9 @@ This Services Agreement (this Agreement) is made between Acme and Beta Industrie 2.1.1 keep records of all work performed 2.1.2 notify the Client of any delay, including: -2.1.3 the cause of the delay -2.1.4 its expected duration -2.1.5 comply with the policies in the price list. + a) the cause of the delay + b) its expected duration +2.1.3 comply with the policies in the price list. (2) The steps of the escalation procedure are: @@ -40,7 +40,7 @@ This Services Agreement (this Agreement) is made between Acme and Beta Industrie 2.2 Changes -(1) A change under item 2.1.3 or step 2.1(2) is made in writing under Section 2.1. +(1) A change under item 2.1.2(a) or step 2.1(2) is made in writing under Section 2.1. 3. Fees diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html index bb6b501..f3ba52a 100644 --- a/tests/golden/features.default.html +++ b/tests/golden/features.default.html @@ -4,7 +4,7 @@ Services Agreement - +