From 1e50a2a5f7824335212ee8f3d8bbdf25297f8dfd Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:39:42 +0000
Subject: [PATCH 001/118] CLI and API robustness (S1, S2, S3, S4, S5, S6, S10
CLI side)
- S1: main() reconfigures stdout to UTF-8 and stderr to backslashreplace
(guarded by hasattr(..., "reconfigure")); stdin is read as bytes and
decoded as UTF-8. Czech text no longer crashes or is corrupted on cp1252.
- S4: a non-UTF-8 input file or stdin is "cannot read NAME: not valid UTF-8
(byte 0x.. at offset N)", exit 1 (offset is in the file, not a buffer).
A non-UTF-8 style file is a StyleError (style/loader.py _read only).
- S5: an OSError writing -o is "cannot write PATH: ...", exit 1.
- S6: -o with an extension no writer knows (x.docx, .pdf, .md) and no -f is
a usage error (exit 2); no extension keeps the html default; -f wins.
- S2: StyleError is defined in errors.py as StyleError(RenderError,
ValueError) and re-exported from style/loader.py, legaldown_render.style
and the package. An unknown format raises the new OptionsError(RenderError,
ValueError), exported from the package.
- S3: RenderRefused (now storing .reason) and StyleError define __reduce__ so
they round-trip through pickle.
- S10 (CLI side): the argparse type _setting turns StyleError into
ArgumentTypeError so its message reaches the user.
Docs: README (exit codes, encoding, -o extension, error classes),
docs/architecture.md (errors). Tests: tests/test_wp2_cli.py (new); no
existing test or golden changed.
---
README.md | 15 +-
docs/architecture.md | 8 +-
src/legaldown_render/__init__.py | 5 +-
src/legaldown_render/api.py | 9 +-
src/legaldown_render/cli.py | 68 +++++++-
src/legaldown_render/errors.py | 35 +++-
src/legaldown_render/style/loader.py | 17 +-
tests/test_wp2_cli.py | 246 +++++++++++++++++++++++++++
8 files changed, 369 insertions(+), 34 deletions(-)
create mode 100644 tests/test_wp2_cli.py
diff --git a/README.md b/README.md
index c7f12d7..1eeed6a 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,12 @@ legaldown-render --print-style --style continental # every setting,
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 (strict mode,
or a template that cannot be assembled with its answers) or the document or answers file cannot be
-read, 2 for an invalid style, setting, or usage, and 70 for an internal error (a bug to report).
+read (a document that is not UTF-8 included) or the output file cannot be written, 2 for an invalid
+style, setting, or usage, and 70 for an internal error (a bug to report).
+
+Documents are read as UTF-8, and standard output is written as UTF-8 whatever the console's encoding.
+`-o` takes its format from the file extension (`.html`, `.htm`, `.txt`, `.text`); a name with no
+extension is HTML, and any other extension (`.docx`, `.pdf`, `.md`) needs an explicit `--format`.
## Python
@@ -83,9 +88,11 @@ open("contract.html", "w").write(result.output)
```
`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.
+Problems in the document never raise. Only an invalid style (`StyleError`), an unknown output
+format (`OptionsError`), an unreadable document (`DocumentError`), strict mode, or a template that
+cannot be assembled with its answers (`RenderRefused`) do. All of them are `RenderError`s, and
+`StyleError` and `OptionsError` are also `ValueError`s. They can be pickled, so they cross process
+boundaries (`multiprocessing`, `concurrent.futures`) intact.
## Styles and preferences
diff --git a/docs/architecture.md b/docs/architecture.md
index f8a25fb..832b53c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -182,7 +182,7 @@ src/legaldown_render/
├── __init__.py public API, __version__, SPEC_VERSION, CONFORMANCE_LEVEL
├── api.py render(), render_file(), RenderOptions, RenderResult
├── cli.py the legaldown-render command
-├── errors.py RenderError, DocumentError, RenderRefused, InternalError
+├── errors.py RenderError, StyleError, OptionsError, DocumentError, RenderRefused, InternalError
├── tree.py render tree nodes (source and resolved inlines, blocks, document)
├── build.py stage 3
├── resolve/
@@ -220,8 +220,10 @@ turns into an error.
- **Document problems** render as failure markers and are listed in `diagnostics`. `strict=True`
raises `RenderRefused` instead, carrying the diagnostics.
-- **Configuration problems** (an invalid style, setting, locale, or format) raise before
- rendering, with every problem named.
+- **Configuration problems** raise before rendering, with every problem named: an invalid style,
+ setting, or locale raises `StyleError`; an unknown output format raises `OptionsError`. Both are
+ `RenderError`s (and `ValueError`s, as they always were). Every renderer error pickles, so it
+ survives `multiprocessing`.
- **Internal inconsistencies** raise `InternalError`, and the CLI exits with 70.
Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate:
diff --git a/src/legaldown_render/__init__.py b/src/legaldown_render/__init__.py
index acbd0a8..8845c22 100644
--- a/src/legaldown_render/__init__.py
+++ b/src/legaldown_render/__init__.py
@@ -18,8 +18,8 @@
from __future__ import annotations
from .api import RenderOptions, RenderResult, render, render_file
-from .errors import DocumentError, InternalError, RenderError, RenderRefused
-from .style import Style, StyleError, builtin_styles, dump_style, load_style
+from .errors import DocumentError, InternalError, OptionsError, RenderError, RenderRefused, StyleError
+from .style import Style, builtin_styles, dump_style, load_style
__version__ = "0.3.0"
@@ -43,6 +43,7 @@
"dump_style",
"builtin_styles",
"RenderError",
+ "OptionsError",
"DocumentError",
"RenderRefused",
"InternalError",
diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py
index 3d58fc7..0fb88e7 100644
--- a/src/legaldown_render/api.py
+++ b/src/legaldown_render/api.py
@@ -21,9 +21,9 @@
from legaldown import AssemblyError, Diagnostic, Document, ValidationResult, parse, parse_template, validate
from .build import build_tree, normalize_source
-from .errors import DocumentError, RenderRefused
+from .errors import DocumentError, OptionsError, RenderRefused, StyleError
from .resolve import Formatter, parse_locale, resolve_tree
-from .style import Style, StyleError, load_style
+from .style import Style, load_style
from .tree import RenderTree, assert_resolved
from .writers import FORMATS, HtmlWriter, TextWriter
@@ -86,7 +86,8 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
Problems in the document never raise: they render as failure markers and
are listed in ``diagnostics``. Raises :class:`StyleError` for an invalid
- style or setting, :class:`DocumentError` for a document that cannot be
+ style or setting, :class:`OptionsError` for an unknown output format,
+ :class:`DocumentError` for a document that cannot be
read, and :class:`RenderRefused` in strict mode when the document has
errors, or when a template cannot be assembled with ``answers``.
"""
@@ -95,7 +96,7 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
elif settings:
raise TypeError("pass either a RenderOptions or keyword settings, not both")
if options.format not in FORMATS:
- raise ValueError(f"unknown output format '{options.format}' (known: {', '.join(FORMATS)})")
+ raise OptionsError(f"unknown output format '{options.format}' (known: {', '.join(FORMATS)})")
overrides = dict(options.overrides)
if options.locale:
overrides["locale"] = options.locale
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index 9906985..8509c08 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -2,12 +2,17 @@
Exit codes: 0 — rendered (document errors, if any, are shown as markers and
listed on stderr); 1 — not rendered: refused under ``--strict``, or the
-document cannot be read; 2 — a usage or style problem; 70 — an internal
-error (a bug to report).
+document cannot be read (including input that is not UTF-8) or the output cannot
+be written; 2 — a usage or style problem; 70 — an internal error (a bug to
+report).
+
+Documents and output are always UTF-8 (the HTML says ````),
+never the console's or the locale's encoding.
"""
from __future__ import annotations
import argparse
+import contextlib
import sys
from pathlib import Path
from typing import Any
@@ -16,8 +21,8 @@
from . import __version__
from .api import RenderOptions, render
-from .errors import DocumentError, InternalError, RenderRefused
-from .style import StyleError, builtin_styles, dump_style, load_style, parse_override
+from .errors import DocumentError, InternalError, RenderRefused, StyleError
+from .style import builtin_styles, dump_style, load_style, parse_override
from .writers import FORMATS, format_for_path
@@ -26,7 +31,11 @@ def _setting(text: str) -> tuple[str, Any]:
if not separator or not key.strip():
raise argparse.ArgumentTypeError(f"expected KEY=VALUE, got '{text}'")
# Typed by the setting: text stays as written, anything else is YAML.
- return key.strip(), parse_override(key.strip(), value)
+ try:
+ return key.strip(), parse_override(key.strip(), value)
+ except StyleError as error:
+ # argparse shows only ArgumentTypeError (and TypeError/ValueError, with a generic message).
+ raise argparse.ArgumentTypeError(str(error)) from None
def _parser() -> argparse.ArgumentParser:
@@ -63,7 +72,34 @@ def _parser() -> argparse.ArgumentParser:
return parser
+def _configure_streams() -> None:
+ """Make the standard streams safe for any content, whatever the console's
+ encoding: the document and the style are UTF-8, so what is written to
+ standard output is UTF-8 as well, and a diagnostic that quotes characters
+ the console cannot show escapes them (``\\u2192``) instead of failing with a
+ UnicodeEncodeError. Replaced streams without ``reconfigure`` (a test
+ capture, a StringIO) are left alone."""
+ if hasattr(sys.stdout, "reconfigure"):
+ with contextlib.suppress(ValueError, OSError):
+ sys.stdout.reconfigure(encoding="utf-8")
+ if hasattr(sys.stderr, "reconfigure"):
+ with contextlib.suppress(ValueError, OSError):
+ sys.stderr.reconfigure(errors="backslashreplace")
+
+
+def _read_source(path: str) -> str:
+ """The document at *path*, or standard input for ``-``, decoded as UTF-8
+ from its bytes (never in the locale's encoding). Raises OSError, or
+ UnicodeDecodeError for input that is not UTF-8."""
+ if path == "-":
+ buffer = getattr(sys.stdin, "buffer", None)
+ # A replaced stdin without bytes (StringIO) already holds text.
+ return buffer.read().decode("utf-8") if buffer is not None else sys.stdin.read()
+ return Path(path).read_bytes().decode("utf-8")
+
+
def main(argv: list[str] | None = None) -> int:
+ _configure_streams()
parser = _parser()
args = parser.parse_args(argv)
overrides = dict(args.overrides)
@@ -83,13 +119,25 @@ def main(argv: list[str] | None = None) -> int:
if not args.input:
parser.error("an input file is required")
- output_format = args.format or (format_for_path(args.output) if args.output else None) or "html"
+ output_format = args.format
+ if not output_format and args.output:
+ file_name = Path(args.output).name
+ output_format = format_for_path(file_name)
+ if not output_format and "." in file_name.rstrip("."):
+ # A name with an extension we do not write (x.docx, x.pdf, x.md): HTML under that
+ # name would mislead. A name without one keeps the html default.
+ parser.error(f"cannot tell the output format from '{args.output}'; use --format html or text")
+ output_format = output_format or "html"
name = "" if args.input == "-" else args.input
try:
- source = sys.stdin.read() if args.input == "-" else Path(args.input).read_text(encoding="utf-8")
+ source = _read_source(args.input)
except OSError as error:
print(f"legaldown-render: cannot read {name}: {error.strerror or error}", file=sys.stderr)
return 1
+ except UnicodeDecodeError as error:
+ print(f"legaldown-render: cannot read {name}: not valid UTF-8 "
+ f"(byte 0x{error.object[error.start]:02x} at offset {error.start})", file=sys.stderr)
+ return 1
answers = None
if args.answers:
@@ -124,7 +172,11 @@ def main(argv: list[str] | None = None) -> int:
_report(name, result.diagnostics, quiet=args.quiet)
if args.output:
- Path(args.output).write_text(result.output, encoding="utf-8")
+ try:
+ Path(args.output).write_text(result.output, encoding="utf-8")
+ except OSError as error:
+ print(f"legaldown-render: cannot write {args.output}: {error.strerror or error}", file=sys.stderr)
+ return 1
else:
sys.stdout.write(result.output)
return 0
diff --git a/src/legaldown_render/errors.py b/src/legaldown_render/errors.py
index 8ba21b5..ff9526a 100644
--- a/src/legaldown_render/errors.py
+++ b/src/legaldown_render/errors.py
@@ -10,6 +10,33 @@ class RenderError(Exception):
"""Base class for every error the renderer raises."""
+class StyleError(RenderError, ValueError):
+ """A style template or override is invalid. ``problems`` lists each
+ problem with its key path.
+
+ It is a :class:`ValueError` as well as a :class:`RenderError`, so code
+ that caught it as a ``ValueError`` keeps working. It lives here, with the
+ other errors, and is re-exported by :mod:`legaldown_render.style`."""
+
+ def __init__(self, source: str, problems: list[str]) -> None:
+ self.source = source
+ self.problems = problems
+ details = "\n".join(f" - {problem}" for problem in problems)
+ super().__init__(f"Invalid style {source}:\n{details}")
+
+ def __reduce__(self) -> tuple[type[StyleError], tuple[str, list[str]]]:
+ # ``Exception`` pickles ``args``, which here is the one formatted message,
+ # and the two-argument constructor would then fail to rebuild it
+ # (multiprocessing would raise BrokenProcessPool instead of the error).
+ return type(self), (self.source, self.problems)
+
+
+class OptionsError(RenderError, ValueError):
+ """The render options are invalid, e.g. an unknown output format. It is
+ a :class:`ValueError` as well, as the bare ``ValueError`` it replaces
+ was."""
+
+
class DocumentError(RenderError):
"""The document cannot be read at all, e.g. its frontmatter is not
valid YAML."""
@@ -18,13 +45,19 @@ class DocumentError(RenderError):
class RenderRefused(RenderError):
"""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."""
+ ``diagnostics`` holds every finding; ``reason`` is the message given when
+ it was raised, or None for the strict-mode default."""
def __init__(self, diagnostics: list[Diagnostic], reason: str | None = None) -> None:
self.diagnostics = diagnostics
+ self.reason = reason
errors = sum(1 for diagnostic in diagnostics if diagnostic.level == "error")
super().__init__(reason or f"Rendering refused in strict mode: the document has {errors} error(s).")
+ def __reduce__(self) -> tuple[type[RenderRefused], tuple[list[Diagnostic], str | None]]:
+ # See StyleError.__reduce__: the constructor does not take the message.
+ return type(self), (self.diagnostics, self.reason)
+
class InternalError(RenderError):
"""The renderer reached a state that is a bug, such as a marker the
diff --git a/src/legaldown_render/style/loader.py b/src/legaldown_render/style/loader.py
index c3239ac..3c48b67 100644
--- a/src/legaldown_render/style/loader.py
+++ b/src/legaldown_render/style/loader.py
@@ -24,23 +24,13 @@
import yaml
+from ..errors import StyleError
from . import model
from .model import Style
STYLE_FORMAT_VERSION = 1
-class StyleError(ValueError):
- """A style template or override is invalid. ``problems`` lists each
- problem with its key path."""
-
- def __init__(self, source: str, problems: list[str]) -> None:
- self.source = source
- self.problems = problems
- details = "\n".join(f" - {problem}" for problem in problems)
- super().__init__(f"Invalid style {source}:\n{details}")
-
-
def builtin_styles() -> list[str]:
"""Names of the style templates shipped with the package."""
folder = resources.files(__package__).joinpath("builtin")
@@ -119,9 +109,12 @@ def _read(spec: str | Path, relative_to: Path | None) -> tuple[dict, str, Path |
if relative_to is not None and not path.is_absolute():
path = relative_to / path
try:
- text = path.read_text("utf-8")
+ text = path.read_bytes().decode("utf-8")
except OSError as error:
raise StyleError(str(path), [f"cannot read the file: {error.strerror or error}"]) from None
+ except UnicodeDecodeError as error:
+ raise StyleError(str(path), [f"cannot read the file: not valid UTF-8 (byte 0x{error.object[error.start]:02x} "
+ f"at offset {error.start})"]) from None
raw = _parse(text, str(path))
raw.setdefault("name", path.stem)
return raw, str(path), path.parent
diff --git a/tests/test_wp2_cli.py b/tests/test_wp2_cli.py
new file mode 100644
index 0000000..8b864fd
--- /dev/null
+++ b/tests/test_wp2_cli.py
@@ -0,0 +1,246 @@
+"""CLI and API robustness: stream encodings (S1), non-UTF-8 input (S4), an
+unwritable output (S5), an unknown output extension (S6), the error
+hierarchy (S2), picklable errors (S3), and the ``--set`` error path (S10, CLI
+side)."""
+from __future__ import annotations
+
+import io
+import os
+import pickle
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from conftest import DOCUMENTS
+from legaldown import Diagnostic
+
+import legaldown_render
+import legaldown_render.cli as cli
+from legaldown_render import (
+ OptionsError,
+ RenderError,
+ RenderOptions,
+ RenderRefused,
+ StyleError,
+ load_style,
+ render,
+)
+from legaldown_render.cli import main
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+FEATURES = str(DOCUMENTS / "features.lgd")
+CZECH = "---\nlegaldown: '0.2'\ntitle: Smlouva o dílo\nlanguage: cs\n---\n\n# Příloha\n\nŽluťoučký kůň úpěl ďábelské ódy – “citát” → konec, ōsaka.\n"
+
+
+def _run(args: list[str], stdin: bytes | None = None, *, narrow: bool = True) -> subprocess.CompletedProcess[bytes]:
+ env = dict(os.environ, PYTHONPATH=str(SRC))
+ if narrow:
+ # A Windows-like console: neither stream is UTF-8.
+ env.update(PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
+ capture_output=True, env=env, check=False)
+
+
+def test_the_package_under_test_is_this_worktree() -> None:
+ assert Path(legaldown_render.__file__).resolve().is_relative_to(SRC)
+
+
+# --- S1: encodings ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_stdin_to_stdout_is_utf8_whatever_the_console_encoding(fmt: str) -> None:
+ expected = render(CZECH, format=fmt).output.encode("utf-8")
+ done = _run(["-", "-f", fmt, "-q"], CZECH.encode("utf-8"))
+ assert done.returncode == 0, done.stderr
+ assert done.stdout == expected
+ assert "Příloha".encode() in done.stdout
+
+
+def test_file_to_stdout_is_utf8_whatever_the_console_encoding(tmp_path: Path) -> None:
+ path = tmp_path / "smlouva.lgd"
+ path.write_bytes(CZECH.encode("utf-8"))
+ done = _run([str(path), "-f", "text", "-q"])
+ assert done.returncode == 0, done.stderr
+ assert done.stdout == render(CZECH, format="text").output.encode("utf-8")
+
+
+def test_print_style_survives_a_narrow_console() -> None:
+ done = _run(["--print-style", "--style", "continental", "--set", "placeholders.blank=…"])
+ assert done.returncode == 0, done.stderr
+ assert "…".encode() in done.stdout
+
+
+def test_stderr_never_crashes_on_a_narrow_console() -> None:
+ done = _run(["žlutý→.lgd"])
+ assert done.returncode == 1
+ assert b"Traceback" not in done.stderr
+ assert b"cannot read" in done.stderr
+ assert b"\\u2192" in done.stderr # not encodable in cp1252: backslash-escaped, not an error
+
+
+# --- S4: not UTF-8 ---------------------------------------------------------------------------
+
+def test_latin1_input_file_is_a_clean_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "latin1.lgd"
+ path.write_bytes("# Příloha\n\nCaf\xe9\n".encode("latin-1", errors="replace"))
+ assert main([str(path)]) == 1
+ err = capsys.readouterr().err
+ assert err.startswith(f"legaldown-render: cannot read {path}: not valid UTF-8 (byte 0x")
+ assert "at offset" in err
+ assert "Traceback" not in err
+
+
+def test_the_offset_is_in_the_file_not_in_a_buffer(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "late.lgd"
+ path.write_bytes(b"a" * 20000 + b"\xe9")
+ assert main([str(path)]) == 1
+ assert "(byte 0xe9 at offset 20000)" in capsys.readouterr().err
+
+
+def test_latin1_stdin_is_a_clean_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
+ monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(b"# Caf\xe9\n"), encoding="cp1252"))
+ assert main(["-"]) == 1
+ assert "legaldown-render: cannot read : not valid UTF-8 (byte 0xe9 at offset 5)" in capsys.readouterr().err
+
+
+def test_stdin_is_decoded_as_utf8_not_as_the_locale(monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(CZECH.encode("utf-8")), encoding="cp1252"))
+ assert main(["-", "-f", "text", "-q"]) == 0
+ assert "Žluťoučký kůň" in capsys.readouterr().out
+
+
+def test_non_utf8_style_file_is_a_style_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "bad.yaml"
+ path.write_bytes(b"version: 1\nname: Caf\xe9\n")
+ with pytest.raises(StyleError, match="not valid UTF-8"):
+ load_style(path)
+ assert main([FEATURES, "--style", str(path)]) == 2
+ err = capsys.readouterr().err
+ assert "not valid UTF-8" in err
+ assert "Traceback" not in err
+
+
+# --- S5: unwritable output -------------------------------------------------------------------
+
+def test_unwritable_output_is_a_clean_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ target = tmp_path / "missing-folder" / "out.html"
+ assert main([FEATURES, "-q", "-o", str(target)]) == 1
+ err = capsys.readouterr().err
+ assert err.startswith(f"legaldown-render: cannot write {target}: ")
+ assert "Traceback" not in err
+
+
+def test_output_that_is_a_directory(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ assert main([FEATURES, "-q", "-f", "html", "-o", str(tmp_path)]) == 1
+ assert "cannot write" in capsys.readouterr().err
+
+
+# --- S6: unknown extension -------------------------------------------------------------------
+
+@pytest.mark.parametrize("name", ["x.docx", "x.pdf", "x.md", "x.DOCX"])
+def test_unknown_output_extension_without_format_is_a_usage_error(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], name: str) -> None:
+ target = tmp_path / name
+ with pytest.raises(SystemExit) as exit_info:
+ main([FEATURES, "-q", "-o", str(target)])
+ assert exit_info.value.code == 2
+ err = capsys.readouterr().err
+ assert f"cannot tell the output format from '{target}'" in err
+ assert "--format html or text" in err
+ assert not target.exists()
+
+
+def test_explicit_format_is_honoured_whatever_the_extension(tmp_path: Path) -> None:
+ target = tmp_path / "x.docx"
+ assert main([FEATURES, "-q", "-f", "text", "-o", str(target)]) == 0
+ assert target.read_text(encoding="utf-8").startswith("Services Agreement")
+
+
+def test_output_without_an_extension_keeps_the_html_default(tmp_path: Path) -> None:
+ for target in (tmp_path / "out", tmp_path / "dir.d" / "out"):
+ target.parent.mkdir(exist_ok=True)
+ assert main([FEATURES, "-q", "-o", str(target)]) == 0
+ assert target.read_text(encoding="utf-8").startswith("")
+
+
+# --- S2: error hierarchy ---------------------------------------------------------------------
+
+def test_style_error_is_a_render_error_and_still_a_value_error() -> None:
+ assert issubclass(StyleError, RenderError)
+ assert issubclass(StyleError, ValueError)
+
+
+def test_style_error_is_importable_from_where_it_always_was() -> None:
+ from legaldown_render import errors
+ from legaldown_render.style import StyleError as from_style
+ from legaldown_render.style.loader import StyleError as from_loader
+
+ assert errors.StyleError is StyleError is from_style is from_loader
+
+
+def test_unknown_format_raises_options_error() -> None:
+ assert issubclass(OptionsError, RenderError)
+ assert issubclass(OptionsError, ValueError)
+ with pytest.raises(OptionsError, match="unknown output format 'pdf'"):
+ render("# x\n", RenderOptions(format="pdf"))
+ with pytest.raises(OptionsError):
+ render("# x\n", format="docx")
+ with pytest.raises(RenderError):
+ render("# x\n", format="docx")
+
+
+def test_invalid_style_through_the_api_is_a_render_error() -> None:
+ with pytest.raises(RenderError):
+ render("# x\n", style="no-such-style")
+
+
+# --- S3: pickling ----------------------------------------------------------------------------
+
+def test_render_refused_round_trips_through_pickle() -> None:
+ diagnostics = [Diagnostic(rule="ref-broken", level="error", message="No such target", line=3)]
+ for reason in (None, "Assembly refused: 1 error(s)."):
+ error = RenderRefused(diagnostics, reason)
+ copy = pickle.loads(pickle.dumps(error))
+ assert type(copy) is RenderRefused
+ assert copy.diagnostics == diagnostics
+ assert str(copy) == str(error)
+ assert copy.reason == error.reason
+
+
+def test_a_real_refusal_round_trips_through_pickle() -> None:
+ with pytest.raises(RenderRefused) as caught:
+ render(Path(FEATURES).read_text(encoding="utf-8"), strict=True)
+ copy = pickle.loads(pickle.dumps(caught.value))
+ assert copy.diagnostics == caught.value.diagnostics
+ assert str(copy) == str(caught.value)
+
+
+def test_style_error_round_trips_through_pickle() -> None:
+ error = StyleError("overrides", ["a.b: wrong", "c: also wrong"])
+ copy = pickle.loads(pickle.dumps(error))
+ assert type(copy) is StyleError
+ assert (copy.source, copy.problems) == ("overrides", ["a.b: wrong", "c: also wrong"])
+ assert str(copy) == str(error)
+
+
+def test_options_error_round_trips_through_pickle() -> None:
+ error = OptionsError("unknown output format 'pdf'")
+ assert str(pickle.loads(pickle.dumps(error))) == str(error)
+
+
+# --- S10 (CLI side): a StyleError from --set reaches the user ---------------------------------
+
+def test_set_style_error_is_shown_by_argparse(monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ def refuse(key: str, text: str) -> object:
+ raise StyleError("overrides", [f"{key}: the value is no good"])
+
+ monkeypatch.setattr(cli, "parse_override", refuse)
+ with pytest.raises(SystemExit) as exit_info:
+ main([FEATURES, "--set", "numbering.scheme=oops"])
+ assert exit_info.value.code == 2
+ err = capsys.readouterr().err
+ assert "numbering.scheme: the value is no good" in err
+ assert "--set" in err
From 6a65d836d76e738f94c8f7c3f2461aa537bd82d8 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:40:07 +0000
Subject: [PATCH 002/118] Values: exact long amounts, faithful durations, no
raw unit ids, spaced symbols (R1-R4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
R2: Babel calls for number/money/duration run in a decimal context whose
precision is max(28, len(value) + 8). Amounts of 27+ digits no longer raise
InvalidOperation and fractions over 28 digits are no longer rounded (§10.3).
R3: durations are formatted like number() (every written digit, locale
separators) and the plural category is taken from the displayed value
(Locale.plural_form on the Decimal: "1.0 days", cs "2,5 roku"). Babel's
format_unit, which rounds to 3 decimals, is no longer used.
R1: the CLDR pattern is looked up through one helper (Locale._data is the only
access; Babel 2.x has no public API): requested length, other lengths, the
language-only locale, then English. A raw "duration-day" is never printed.
Formatter.format_duration returns DurationText(text, english_fallback); the
resolver emits one render-locale-fallback Warning per (locale, unit) when
English stands in. Formatter.duration keeps returning str.
R4: an alphabetic currency symbol or code touching a digit on either side gets
U+00A0 in symbol and code mode ("CZK 5.00", "CHF 1.00"); "$5.00", "R$ 5,00",
"CA$5.00" and cs "5,00 Kč" are unchanged.
New: tests/test_wp1_values.py. No existing test or golden changed. Docs: ADR 0005
and docs/architecture.md.
---
docs/architecture.md | 4 +-
docs/decisions/0005-locale-formatting.md | 13 +-
src/legaldown_render/resolve/resolver.py | 8 +-
src/legaldown_render/resolve/values.py | 125 +++++++++++++++--
tests/test_wp1_values.py | 170 +++++++++++++++++++++++
5 files changed, 307 insertions(+), 13 deletions(-)
create mode 100644 tests/test_wp1_values.py
diff --git a/docs/architecture.md b/docs/architecture.md
index f8a25fb..13e7d6a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -137,7 +137,7 @@ Guessing at lost structure was tried, and it traded each gap for new bugs.
| `{{ref:}}` | `CrossRef(designation, anchor)` (§13.3). A ref to an unnumbered item or paragraph falls back to its section's designation, with `ref-not-enumerated` | `[BROKEN REF: id]` |
| `{{term:}}` | `TermRef(label or term, def anchor)` (§7.3, §13.4) | `[UNDEFINED: id]` |
| `"Term" {{def:}}` | `DefinedTerm` without quotation marks, anchored at `def:` (§7.2) | n/a |
-| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005) | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` |
+| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005): amounts and durations keep every written digit, and a duration without a pattern in the locale falls back to another length, the language alone, then English with `render-locale-fallback` | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` |
| `{{party:}}` `{{side:}}` | `Value(label or display name)` (§3.6) | `[INVALID PARTY: …]`, `[UNKNOWN PARTY: …]`, and the side forms |
| `{{field:}}` | `Value(raw value)` | `[INVALID FIELD: …]` |
| `{{placeholder:}}` | `Blank`, with the question's prompt if the style asks for it | `[INVALID PLACEHOLDER: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION UNIT: …]` |
@@ -226,7 +226,7 @@ turns into an error.
Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate:
`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
+`render-not-processed`, `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), 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: they are about a directive within a block, and the validator makes only a block's
line public (`Document.line_of`, U2).
diff --git a/docs/decisions/0005-locale-formatting.md b/docs/decisions/0005-locale-formatting.md
index b313de1..794646e 100644
--- a/docs/decisions/0005-locale-formatting.md
+++ b/docs/decisions/0005-locale-formatting.md
@@ -19,8 +19,17 @@ Use [Babel](https://babel.pocoo.org/). It is pure Python and ships CLDR data.
- `format_date` for dates
- `format_currency` for money, padded to the currency's minor units and never rounded (§10.3).
- Values are handled as `Decimal` from the source string.
-- `format_unit` for durations, with CLDR plural rules
+ Values are handled as `Decimal` from the source string, in a decimal context with the precision
+ of the source (at least 28 digits, plus a margin), so that no amount of 28 digits or more is
+ rounded or raises. An alphabetic symbol or code is separated from the digits by U+00A0, as CLDR's
+ currency spacing says ("CZK 5.00"); Babel does not do that itself.
+- CLDR unit patterns for durations, with CLDR plural rules. Babel's `format_unit` is not used: it
+ rounds the number to 3 decimals, chooses the plural from the unrounded value, and prints the raw
+ unit id ("30 duration-day") where a length has no pattern. The renderer formats the number as it
+ does any amount, chooses the plural category from the digits it shows ("1.0" is plural in English,
+ "2,5" is `many` in Czech), and looks the pattern up through one helper in `resolve/values.py`:
+ requested length, then the other lengths, then the language alone, then English, with a
+ `render-locale-fallback` Warning when English stands in.
- CLDR's ISO 4217 data to check currency codes
All formatting goes through the `locale/` module, so the dependency sits behind one interface.
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index cd44dc4..0301967 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -620,7 +620,13 @@ def _duration(self, directive: Directive, value: str) -> Inline:
return FailureMarker(f"[INVALID DURATION: {value}]")
if unit not in DURATION_UNITS:
return FailureMarker(f"[INVALID DURATION UNIT: {unit}]" if unit else "[INVALID DURATION UNIT]")
- return Value("duration", self.formatter.duration(value, unit))
+ shown = self.formatter.format_duration(value, unit)
+ if shown.english_fallback:
+ # One Warning per locale and unit: _warn drops a repeat. A value
+ # is never shown as a raw CLDR unit id (§10.5).
+ self._warn("render-locale-fallback",
+ f"No CLDR pattern for '{DURATION_UNITS[unit]}' in '{self.formatter.tag}'; English used.")
+ return Value("duration", shown.text)
def _field(self, directive: Directive, value: str) -> Inline:
field_type = directive.params.get("type")
diff --git a/src/legaldown_render/resolve/values.py b/src/legaldown_render/resolve/values.py
index 6e96538..f15ec9c 100644
--- a/src/legaldown_render/resolve/values.py
+++ b/src/legaldown_render/resolve/values.py
@@ -5,19 +5,24 @@
a period — and only their display varies by locale. Amounts are handled as
``Decimal`` from the source text and are never rounded or truncated (§10.3):
money is padded to its currency's minor units, and any further digits written
-in the source are kept.
+in the source are kept. A duration keeps every digit it is written with too,
+and its plural follows the digits it displays (§10.5).
"""
from __future__ import annotations
import datetime
+import decimal
import re
+from collections.abc import Iterator
+from contextlib import contextmanager
+from dataclasses import dataclass
from decimal import Decimal
from babel import Locale, UnknownLocaleError
from babel.dates import format_date
from babel.numbers import format_currency, parse_pattern
-from babel.units import format_unit
+from ..errors import InternalError
from ..style.model import Values
#: §10.5 duration units → CLDR unit ids.
@@ -31,6 +36,16 @@
"Y": "duration-year",
}
+#: CLDR unit-pattern lengths, in the order a missing length is replaced.
+_LENGTHS = ("long", "short", "narrow")
+
+#: A letter directly touching a digit, on either side. CLDR's currency
+#: spacing puts a no-break space between an alphabetic currency symbol and
+#: the digits ("CZK 5.00", "5,00 Kč"); Babel's patterns do not apply it, so
+#: it is applied here. A symbol that ends in a sign ("$", "R$", "CA$") has no
+#: letter next to the digits and is left alone.
+_LETTER_DIGIT = re.compile(r"(?<=[^\W\d_])(?=\d)|(?<=\d)(?=[^\W\d_])")
+
def parse_locale(tag: str) -> Locale | None:
"""The Babel locale for a BCP 47 *tag* (``cs-CZ``, ``en``), or None."""
@@ -40,6 +55,84 @@ def parse_locale(tag: str) -> Locale | None:
return None
+@dataclass(frozen=True)
+class DurationText:
+ """A formatted duration, and whether the locale had no duration pattern
+ for its unit at all, so that English is shown. The formatter stays free
+ of diagnostics: the resolver reports the fallback (``render-locale-fallback``)."""
+
+ text: str
+ english_fallback: bool
+
+
+@contextmanager
+def _exact(value: str) -> Iterator[None]:
+ """A decimal context in which the amount *value* is never rounded (§10.3).
+
+ The default context carries 28 significant digits: Babel's quantizing then
+ rounds a longer fraction and raises ``InvalidOperation`` for an integer of
+ 28 digits or more. ``Decimal(value)`` itself is always exact; the
+ precision here covers every digit of the source plus a margin for the
+ digits formatting adds (padding to a currency's minor units). With it no
+ operation on a valid amount can signal, so no trap is relaxed: one that
+ fired would be a bug, and surfaces as ``InternalError``.
+ """
+ precision = max(28, len(value) + 8)
+ context = decimal.Context(prec=precision, rounding=decimal.ROUND_HALF_EVEN,
+ Emin=decimal.MIN_EMIN, Emax=decimal.MAX_EMAX)
+ with decimal.localcontext(context):
+ yield
+
+
+def _unit_patterns(locale: Locale, unit_id: str, length: str) -> dict[str, str]:
+ """The CLDR patterns of *unit_id* at *length* in *locale*, by plural
+ category (``one``, ``few``, ``other``, …), empty when there are none. The
+ "per unit" form (``per``) is not a plural category and is left out.
+
+ Babel 2.x has no public API for a pattern by plural category: ``format_unit``
+ formats the number itself, rounding it to 3 decimals, and prints the unit
+ id when a length has no pattern. ``Locale._data`` is the one place the
+ patterns can be read, and it is read only here. Babel has already merged
+ the data a regional locale inherits into it, so no parent is looked up.
+ """
+ lengths = locale._data.get("unit_patterns", {}).get(unit_id, {}) # noqa: SLF001
+ patterns = lengths.get(length, {})
+ return {category: pattern for category, pattern in patterns.items() if category != "per"}
+
+
+def _duration_pattern(locale: Locale, unit_id: str, length: str, value: Decimal) -> tuple[str, bool]:
+ """The CLDR pattern for a duration of *value*, and whether it is English
+ for a locale that is not.
+
+ A raw unit id (``duration-day``) is never the answer. The lookup goes:
+ the requested length, then the other lengths (long, short, narrow); the
+ same for the locale's language alone; then English. In each set of
+ patterns the plural category of *value* is used, or ``other`` when the set
+ has no pattern for that category. The category comes from the locale
+ whose pattern is used, because it is that locale's grammar.
+ """
+ lengths = (length, *(other for other in _LENGTHS if other != length))
+ candidates = [locale]
+ language = parse_locale(locale.language)
+ if language is not None and str(language) != str(locale):
+ candidates.append(language)
+ for candidate in candidates:
+ category = candidate.plural_form(value)
+ for each in lengths:
+ patterns = _unit_patterns(candidate, unit_id, each)
+ pattern = patterns.get(category) or patterns.get("other")
+ if pattern:
+ return pattern, False
+ english = Locale("en")
+ category = english.plural_form(value)
+ for each in lengths:
+ patterns = _unit_patterns(english, unit_id, each)
+ pattern = patterns.get(category) or patterns.get("other")
+ if pattern:
+ return pattern, locale.language != "en"
+ raise InternalError(f"Babel has no English pattern for the unit '{unit_id}'. This is a bug.")
+
+
class Formatter:
"""Formats values for one locale and one style's ``values`` settings."""
@@ -58,6 +151,10 @@ def date(self, value: str) -> str:
def number(self, value: str) -> str:
"""A decimal amount with the locale's separators, keeping every
digit written after the decimal point."""
+ with _exact(value):
+ return self._number(value)
+
+ def _number(self, value: str) -> str:
decimals = len(value.partition(".")[2])
pattern = parse_pattern(self.locale.decimal_formats[None].pattern)
pattern.frac_prec = (decimals, decimals)
@@ -67,6 +164,10 @@ def money(self, amount: str, currency: str | None) -> str:
"""A money amount (already validated), with its currency when given."""
if not currency:
return self.number(amount)
+ with _exact(amount):
+ return self._money(amount, currency)
+
+ def _money(self, amount: str, currency: str) -> str:
value = Decimal(amount)
if self.values.money == "name":
return format_currency(value, currency, locale=self.locale, format_type="name",
@@ -76,12 +177,20 @@ def money(self, amount: str, currency: str | None) -> str:
standard = self.locale.currency_formats["standard"].pattern
pattern = standard.replace("¤", "¤¤") if "¤¤" not in standard else standard
text = format_currency(value, currency, format=pattern, locale=self.locale, decimal_quantization=False)
- if self.values.money == "code":
- # CLDR spaces a letter code from the digits ("USD 10,000.00");
- # Babel does not apply that rule, so it is applied here.
- text = re.sub(rf"^{currency}(?=\d)", f"{currency}\u00a0", text)
- return text
+ # CLDR spaces an alphabetic symbol or code from the digits, on
+ # whichever side it stands ("USD 10,000.00", "10 000,00 CZK").
+ return _LETTER_DIGIT.sub(" ", text)
def duration(self, value: str, unit: str) -> str:
"""A duration (already validated) such as "12 months" or "12 měsíců"."""
- return format_unit(Decimal(value), DURATION_UNITS[unit], length=self.values.duration, locale=self.locale)
+ return self.format_duration(value, unit).text
+
+ def format_duration(self, value: str, unit: str) -> DurationText:
+ """A duration with every digit as written ("0.0001 days"), its plural
+ chosen from those digits as CLDR defines it ("1.0 days" in English,
+ "2,5 roku" in Czech), and whether English had to stand in for a
+ locale without a pattern (see :func:`_duration_pattern`)."""
+ with _exact(value):
+ pattern, english = _duration_pattern(
+ self.locale, DURATION_UNITS[unit], self.values.duration, Decimal(value))
+ return DurationText(pattern.replace("{0}", self._number(value)), english)
diff --git a/tests/test_wp1_values.py b/tests/test_wp1_values.py
new file mode 100644
index 0000000..e19fbc8
--- /dev/null
+++ b/tests/test_wp1_values.py
@@ -0,0 +1,170 @@
+"""Value formatting (§10, §13.5): exact amounts, durations that keep their
+written digits, no raw CLDR unit id, and spaced alphabetic currency symbols."""
+from __future__ import annotations
+
+import pytest
+
+from legaldown_render import render
+from legaldown_render.resolve import DURATION_UNITS, Formatter, parse_locale
+from legaldown_render.resolve.values import DurationText
+from legaldown_render.style.model import Values
+
+
+def formatter(tag: str, **values: str) -> Formatter:
+ locale = parse_locale(tag)
+ assert locale is not None
+ return Formatter(locale, Values(**values))
+
+
+# -- R2: no crash, no rounding on long amounts (§10.3) -----------------------
+
+@pytest.mark.parametrize("digits", [29, 40, 60])
+def test_long_integers_format_exactly(digits: int) -> None:
+ value = "1234567890" * 6
+ value = value[:digits]
+ grouped = f"{int(value):,}"
+ en = formatter("en-US")
+ assert en.number(value) == grouped
+ assert en.money(value, "USD") == f"${grouped}.00"
+ assert formatter("en-US", money="code").money(value, "USD") == f"USD\xa0{grouped}.00"
+ assert formatter("en-US", money="name").money(value, "USD") == f"{grouped}.00 US dollars"
+
+
+def test_long_fraction_keeps_every_digit() -> None:
+ fraction = "1234567891" * 4
+ en = formatter("en-US")
+ assert en.number("0." + fraction) == "0." + fraction
+ assert en.money("1." + fraction, "EUR") == "€1." + fraction
+ assert en.number("1" * 29 + ".5") == f"{int('1' * 29):,}.5"
+
+
+def test_long_duration_keeps_every_digit() -> None:
+ assert formatter("en-US").duration("1" * 40, "D") == f"{int('1' * 40):,} days"
+ assert formatter("en-US").duration("0." + "1" * 40, "D") == "0." + "1" * 40 + " days"
+
+
+def test_long_amount_renders_end_to_end() -> None:
+ source = "---\ntitle: T\n---\nPay {{money: " + "9" * 29 + "}} {{money: 12345678901234567890123456789.1234567890123456789}}.\n"
+ output = render(source, format="text", locale="en-US").output
+ assert f"{int('9' * 29):,}" in output
+ assert "12,345,678,901,234,567,890,123,456,789.1234567890123456789" in output
+
+
+# -- R3: durations keep their digits; the plural follows the displayed value -
+
+@pytest.mark.parametrize(("tag", "value", "unit", "expected"), [
+ ("en", "0.0001", "D", "0.0001 days"),
+ ("en", "1.23456", "Y", "1.23456 years"),
+ ("en", "1", "D", "1 day"),
+ ("en", "1.0", "D", "1.0 days"),
+ ("en", "12", "MO", "12 months"),
+ ("cs", "1", "Y", "1 rok"),
+ ("cs", "2", "Y", "2 roky"),
+ ("cs", "5", "Y", "5 let"),
+ ("cs", "2.5", "Y", "2,5 roku"),
+ ("de", "1.50", "W", "1,50 Wochen"),
+])
+def test_duration_digits_and_plural(tag: str, value: str, unit: str, expected: str) -> None:
+ assert formatter(tag).duration(value, unit) == expected
+
+
+# -- R1: never a raw CLDR unit id ---------------------------------------------
+
+@pytest.mark.parametrize("tag", ["pt-BR", "ja", "zh", "ko", "it", "tr", "nl", "cs", "de", "sk", "pl", "en"])
+@pytest.mark.parametrize("length", ["long", "short", "narrow"])
+@pytest.mark.parametrize("value", ["1", "2", "5", "1.5"])
+def test_no_cldr_unit_id_is_ever_printed(tag: str, length: str, value: str) -> None:
+ f = formatter(tag, duration=length)
+ for unit in DURATION_UNITS:
+ text = f.duration(value, unit)
+ assert "duration-" not in text
+ assert value.replace(".", f.locale.number_symbols["latn"]["decimal"]) in text
+
+
+def test_pt_br_long_falls_back_to_the_short_pattern() -> None:
+ pt = formatter("pt-BR")
+ assert pt.duration("30", "D") == "30 dias"
+ assert pt.duration("12", "MO") == "12 meses"
+ assert pt.duration("1", "Y") == "1 ano"
+
+
+def test_fallback_goes_to_the_other_length_first() -> None:
+ # cs has a long and a narrow pattern: narrow is asked, narrow is used.
+ assert formatter("cs", duration="narrow").duration("30", "D") == "30 d."
+ # zh has only "short": long and narrow fall back to it, still Chinese.
+ assert formatter("zh", duration="long").duration("30", "D") == "30天"
+ assert formatter("zh", duration="narrow").duration("30", "D") == "30天"
+
+
+def test_a_fallback_inside_the_locale_is_not_signalled() -> None:
+ # pt-BR long has no pattern but short has: Portuguese, no English.
+ assert formatter("pt-BR").format_duration("30", "D") == DurationText("30 dias", False)
+ assert formatter("en").format_duration("1", "D") == DurationText("1 day", False)
+
+
+def test_english_is_the_last_resort_and_is_signalled(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown_render.resolve import values
+
+ real = values._unit_patterns
+ # A Czech locale without any duration pattern (Babel's data has none such).
+ monkeypatch.setattr(values, "_unit_patterns",
+ lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
+ f = formatter("cs")
+ assert f.format_duration("5", "D") == DurationText("5 days", True)
+ assert f.duration("1", "D") == "1 day"
+ assert f.duration("1.5", "Y") == "1,5 years" # the number keeps the locale's separator
+
+
+def test_fallback_warning_is_emitted_once_by_the_resolver(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown_render.resolve import values
+
+ real = values._unit_patterns
+ monkeypatch.setattr(values, "_unit_patterns",
+ lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
+ source = ("---\ntitle: T\nlanguage: cs\n---\n"
+ "Lhůta {{duration: 5, unit=D}} a {{duration: 6, unit=D}} a {{duration: 2, unit=MO}}.\n")
+ result = render(source, format="text")
+ fallbacks = [d for d in result.diagnostics if d.rule == "render-locale-fallback"]
+ assert [d.message for d in fallbacks] == [
+ "No CLDR pattern for 'duration-day' in 'cs'; English used.",
+ "No CLDR pattern for 'duration-month' in 'cs'; English used.",
+ ]
+ assert all(d.level == "warning" for d in fallbacks)
+ assert "5 days" in result.output
+
+
+def test_normal_locales_emit_no_fallback_warning() -> None:
+ for language in ("cs", "pt-BR", "ja", "en"):
+ source = f"---\ntitle: T\nlanguage: {language}\n---\nX {{{{duration: 5, unit=D}}}}.\n"
+ result = render(source, format="text")
+ assert not [d for d in result.diagnostics if d.rule == "render-locale-fallback"], language
+
+
+# -- R4: an alphabetic symbol is spaced from the digits -----------------------
+
+@pytest.mark.parametrize(("tag", "currency", "expected"), [
+ ("en", "CZK", "CZK\xa05.00"),
+ ("en", "CHF", "CHF\xa05.00"),
+ ("en", "USD", "$5.00"),
+ ("en", "EUR", "€5.00"),
+ ("pt-BR", "BRL", "R$\xa05,00"),
+ ("en", "CAD", "CA$5.00"),
+ ("cs", "CZK", "5,00\xa0Kč"),
+])
+def test_symbol_mode_spacing(tag: str, currency: str, expected: str) -> None:
+ assert formatter(tag).money("5", currency) == expected
+
+
+def test_symbol_after_the_digits_is_spaced_too() -> None:
+ # Whatever the locale puts after the amount, a letter never touches a digit.
+ import re
+
+ for tag in ("en", "de", "fr", "cs", "pl", "sv", "ja", "ru", "pt-PT", "es", "nl", "it"):
+ for currency in ("CZK", "CHF", "USD", "EUR", "SEK", "PLN", "XAF"):
+ text = formatter(tag).money("5", currency)
+ assert not re.search(r"[^\W\d_]\d|\d[^\W\d_]", text), (tag, currency, text)
+
+
+def test_code_mode_still_spaced() -> None:
+ assert formatter("en", money="code").money("5", "CZK") == "CZK\xa05.00"
+ assert formatter("de", money="code").money("5", "CZK") == "5,00\xa0CZK"
From e5196f8e7e299a50f67a1a46dc25615db2fd58dc Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:40:38 +0000
Subject: [PATCH 003/118] Build fidelity: comment-only items and quotes,
emphasis next to directives, dropped-comment spacing, {{term:}} text (B2, B3,
B4, B8)
- B2: a list item whose model blocks are all comments/raw HTML (kind "html")
is left out, after the whole list is traversed so item numbering and
PlacedMarker.item stay aligned; a list left with no items and an ordinary
quote with no built blocks are left out. A truly empty item and an empty
drafting note keep their place. (build.py _list, list, quote)
- B3: every directive sentinel is wrapped in U+2E31 (category Po, accepted by
markdown-it's isPunctChar) on both sides, as the braces it replaces are
punctuation, so `_text_{{ref: a}}`, `{{ref: a}}_text_`, `__b__{{ref: a}}`
and `*t*{{ref: a}}` parse as with `{x}`. sentinel_re, encoded_sentinel_re
and _restore consume the wrappers; the hidden form's "h" tag is no longer
needed. (build.py)
- B4: _merge_text strips the spaces before a dropped inline when a line break
follows, so no double space is left.
- B8: Resolver._survey collects definition id -> plain rendered term text
(first definition wins, via _title_text); {{term:}} uses it, falling back
to the validator's definition_lookup.
Existing test changed: test_empty_list_items_keep_their_place is extended
(an empty item still keeps its place; a comment-only one goes). Goldens are
unchanged. Docs: CONFORMANCE.md, docs/architecture.md.
---
CONFORMANCE.md | 9 +
docs/architecture.md | 13 +-
src/legaldown_render/build.py | 79 +++++---
src/legaldown_render/resolve/resolver.py | 21 +-
tests/test_render.py | 5 +
tests/test_wp4_build.py | 246 +++++++++++++++++++++++
6 files changed, 343 insertions(+), 30 deletions(-)
create mode 100644 tests/test_wp4_build.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index dd09b8d..f8b7774 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -54,6 +54,15 @@ It is checked against the specification's own examples and fixtures corpus (`tes
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.
+- **Comment-only items and quotes.** A list item whose blocks are all comments or raw HTML is
+ left out (§8.6: the author removed it), and so is a list left with no items and a quote that
+ holds only comments. Numbers and designations are those of the items that remain. An item with
+ no content at all keeps its place (CommonMark), and so does an empty drafting note, whose label
+ is the template construct (§15.6).
+- **`{{term:}}` text.** A `{{term:}}` shows the term as rendered at its first definition in the
+ body, with its Markdown resolved (`"A *b* c" {{def: abc}}` is "A b c"), not the term's raw
+ source; `label=` overrides it. A definition not in the body (a template's other alternative)
+ falls back to the validator's term text.
- **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
diff --git a/docs/architecture.md b/docs/architecture.md
index f8a25fb..4fe9320 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -96,13 +96,18 @@ Markdown cannot reinterpret directive syntax, such as an underscore in an identi
random for each build, so no text in a document, whether written literally, as an entity, or
percent-encoded, can pass for a sentinel. Emphasis that wraps only a defined term is dropped with
it, because the style decides how defined terms look (§7.2). A `{{def:}}` becomes a sentinel
-that renders nothing, led by a punctuation character so that an emphasis closer just before it
-still closes. A defined term in a link title or alt text shows its term but is not the
+that renders nothing. Every sentinel is wrapped in a punctuation character (U+2E31, Unicode
+category Po, which markdown-it's `isPunctChar` accepts) on both sides, as the `{{` and `}}` it
+replaces are punctuation, so that CommonMark's emphasis flanking rules see the same neighbours as
+in the source: `_text_{{ref: a}}` closes its emphasis. A defined term in a link title or alt text shows its term but is not the
definition's anchor.
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
+one space stays, and before a line break none does. A list item whose blocks are all comments,
+and a quote that holds only comments, are dropped (with a list left empty), after the whole list
+is traversed so that the validator's item numbers stay aligned with the markers it placed. 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
@@ -135,7 +140,7 @@ Guessing at lost structure was tried, and it traded each gap for new bugs.
| Directive | Resolves to | Failure markers |
|---|---|---|
| `{{ref:}}` | `CrossRef(designation, anchor)` (§13.3). A ref to an unnumbered item or paragraph falls back to its section's designation, with `ref-not-enumerated` | `[BROKEN REF: id]` |
-| `{{term:}}` | `TermRef(label or term, def anchor)` (§7.3, §13.4) | `[UNDEFINED: id]` |
+| `{{term:}}` | `TermRef(label or the definition's rendered term text, def anchor)` (§7.3, §13.4); the text is collected in `Resolver._survey` | `[UNDEFINED: id]` |
| `"Term" {{def:}}` | `DefinedTerm` without quotation marks, anchored at `def:` (§7.2) | n/a |
| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005) | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` |
| `{{party:}}` `{{side:}}` | `Value(label or display name)` (§3.6) | `[INVALID PARTY: …]`, `[UNKNOWN PARTY: …]`, and the side forms |
diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py
index d99bdd4..2d8e568 100644
--- a/src/legaldown_render/build.py
+++ b/src/legaldown_render/build.py
@@ -14,7 +14,10 @@
``\\ue000:\\ue001``, so Markdown can never reinterpret directive
syntax, and put back as tree nodes afterwards. The nonce is random for every
build, so no text in a document — written out, as an entity, or
-percent-encoded — can pass for a sentinel.
+percent-encoded — can pass for a sentinel. Each sentinel is wrapped in
+Unicode punctuation on both sides, as the ``{{`` and ``}}`` it replaces are
+punctuation, so that Markdown's emphasis rules (CommonMark's left- and
+right-flanking delimiter runs) see the same neighbours as in the source.
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
@@ -74,10 +77,17 @@
)
_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"
+# Wraps every sentinel, before and after. A directive's braces are Unicode
+# punctuation, and CommonMark decides whether an emphasis delimiter run opens
+# or closes from the punctuation and whitespace next to it; a bare sentinel
+# (private use, category Co) reads like a letter and would change that:
+# ``_text_{{ref: a}}`` would not close its emphasis. U+2E31 is category Po —
+# markdown-it-py's ``isPunctChar`` accepts only categories P* — and too rare
+# in a document to matter, and the pairs the builder adds are the only ones
+# the sentinel patterns consume.
+_WRAP = "\u2e31"
+_WRAPPERS = (_WRAP,)
+_ENCODED_WRAP = "".join(f"%{byte:02X}" for byte in _WRAP.encode())
# 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("")
@@ -181,12 +191,12 @@ def __init__(self, document: Document, result: ValidationResult) -> None:
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.
- self.sentinel_re = re.compile(f"(?:{_HIDDEN_LEAD}{_OPEN}{nonce}h|{_OPEN}{nonce}):(\\d+){_CLOSE}")
+ # A sentinel with its wrappers; only a complete one matches, so a
+ # wrapper character in the source is never taken.
+ self.sentinel_re = re.compile(f"{_WRAP}{_OPEN}{nonce}:(\\d+){_CLOSE}{_WRAP}")
# How markdown-it percent-encodes a sentinel inside a URL.
self.encoded_sentinel_re = re.compile(
- f"(?:%E2%B8%B1%EE%80%80{nonce}h|%EE%80%80{nonce}):(\\d+)%EE%80%81", re.IGNORECASE)
+ f"{_ENCODED_WRAP}%EE%80%80{nonce}:(\\d+)%EE%80%81{_ENCODED_WRAP}", re.IGNORECASE)
self.nonce = nonce
# -- sentinels ------------------------------------------------------------
@@ -194,9 +204,7 @@ def __init__(self, document: Document, result: ValidationResult) -> None:
def _sentinel(self, payload: _Payload) -> str:
self.payloads.append(payload)
index = len(self.payloads) - 1
- if isinstance(payload, _Hidden):
- return f"{_HIDDEN_LEAD}{_OPEN}{self.nonce}h:{index}{_CLOSE}"
- return f"{_OPEN}{self.nonce}:{index}{_CLOSE}"
+ return f"{_WRAP}{_OPEN}{self.nonce}:{index}{_CLOSE}{_WRAP}"
def _protect(self, body: str) -> str:
"""*body* — one block's text — with every directive replaced by a
@@ -392,16 +400,24 @@ def block(self, block: ModelBlock, placed: list[PlacedMarker], *, top_level: boo
return Paragraph((Strong(inlines),)) if inlines else None
raise InternalError(f"unexpected block kind '{block.kind}' in the validator's model")
- def list(self, block: ModelBlock, placed: list[PlacedMarker]) -> List:
+ def list(self, block: ModelBlock, placed: list[PlacedMarker]) -> List | None:
"""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."""
+ number among all the list's items, as the validator numbers them.
+ None for a list whose every item is only a comment."""
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:
+ def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numbers: Iterator[int]) -> List | None:
"""*block*'s items, numbered from *numbers* in pre-order, to find the
- items *markers* belong to."""
+ items *markers* belong to.
+
+ An item whose blocks are all comments or raw HTML is the author
+ removing it (§8.6), and is left out — after the whole list is
+ traversed, so the numbers (and with them the markers) stay those of
+ the validator's list. An item with no blocks at all is an empty item
+ (CommonMark) and keeps its place, so later items keep their numbers.
+ A list left with no items is left out."""
self._check_depth(1)
self.depth += 1
try:
@@ -416,15 +432,19 @@ def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numb
blocks: list[Block] = []
for child in children:
if child.kind in ("ordered_list", "unordered_list"):
- blocks.append(self._list(child, markers, numbers))
+ nested = self._list(child, markers, numbers)
+ if nested is not None:
+ blocks.append(nested)
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)
+ if item.blocks and all(child.kind == "html" for child in item.blocks):
+ continue # only a comment: the item is removed
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))
+ return List(ordered=block.kind == "ordered_list", items=tuple(items)) if items else None
def _check_depth(self, more: int) -> None:
"""Refuse a document that nests lists, quotes, and inline formatting
@@ -433,13 +453,15 @@ def _check_depth(self, more: int) -> None:
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:
+ def quote(self, block: ModelBlock) -> Block | None:
"""A block quote, its content the blocks the validator reads in it
(``quote_blocks``). Past the validator's quote depth, it reads the
quote's text as one paragraph, and so does the builder. A drafting
note is decided by the validator's own test (§15.6), and its content
is the validator's reading without the ``[!DRAFTING]`` marker
- (``drafting_note_blocks``)."""
+ (``drafting_note_blocks``). A quote that holds nothing but comments
+ is left out (None); an empty drafting note stays, since its label is
+ the template construct the reader sees (§15.6)."""
drafting = is_drafting_note(block)
self._check_depth(1)
# The quotes and list items the quote is in, before entering it.
@@ -450,7 +472,9 @@ def quote(self, block: ModelBlock) -> Block:
blocks = self.blocks(children, None, markers=False)
finally:
self.depth -= 1
- return DraftingNote(blocks) if drafting else Quote(blocks)
+ if drafting:
+ return DraftingNote(blocks)
+ return Quote(blocks) if blocks else None
# -- document -------------------------------------------------------------
@@ -520,7 +544,8 @@ def _trim(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]:
def _merge_text(inlines: list[Inline]) -> tuple[Inline, ...]:
"""*inlines* with adjacent text joined. Where HTML or a comment was
dropped between two spaces, one of them goes: ``a b`` reads
- "a b". A dropped `` `` between two words leaves one space."""
+ "a b". A dropped `` `` between two words leaves one space. Before a
+ line break, the spaces before a dropped inline all go."""
merged: list[Inline] = []
dropped = parted = False
for inline in inlines:
@@ -529,7 +554,15 @@ def _merge_text(inlines: list[Inline]) -> tuple[Inline, ...]:
parted = parted or inline is _DROPPED_BREAK
continue
before = merged[-1] if merged else None
- if dropped and isinstance(inline, Text) and isinstance(before, Text) and before.text.endswith(" "):
+ if dropped and isinstance(inline, SoftBreak | HardBreak) and isinstance(before, Text):
+ # The comment ended its line: the space before it goes, or the
+ # line would end in one (and "a \nb" read "a b").
+ stripped = before.text.rstrip(" ")
+ merged.pop()
+ if stripped:
+ merged.append(Text(stripped))
+ before = merged[-1] if merged else None
+ elif dropped and isinstance(inline, Text) and isinstance(before, Text) and before.text.endswith(" "):
inline = Text(inline.text.lstrip(" "))
elif parted and before is not None and not isinstance(inline, SoftBreak | HardBreak):
if isinstance(before, Text):
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index cd44dc4..cc5e055 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -147,6 +147,9 @@ def __init__(
questions = self.metadata.questions
self.questions: dict[str, Any] = questions if isinstance(questions, dict) else {}
self.inconsistent_placeholders: set[str] = set()
+ # A definition id -> the plain text of its term as rendered (Markdown
+ # resolved), from the first definition in the body (see _survey).
+ self.term_texts: dict[str, str] = {}
self.attachment_anchors: dict[str, str | None] = {}
self.section_presences: list[Presence] = []
@@ -238,10 +241,19 @@ def _settle_term_target(self, inline: Inline) -> Inline:
def _survey(self) -> None:
"""One pass over the tree before resolving: which placeholder ids
- are used with conflicting types (§10.7)."""
+ are used with conflicting types (§10.7), and what each defined term
+ reads as, for ``{{term:}}`` (§7.3)."""
types: dict[str, set[str]] = {}
for inline in iter_tree_inlines(self.tree):
- if isinstance(inline, DirectiveSource):
+ if isinstance(inline, DefinitionSource):
+ # The first definition of an id is the one the validator's
+ # lookup and the anchor keep. The term is the text the reader
+ # sees at the definition: its Markdown is resolved, not the
+ # validator's raw ``anchor.term``.
+ identifier = self._definition_id(inline)
+ if identifier not in self.term_texts:
+ self.term_texts[identifier] = self._title_text(inline.children)
+ elif isinstance(inline, DirectiveSource):
directive = inline.directive
if directive.name == "placeholder" and directive.positional and not directive.malformed:
types.setdefault(directive.positional, set()).add(self._placeholder_type(directive))
@@ -580,7 +592,10 @@ def _term(self, directive: Directive, definition_id: str) -> Inline:
term = self.result.index.definition_lookup.get(definition_id)
if term is None:
return FailureMarker(f"[UNDEFINED: {definition_id}]")
- text = directive.params.get("label") or self._plain_value(term)
+ # The term as rendered at its definition; the validator's own text
+ # (an empty term reads as its id, a definition not in the body) is
+ # the fallback.
+ text = directive.params.get("label") or self.term_texts.get(definition_id) or self._plain_value(term)
# Settled once everything is resolved (see _settle_term_target).
target = DEFINITION_ANCHOR_PREFIX + definition_id
return TermRef(text, target, self.style.definitions.term_style)
diff --git a/tests/test_render.py b/tests/test_render.py
index d3c8f7b..8aac3c0 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -922,6 +922,11 @@ def test_empty_list_items_keep_their_place() -> None:
output = text("# A\n\n- \n - \n- b {#b}\n\nSee {{ref: b}}.\n")
assert "(a)\n (i)\n(b) b" in output
assert "See 1(b)." in output
+ # An item that holds only a comment is the author removing it (§8.6):
+ # it is left out, and the numbers are still the validator's.
+ output = text("# A\n\n- \n- \n- b {#b}\n\nSee {{ref: b}}.\n")
+ assert "(a)\n(b) b" in output
+ assert "See 1(b)." in output
@pytest.mark.parametrize("output_format", ["html", "text"])
diff --git a/tests/test_wp4_build.py b/tests/test_wp4_build.py
new file mode 100644
index 0000000..f21ae17
--- /dev/null
+++ b/tests/test_wp4_build.py
@@ -0,0 +1,246 @@
+"""Build fidelity (WP4): what the builder keeps of a comment-only item or
+quote (B2), how emphasis next to a directive parses (B3), the spacing where
+an inline comment was dropped (B4), and what ``{{term:}}`` shows (B8)."""
+from __future__ import annotations
+
+import html as html_escape
+import unicodedata
+
+import pytest
+from legaldown import parse, validate
+from markdown_it import MarkdownIt
+from markdown_it.common.utils import isPunctChar
+
+from legaldown_render import render
+from legaldown_render.build import _WRAPPERS, build_tree
+from legaldown_render.tree import (
+ DirectiveSource,
+ DraftingNote,
+ Emphasis,
+ Paragraph,
+ Quote,
+ Strong,
+ Text,
+)
+
+FRONT = "---\ntitle: T\n---\n"
+
+
+def text(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="text", **settings).output
+
+
+def html(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="html", **settings).output
+
+
+def built(body: str):
+ document = parse(FRONT + body)
+ return build_tree(document, validate(document))
+
+
+def blocks_of(body: str):
+ return built(body).sections[0].blocks
+
+
+# -- B2: comment-only list items and quotes ----------------------------------
+
+
+def test_a_list_item_holding_only_a_comment_is_dropped() -> None:
+ output = text("# A\n\n- a\n- \n- c\n")
+ assert "(a) a\n(b) c" in output
+ assert "(c)" not in output
+
+
+def test_dropping_a_comment_only_item_keeps_markers_on_their_items() -> None:
+ # Numbering and the marker-to-item mapping are those of the validator's
+ # (unfiltered) list; only the output drops the item.
+ body = "# A\n\n- \n- b {#b}\n - \n - bb {#bb}\n- c {#c}\n\nSee {{ref: b}}, {{ref: bb}}, {{ref: c}}.\n"
+ output = text(body)
+ assert "(a) b\n (i) bb\n(b) c" in output
+ assert "See 1(a), 1(a)(i), 1(b)." in output
+
+
+def test_a_list_left_with_no_items_is_dropped() -> None:
+ output = text("# A\n\nBefore.\n\n- \n- \n\nAfter.\n")
+ assert "(a)" not in output
+ assert "Before.\n\nAfter." in output
+ assert "
\n")
+ assert "\n")
+
+
+def test_an_empty_item_keeps_its_place_but_a_comment_only_one_does_not() -> None:
+ # The validator keeps an empty item (CommonMark); a comment is the
+ # author's way to remove one.
+ output = text("# A\n\n- \n- \n- b\n")
+ assert "(a)\n(b) b" in output
+
+
+def test_an_item_with_a_comment_and_text_stays() -> None:
+ assert "(a) kept" in text("# A\n\n- kept \n")
+
+
+def test_an_item_with_a_nested_list_stays_even_when_its_own_text_is_a_comment() -> None:
+ output = text("# A\n\n- \n - inner\n")
+ assert "inner" in output
+
+
+def test_a_comment_only_quote_is_dropped() -> None:
+ assert "
\n")
+ assert blocks_of("# A\n\n> \n") == ()
+ assert [type(b) for b in blocks_of("# A\n\n> \n\nText.\n")] == [Paragraph]
+
+
+def test_a_quote_with_text_stays() -> None:
+ assert [type(b) for b in blocks_of("# A\n\n> said \n")] == [Quote]
+
+
+def test_an_empty_drafting_note_keeps_its_label() -> None:
+ blocks = blocks_of("# A\n\n> [!DRAFTING]\n> \n")
+ assert [type(b) for b in blocks] == [DraftingNote]
+ assert "Drafting note" in html("# A\n\n> [!DRAFTING]\n> \n") or "ld-drafting" in html(
+ "# A\n\n> [!DRAFTING]\n> \n")
+
+
+def test_a_quote_nested_in_a_quote_that_is_comment_only_is_dropped_with_it() -> None:
+ assert blocks_of("# A\n\n> > \n") == ()
+
+
+def test_a_comment_only_item_does_not_leave_an_empty_list_in_an_item() -> None:
+ output = text("# A\n\n- a\n - \n- b\n")
+ assert "(a) a\n(b) b" in output
+
+
+# -- B3: emphasis flanking next to a directive -------------------------------
+
+
+def _signature(inlines) -> str:
+ out = []
+ for inline in inlines:
+ match inline:
+ case Text(text=value):
+ out.append(html_escape.escape(value, quote=False))
+ case DirectiveSource():
+ out.append("{x}")
+ case Emphasis(children=children):
+ out.append(f"{_signature(children)}")
+ case Strong(children=children):
+ out.append(f"{_signature(children)}")
+ case _:
+ raise AssertionError(inline)
+ return "".join(out)
+
+
+@pytest.mark.parametrize("source", [
+ "_text_{{ref: a}}",
+ "{{ref: a}}_text_",
+ "__b__{{ref: a}}",
+ "{{ref: a}}__b__",
+ "*t*{{ref: a}}",
+ "{{ref: a}}*t*",
+ "x _text_{{ref: a}} y",
+ "x{{ref: a}}_text_y",
+ "*a{{ref: a}}* b",
+ "**a{{ref: a}}**b",
+ "_{{ref: a}}_",
+ "_a_{{ref: a}}_b_",
+ "foo_{{ref: a}}_bar",
+])
+def test_emphasis_next_to_a_directive_parses_as_it_does_next_to_a_brace(source: str) -> None:
+ body = f"# A {{#a}}\n\n{source}\n"
+ paragraphs = [b for b in blocks_of(body) if isinstance(b, Paragraph)]
+ expected = MarkdownIt("commonmark").renderInline(source.replace("{{ref: a}}", "{x}"))
+ assert _signature(paragraphs[0].inlines) == expected
+
+
+def test_every_sentinel_wrapper_is_punctuation_to_markdown_it() -> None:
+ wrappers = _WRAPPERS
+ assert wrappers
+ for char in wrappers:
+ assert isPunctChar(char), hex(ord(char))
+ assert unicodedata.category(char).startswith("P")
+
+
+def test_a_directive_next_to_emphasis_renders() -> None:
+ output = html("# A {#a}\n\n_text_{{ref: a}} and {{ref: a}}_more_\n")
+ assert "text" in output and "more" in output
+
+
+def test_wrappers_do_not_leak_into_code_urls_or_alt_text() -> None:
+ output = text("# A {#a}\n\n`{{ref: a}}` [x](http://e.com/{{ref: a}}) \n")
+ assert "{{ref: a}}" in output
+ for char in _WRAPPERS:
+ assert char not in output
+
+
+# -- B4: spacing where an inline comment was dropped -------------------------
+
+
+@pytest.mark.parametrize(("body", "expected"), [
+ ("one \ntwo\n", "one two"),
+ ("one \ntwo\n", "one\ntwo"),
+ ("one \\\ntwo\n", "one\ntwo"),
+ ("one \ntwo\n", "one two"),
+ ("one \ntwo\n", "one two"),
+])
+def test_no_double_space_before_a_line_break(body: str, expected: str) -> None:
+ assert text("# A\n\n" + body).endswith("\n\n" + expected + "\n")
+
+
+def test_no_trailing_space_when_a_dropped_comment_ends_the_block() -> None:
+ paragraph = blocks_of("# A\n\nword \n")[0]
+ assert [i.text for i in paragraph.inlines] == ["word"]
+
+
+def test_a_space_still_parts_words_around_a_dropped_comment() -> None:
+ assert "a b" in text("# A\n\na b\n")
+ assert "a b" in text("# A\n\na b\n")
+
+
+def test_a_dropped_comment_between_text_and_a_hard_break_leaves_no_space() -> None:
+ paragraph = blocks_of("# A\n\none \\\ntwo\n")[0]
+ first = paragraph.inlines[0]
+ assert isinstance(first, Text) and first.text == "one"
+
+
+# -- B8: what {{term:}} shows ------------------------------------------------
+
+
+def test_a_term_reference_shows_the_rendered_text_of_the_term() -> None:
+ output = text('# A\n\n"A *b* c" {{def: abc}} means it.\n\nSee {{term: abc}}.\n')
+ assert "See A b c." in output
+ assert "*" not in output.split("See", 1)[1]
+
+
+def test_a_term_reference_label_still_wins() -> None:
+ output = text('# A\n\n"A *b* c" {{def: abc}} means it.\n\nSee {{term: abc, label=the thing}}.\n')
+ assert "See the thing." in output
+
+
+def test_a_term_reference_resolves_directives_inside_the_term() -> None:
+ body = '# A {#a}\n\n"Fee **under** {{ref: a}}" {{def: fee}} means it.\n\nSee {{term: fee}}.\n'
+ output = text(body)
+ assert "See Fee under" in output
+
+
+def test_a_term_reference_uses_the_first_definition() -> None:
+ body = '# A\n\n"First *one*" {{def: t}} here.\n\n"Second" {{def: t}} there.\n\nSee {{term: t}}.\n'
+ assert "See First one." in text(body)
+
+
+def test_a_term_reference_before_its_definition() -> None:
+ body = '# A\n\nSee {{term: abc}} first.\n\n"A *b* c" {{def: abc}} means it.\n'
+ assert "See A b c first." in text(body)
+
+
+def test_a_plain_term_reference_is_unchanged() -> None:
+ assert "See Fee." in text('# A\n\n"Fee" {{def: fee}} means it.\n\nSee {{term: fee}}.\n')
+
+
+def test_an_empty_term_falls_back_to_the_validator() -> None:
+ output = text('# A\n\n"" {{def: late-fee}} means it.\n\nSee {{term: late-fee}}.\n')
+ assert "See Late Fee." in output
+
+
+def test_an_undefined_term_is_still_reported() -> None:
+ assert "[UNDEFINED: nope]" in text("# A\n\nSee {{term: nope}}.\n")
From ef856757e5d44ff7e4ec3df1fd3c258349fcb19b Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:40:44 +0000
Subject: [PATCH 004/118] render_file(): a non-UTF-8 file raises DocumentError
(S4, API side)
render_file() read the file with read_text, so a non-UTF-8 file raised a
bare UnicodeDecodeError. It now reads bytes and raises DocumentError with
the CLI's wording ("not valid UTF-8 (byte 0x.. at offset N)").
Behaviour change: DocumentError is not a ValueError, so code that caught
UnicodeDecodeError/ValueError from render_file for this case must catch
DocumentError (or RenderError). OSError for unreadable files is unchanged.
Test: tests/test_wp2_cli.py::test_render_file_raises_document_error_for_non_utf8.
---
src/legaldown_render/api.py | 13 +++++++++++--
tests/test_wp2_cli.py | 12 ++++++++++++
2 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py
index 0fb88e7..508ce04 100644
--- a/src/legaldown_render/api.py
+++ b/src/legaldown_render/api.py
@@ -123,8 +123,17 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
def render_file(path: str | Path, options: RenderOptions | None = None, /, **settings: Any) -> RenderResult:
- """Render the LegalDown file at *path* (see :func:`render`)."""
- return render(Path(path).read_text(encoding="utf-8"), options, **settings)
+ """Render the LegalDown file at *path* (see :func:`render`). Raises
+ :class:`DocumentError` when the file is not valid UTF-8, as for any
+ document that cannot be read at all; an unreadable file raises
+ :class:`OSError`."""
+ data = Path(path).read_bytes()
+ try:
+ source = data.decode("utf-8")
+ except UnicodeDecodeError as error:
+ raise DocumentError(f"The document cannot be read: {path} is not valid UTF-8 "
+ f"(byte 0x{data[error.start]:02x} at offset {error.start})") from None
+ return render(source, options, **settings)
def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagnostic]]:
diff --git a/tests/test_wp2_cli.py b/tests/test_wp2_cli.py
index 8b864fd..949fcff 100644
--- a/tests/test_wp2_cli.py
+++ b/tests/test_wp2_cli.py
@@ -18,6 +18,7 @@
import legaldown_render
import legaldown_render.cli as cli
from legaldown_render import (
+ DocumentError,
OptionsError,
RenderError,
RenderOptions,
@@ -25,6 +26,7 @@
StyleError,
load_style,
render,
+ render_file,
)
from legaldown_render.cli import main
@@ -122,6 +124,16 @@ def test_non_utf8_style_file_is_a_style_error(tmp_path: Path, capsys: pytest.Cap
assert "Traceback" not in err
+def test_render_file_raises_document_error_for_non_utf8(tmp_path: Path) -> None:
+ path = tmp_path / "latin1.lgd"
+ path.write_bytes(b"# Caf\xe9\n")
+ with pytest.raises(DocumentError, match=r"not valid UTF-8 \(byte 0xe9 at offset 5\)"):
+ render_file(path)
+ good = tmp_path / "good.lgd"
+ good.write_bytes(CZECH.encode("utf-8"))
+ assert "Příloha" in render_file(good, format="text").output
+
+
# --- S5: unwritable output -------------------------------------------------------------------
def test_unwritable_output_is_a_clean_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
From 5dd564bc69f34610967a85ef3f3edc49fe04d1d7 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:41:36 +0000
Subject: [PATCH 005/118] Money keeps every written fraction digit (R2
follow-up)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
money with a currency showed the larger of the currency's minor units and the
written digits only when the last written digit was non-zero: "1.500" EUR gave
"€1.50" and "1.1230" gave "€1.123". The fraction digit count is now fixed in the
pattern as max(minor units, written decimals) with currency_digits off, in
symbol, code and name mode, under _exact. "5" EUR is still "€5.00", JPY "5"
is "¥5", JPY "0.125" is "¥0.125". Tests added to tests/test_wp1_values.py; no
existing test or golden changed.
---
src/legaldown_render/resolve/values.py | 35 ++++++++++++++++---------
tests/test_wp1_values.py | 36 ++++++++++++++++++++++++++
2 files changed, 59 insertions(+), 12 deletions(-)
diff --git a/src/legaldown_render/resolve/values.py b/src/legaldown_render/resolve/values.py
index f15ec9c..2e20bbc 100644
--- a/src/legaldown_render/resolve/values.py
+++ b/src/legaldown_render/resolve/values.py
@@ -4,8 +4,8 @@
Values are stored canonically in the source — ISO dates, decimal amounts with
a period — and only their display varies by locale. Amounts are handled as
``Decimal`` from the source text and are never rounded or truncated (§10.3):
-money is padded to its currency's minor units, and any further digits written
-in the source are kept. A duration keeps every digit it is written with too,
+money is padded to its currency's minor units, and every digit written in
+the source is kept (the fraction shows the larger of the two). A duration keeps every digit it is written with too,
and its plural follows the digits it displays (§10.5).
"""
from __future__ import annotations
@@ -20,7 +20,7 @@
from babel import Locale, UnknownLocaleError
from babel.dates import format_date
-from babel.numbers import format_currency, parse_pattern
+from babel.numbers import format_currency, get_currency_precision, parse_pattern
from ..errors import InternalError
from ..style.model import Values
@@ -169,17 +169,28 @@ def money(self, amount: str, currency: str | None) -> str:
def _money(self, amount: str, currency: str) -> str:
value = Decimal(amount)
- if self.values.money == "name":
- return format_currency(value, currency, locale=self.locale, format_type="name",
- decimal_quantization=False)
- pattern = None
- if self.values.money == "code":
- standard = self.locale.currency_formats["standard"].pattern
- pattern = standard.replace("¤", "¤¤") if "¤¤" not in standard else standard
- text = format_currency(value, currency, format=pattern, locale=self.locale, decimal_quantization=False)
+ mode = self.values.money
+ if mode == "name":
+ source = self.locale.decimal_formats[None].pattern
+ else:
+ source = self.locale.currency_formats["standard"].pattern
+ if mode == "code" and "¤¤" not in source:
+ source = source.replace("¤", "¤¤")
+ # Fraction digits shown: the currency's minor units, or every digit
+ # written when there are more (§10.3). Babel's own quantizing would
+ # drop a trailing zero beyond the minor units ("1.500" EUR), so the
+ # digit count is fixed in the pattern, and currency_digits is off.
+ pattern = parse_pattern(source)
+ digits = max(get_currency_precision(currency), len(amount.partition(".")[2]))
+ pattern.frac_prec = (digits, digits)
+ text = format_currency(value, currency, format=pattern, locale=self.locale, currency_digits=False,
+ format_type="name" if mode == "name" else "standard",
+ decimal_quantization=False)
+ if mode == "name":
+ return text
# CLDR spaces an alphabetic symbol or code from the digits, on
# whichever side it stands ("USD 10,000.00", "10 000,00 CZK").
- return _LETTER_DIGIT.sub(" ", text)
+ return _LETTER_DIGIT.sub("\u00a0", text)
def duration(self, value: str, unit: str) -> str:
"""A duration (already validated) such as "12 months" or "12 měsíců"."""
diff --git a/tests/test_wp1_values.py b/tests/test_wp1_values.py
index e19fbc8..8549a9c 100644
--- a/tests/test_wp1_values.py
+++ b/tests/test_wp1_values.py
@@ -168,3 +168,39 @@ def test_symbol_after_the_digits_is_spaced_too() -> None:
def test_code_mode_still_spaced() -> None:
assert formatter("en", money="code").money("5", "CZK") == "CZK\xa05.00"
assert formatter("de", money="code").money("5", "CZK") == "5,00\xa0CZK"
+
+
+# -- money keeps every written fraction digit, padded to the minor units ------
+
+@pytest.mark.parametrize(("amount", "currency", "expected"), [
+ ("1.1230", "EUR", "€1.1230"),
+ ("1.500", "EUR", "€1.500"),
+ ("1.50", "EUR", "€1.50"),
+ ("5", "EUR", "€5.00"),
+ ("5.5", "EUR", "€5.50"),
+ ("5", "JPY", "¥5"),
+ ("0.125", "JPY", "¥0.125"),
+ ("5.0", "JPY", "¥5.0"),
+ ("1234.5670", "USD", "$1,234.5670"),
+])
+def test_money_fraction_digits_symbol(amount: str, currency: str, expected: str) -> None:
+ assert formatter("en-US").money(amount, currency) == expected
+
+
+def test_money_fraction_digits_code_and_name() -> None:
+ code = formatter("en-US", money="code")
+ name = formatter("en-US", money="name")
+ assert code.money("1.500", "EUR") == "EUR\xa01.500"
+ assert code.money("5", "EUR") == "EUR\xa05.00"
+ assert code.money("0.125", "JPY") == "JPY\xa00.125"
+ assert code.money("5", "JPY") == "JPY\xa05"
+ assert name.money("1.500", "EUR") == "1.500 euros"
+ assert name.money("5", "EUR") == "5.00 euros"
+ assert name.money("0.125", "JPY") == "0.125 Japanese yen"
+ assert name.money("5", "JPY") == "5 Japanese yen"
+
+
+def test_money_trailing_zeros_in_other_locales_and_long_amounts() -> None:
+ assert formatter("cs-CZ").money("1.500", "CZK") == "1,500\xa0Kč"
+ assert formatter("de", money="code").money("1.1230", "EUR") == "1,1230\xa0EUR"
+ assert formatter("en-US").money("1" * 40 + ".1230", "EUR") == f"€{int('1' * 40):,}.1230"
From 90346706b7516a8be30042c5d761837ad793ef48 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:42:46 +0000
Subject: [PATCH 006/118] Terms that refer to themselves render instead of
crashing (B8 follow-up)
`"Self {{term: s}}" {{def: s}}`, and cycles through several definitions,
raised RecursionError in Resolver._term (already so before WP4, through
_plain_value of the validator's raw term). The term-text computation now
tracks the definitions it has open (the _survey pre-pass and the
_plain_value fallback, _plain_open): a {{term:}} to an id already open shows
its label=, else the definition id, and goes no further. The validator 0.4.0
reports nothing for such documents; the spec's definition-circular Warning
is not emitted by it, and no diagnostic is added here (CONFORMANCE.md notes
it).
Tests: direct and indirect cycles, at the definition site and at reference
sites, in the text and HTML writers.
---
CONFORMANCE.md | 4 +++
src/legaldown_render/resolve/resolver.py | 33 +++++++++++++++++----
tests/test_wp4_build.py | 37 ++++++++++++++++++++++++
3 files changed, 69 insertions(+), 5 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index f8b7774..542eca8 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -63,6 +63,10 @@ It is checked against the specification's own examples and fixtures corpus (`tes
body, with its Markdown resolved (`"A *b* c" {{def: abc}}` is "A b c"), not the term's raw
source; `label=` overrides it. A definition not in the body (a template's other alternative)
falls back to the validator's term text.
+ A term that refers to itself, directly or through other terms (`"A {{term: a}}" {{def: a}}`), is
+ rendered without looping: the inner reference shows its `label=`, else the definition id. The
+ validator (0.4.0) does not report these cycles; the specification's `definition-circular`
+ Warning (§16) is not emitted yet.
- **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
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index cc5e055..34ad16c 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -150,6 +150,10 @@ def __init__(
# A definition id -> the plain text of its term as rendered (Markdown
# resolved), from the first definition in the body (see _survey).
self.term_texts: dict[str, str] = {}
+ # The definitions whose term text is being worked out, so that a
+ # {{term:}} inside a term, directly or through other terms, cannot
+ # send the computation round in a circle.
+ self._terms_open: list[str] = []
self.attachment_anchors: dict[str, str | None] = {}
self.section_presences: list[Presence] = []
@@ -252,7 +256,11 @@ def _survey(self) -> None:
# validator's raw ``anchor.term``.
identifier = self._definition_id(inline)
if identifier not in self.term_texts:
- self.term_texts[identifier] = self._title_text(inline.children)
+ self._terms_open.append(identifier)
+ try:
+ self.term_texts[identifier] = self._title_text(inline.children)
+ finally:
+ self._terms_open.pop()
elif isinstance(inline, DirectiveSource):
directive = inline.directive
if directive.name == "placeholder" and directive.positional and not directive.malformed:
@@ -592,14 +600,29 @@ def _term(self, directive: Directive, definition_id: str) -> Inline:
term = self.result.index.definition_lookup.get(definition_id)
if term is None:
return FailureMarker(f"[UNDEFINED: {definition_id}]")
- # The term as rendered at its definition; the validator's own text
- # (an empty term reads as its id, a definition not in the body) is
- # the fallback.
- text = directive.params.get("label") or self.term_texts.get(definition_id) or self._plain_value(term)
+ label = directive.params.get("label")
+ if definition_id in self._terms_open:
+ # A term that refers to itself, directly or through others: the
+ # reference shows its label, else the id, and goes no further.
+ text = label or definition_id
+ else:
+ # The term as rendered at its definition; the validator's own
+ # text (an empty term reads as its id, a definition not in the
+ # body) is the fallback.
+ text = label or self.term_texts.get(definition_id) or self._plain_open(definition_id, term)
# Settled once everything is resolved (see _settle_term_target).
target = DEFINITION_ANCHOR_PREFIX + definition_id
return TermRef(text, target, self.style.definitions.term_style)
+ def _plain_open(self, definition_id: str, term: str) -> str:
+ """The validator's *term* text of a definition as plain text, with
+ the definition marked open while its directives resolve."""
+ self._terms_open.append(definition_id)
+ try:
+ return self._plain_value(term)
+ finally:
+ self._terms_open.pop()
+
def _date(self, directive: Directive, value: str) -> Inline:
if not is_valid_iso_date(value):
return FailureMarker(f"[INVALID DATE: {value}]")
diff --git a/tests/test_wp4_build.py b/tests/test_wp4_build.py
index f21ae17..396bb69 100644
--- a/tests/test_wp4_build.py
+++ b/tests/test_wp4_build.py
@@ -244,3 +244,40 @@ def test_an_empty_term_falls_back_to_the_validator() -> None:
def test_an_undefined_term_is_still_reported() -> None:
assert "[UNDEFINED: nope]" in text("# A\n\nSee {{term: nope}}.\n")
+
+
+# -- B8: a term that refers to itself ----------------------------------------
+
+SELF = '# A\n\n"Self {{term: s}}" {{def: s}} means it.\n\nSee {{term: s}} and {{term: s, label=the label}}.\n'
+INDIRECT = ('# A\n\n"A {{term: b}}" {{def: a}} x.\n\n"B {{term: a}}" {{def: b}} y.\n\n'
+ "See {{term: a}}, {{term: b}} and {{term: a, label=the label}}.\n")
+
+
+@pytest.mark.parametrize("output_format", ["text", "html"])
+@pytest.mark.parametrize("body", [SELF, INDIRECT], ids=["direct", "indirect"])
+def test_a_cyclic_term_renders_in_both_writers(body: str, output_format: str) -> None:
+ result = render(FRONT + body, format=output_format)
+ assert "the label" in result.output
+ assert "[UNDEFINED" not in result.output
+
+
+def test_a_term_inside_its_own_definition_shows_its_id() -> None:
+ output = text(SELF)
+ # At the definition and at a reference alike: the inner reference shows
+ # the id (its label when it has one), and goes no further.
+ assert "Self Self s means it." in output
+ assert "See Self s and the label." in output
+
+
+def test_a_term_reference_in_a_cycle_shows_its_label_else_its_id() -> None:
+ output = text(INDIRECT)
+ assert output.rstrip().endswith("and the label.")
+ # No term shows its own reference as raw directive source.
+ assert "{{" not in output
+
+
+def test_a_cycle_through_a_reference_site_inside_another_terms_definition() -> None:
+ body = ('# A\n\n"A" {{def: a}} x.\n\n"B {{term: a}} and {{term: c}}" {{def: b}} y.\n\n'
+ '"C {{term: b}}" {{def: c}} z.\n\nSee {{term: b}}.\n')
+ assert "See B A and" in text(body)
+ assert "B A and" in html(body)
From 64be9dfa29e1a72d9a4a4697103e4dea4ebe2b59 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:42:46 +0000
Subject: [PATCH 007/118] B2 guard: an item a marker belongs to never vanishes
A comment-only list item is now dropped only when the markers give it no
anchor identifier and no condition. The validator does not place a marker
on such an item, so this is defensive; the test drives the builder with a
made-up marker.
---
src/legaldown_render/build.py | 7 +++++--
tests/test_wp4_build.py | 18 ++++++++++++++++++
2 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py
index 2d8e568..a07daa4 100644
--- a/src/legaldown_render/build.py
+++ b/src/legaldown_render/build.py
@@ -439,8 +439,11 @@ def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numb
continue # never emitted (§8.6, §8.7)
elif (built := self.block(child, [], top_level=False)) is not None:
blocks.append(built)
- if item.blocks and all(child.kind == "html" for child in item.blocks):
- continue # only a comment: the item is removed
+ # Only a comment: the item is removed. Defensive: an item a
+ # marker belongs to (an anchor or a condition) never vanishes.
+ if item.blocks and all(child.kind == "html" for child in item.blocks) \
+ and not identifier and not condition:
+ continue
items.append(ListItem(blocks=tuple(blocks), anchor_id=identifier, condition=condition))
finally:
self.depth -= 1
diff --git a/tests/test_wp4_build.py b/tests/test_wp4_build.py
index 396bb69..6862f29 100644
--- a/tests/test_wp4_build.py
+++ b/tests/test_wp4_build.py
@@ -281,3 +281,21 @@ def test_a_cycle_through_a_reference_site_inside_another_terms_definition() -> N
'"C {{term: b}}" {{def: c}} z.\n\nSee {{term: b}}.\n')
assert "See B A and" in text(body)
assert "B A and" in html(body)
+
+
+def test_a_comment_only_item_with_a_marker_is_kept() -> None:
+ # Defensive (B2): an item a marker belongs to never vanishes. The
+ # validator does not place a marker on a comment-only item, so a marker
+ # is made up here to drive the builder's guard.
+ from legaldown import PlacedMarker
+
+ from legaldown_render.build import _Builder
+
+ document = parse(FRONT + "# A\n\n- \n- b\n")
+ result = validate(document)
+ marker = PlacedMarker(section=0, block=0, fragment=0, offset=0, source="",
+ identifier="x", condition="", item=0)
+ out = _Builder(document, result).list(document.sections[0].blocks[0], [marker])
+ assert out is not None and [item.anchor_id for item in out.items] == ["x", ""]
+ plain = _Builder(document, result).list(document.sections[0].blocks[0], [])
+ assert plain is not None and len(plain.items) == 1
From 14b6977f0c12908b818a5aa6ba39156a9ca6ef05 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:46:45 +0000
Subject: [PATCH 008/118] Style validation: blank fallback, extends cycles,
flat dumps, overrides, template fields (R6, S7-S11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- R6: Resolver._placeholder renders [TBD: id] when the effective
placeholders.blank is empty or whitespace (spec §13.5 rule 6); no StyleError.
- S7: extends cycles are detected on resolved paths (Path.resolve), so
../dir/a.yaml, a second spelling, or a symlink no longer recurses until
"File name too long".
- S8: dump_style drops `extends`, so a --print-style dump is self-contained and
reloads from any directory.
- S9: overriding `version` or `extends` is a StyleError (it was silently
ignored, and --print-style could print an unloadable style).
- S10 (loader side): a YAML error in a --set value raises StyleError naming
the key and the YAML problem (with line/column) instead of resurfacing as a
misleading type error; reaches stderr through WP2's argparse wrapper.
- S11: model.TemplateFields / template_settings inventory the {field} names of
every text template setting (verified against numbering.fill,
Resolver._ref, fill_condition); the loader reports unknown
identifier-shaped fields and missing required ones, naming the setting
(labels.condition must contain {condition}, §15.8). Other braces stay
literal. Built-in styles and labels pass.
Existing test changed: tests/test_style.py::test_dump_round_trips now compares
with extends=None, because the flattened dump no longer records what the
style extended (S8). Docs: style-templates.md (field table, flat dump,
overrides), CONFORMANCE.md, architecture.md.
---
CONFORMANCE.md | 2 +
docs/architecture.md | 3 +-
docs/style-templates.md | 39 +++-
src/legaldown_render/resolve/resolver.py | 4 +-
src/legaldown_render/style/loader.py | 118 ++++++++--
src/legaldown_render/style/model.py | 61 ++++-
tests/test_style.py | 4 +-
tests/test_wp6_style.py | 282 +++++++++++++++++++++++
8 files changed, 486 insertions(+), 27 deletions(-)
create mode 100644 tests/test_wp6_style.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index dd09b8d..8a5c0ee 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -35,10 +35,12 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| 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 |
+| A conditional unit's condition is shown (§15.8, MUST) | ✅ Also for a custom style: `labels.condition` must contain `{condition}`, or the style is refused |
| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ |
| `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 HTML table is dropped with the Warning |
+| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty or only whitespace renders `[TBD: id]` |
| 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 |
diff --git a/docs/architecture.md b/docs/architecture.md
index c9d5128..11bc3c5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -221,7 +221,8 @@ turns into an error.
- **Document problems** render as failure markers and are listed in `diagnostics`. `strict=True`
raises `RenderRefused` instead, carrying the diagnostics.
- **Configuration problems** raise before rendering, with every problem named: an invalid style,
- setting, or locale raises `StyleError`; an unknown output format raises `OptionsError`. Both are
+ setting, or locale raises `StyleError` (this includes a `{field}` that a text template does not
+ accept or lacks, a `--set` value that is not valid YAML, and an `extends` cycle); an unknown output format raises `OptionsError`. Both are
`RenderError`s (and `ValueError`s, as they always were). Every renderer error pickles, so it
survives `multiprocessing`.
- **Internal inconsistencies** raise `InternalError`, and the CLI exits with 70.
diff --git a/docs/style-templates.md b/docs/style-templates.md
index 88ee6f5..c0027e1 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -25,7 +25,8 @@ Values are layered. Each layer overrides the one before it, **field by field**:
1. **Built-in defaults.** Every setting has one, defined in `legaldown_render/style/model.py`.
The `default` style is exactly these defaults.
2. **The `extends` chain.** A style names the style it builds on, which can be a built-in name or
- a path relative to the file. Chains are resolved base first, and cycles are rejected.
+ a path relative to the file. Chains are resolved base first, and cycles are rejected, however
+ the files are spelled (`../dir/a.yaml`, a symbolic link).
3. **The chosen style**: `--style NAME|PATH`, or `RenderOptions(style=...)`.
4. **Per-job overrides**: `--set numbering.scheme=mixed` or `overrides={"numbering.scheme": "mixed"}`,
plus `--locale`. On the command line, text settings take the value as written
@@ -34,7 +35,9 @@ Values are layered. Each layer overrides the one before it, **field by field**:
or `C:\new` stay exactly as written. To keep quotation marks
around a value, wrap it in the other kind (`--set 'references.format="{designation}"'`
gives `{designation}`, and `--set "references.format='\"{designation}\"'"` gives
- `"{designation}"`). Other settings are read as YAML (`true`, `2`, `[...]`).
+ `"{designation}"`). Other settings are read as YAML (`true`, `2`, `[...]`); a value that is not
+ valid YAML is an error naming the setting and the YAML problem. `version` and `extends` belong
+ to the template file and cannot be overridden: choose the template with `--style`.
Mappings merge field by field, so `headings: {1: {align: center}}` keeps level 1's size and
weight. Lists, such as `enumeration.levels`, replace the list they override.
@@ -51,7 +54,8 @@ legaldown-render: Invalid style 'default':
To see the effective style, with every setting after all layers are applied, use
`legaldown-render --print-style --style continental --set locale=cs-CZ`. The output is itself a
-valid style file.
+valid style file, and a self-contained one: every setting is written out and there is no `extends`,
+so it loads from any directory.
## Built-in styles
@@ -100,7 +104,7 @@ values:
duration: long # long (12 months) | short | narrow
placeholders:
- blank: "[_____]"
+ blank: "[_____]" # empty or whitespace only: renders [TBD: id] (§13.5 rule 6)
show_prompt: false # show the question's prompt next to the blank (§15.8)
title_block: { parties: true, effective_date: true, version: false }
@@ -152,6 +156,33 @@ example, decimal headings use `label: "{path}"` and `ref: ".{n}"`. Legal enumera
referenced as "2.3(b)". Under the `none` scheme, designations are heading text, and item pieces
are set off by a space ("Termination (a)", §13.3).
+### Text templates and their fields
+
+A text setting that holds `{field}` names is checked when the style is loaded. A field is a name
+in braces that is an identifier (`{n}`, `{designation}`); any other braces (`{}`, `{1}`, `{a b}`)
+are literal text. An identifier the setting does not accept is an error naming the setting (a
+typo would otherwise be shown literally), and so is a missing required field.
+
+| Setting | Accepted fields | Required |
+|---|---|---|
+| `numbering.levels[].label` | `{n}`, `{path}` | none |
+| `numbering.levels[].ref` | `{n}` | `{n}` |
+| `enumeration.levels[].label`, `paragraphs.format.label` | `{n}`, `{path}`, `{section}` | none |
+| `enumeration.levels[].ref`, `paragraphs.format.ref` | `{n}`, `{section}` | `{n}` |
+| `references.format` | `{designation}` | `{designation}`, or every reference reads the same |
+| `labels.condition` | `{condition}` | `{condition}`, as §15.8 requires the condition to be shown |
+| `labels.attachment_file` | `{file}` | none |
+| every other label | none | none |
+
+```
+$ legaldown-render contract.lgd --set references.format=nope
+legaldown-render: Invalid style 'default':
+ - references.format: must contain {designation} — every reference would read the same (§13.3)
+```
+
+A `ref` is one piece of a designation, so it cannot show the whole `{path}`. A label left unset
+takes the built-in one for the document language, which always passes.
+
### Built-in labels
Labels follow the document's `language`. English (`en`), Czech (`cs`), German (`de`), French
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 0301967..bec7d29 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -661,7 +661,9 @@ def _placeholder(self, directive: Directive, placeholder_id: str) -> Inline:
prompt = ""
if self.style.placeholders.show_prompt and isinstance(declared, dict):
prompt = str(declared.get("prompt") or "")
- return Blank(placeholder_id, self.style.placeholders.blank, prompt)
+ # §13.5 rule 6: a style whose blank is empty offers no visual blank.
+ blank = self.style.placeholders.blank
+ return Blank(placeholder_id, blank if blank.strip() else f"[TBD: {placeholder_id}]", prompt)
def _attach(self, directive: Directive, attachment_id: str) -> Inline:
title = self.result.index.attachment_lookup.get(attachment_id)
diff --git a/src/legaldown_render/style/loader.py b/src/legaldown_render/style/loader.py
index 3c48b67..56f3498 100644
--- a/src/legaldown_render/style/loader.py
+++ b/src/legaldown_render/style/loader.py
@@ -16,6 +16,8 @@
import copy
import dataclasses
import difflib
+import os
+import re
import types
from collections.abc import Mapping
from importlib import resources
@@ -26,7 +28,7 @@
from ..errors import StyleError
from . import model
-from .model import Style
+from .model import Style, template_settings
STYLE_FORMAT_VERSION = 1
@@ -54,7 +56,9 @@ def load_style(
else:
data, source = _load_chain(style if style is not None else "default", seen=())
if overrides:
- data = _merge(data, _expand_dotted(overrides))
+ expanded = _expand_dotted(overrides)
+ _refuse_meta_overrides(expanded)
+ data = _merge(data, expanded)
return _build(data, source)
@@ -64,8 +68,14 @@ def style_to_dict(style: Style) -> dict[str, Any]:
def dump_style(style: Style) -> str:
- """*style* as a complete YAML style template."""
- return yaml.safe_dump(style_to_dict(style), sort_keys=False, allow_unicode=True)
+ """*style* as a complete, self-contained YAML style template.
+
+ Every setting is written out, so the template extends nothing: ``extends``
+ names a file relative to the one that holds it, which a dump written
+ elsewhere (``--print-style > house.yaml``) could not find again."""
+ data = style_to_dict(style)
+ data.pop("extends", None)
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
# ---------------------------------------------------------------------------
@@ -73,29 +83,38 @@ def dump_style(style: Style) -> str:
# ---------------------------------------------------------------------------
-def _load_chain(spec: str | Path, *, seen: tuple[str, ...], relative_to: Path | None = None) -> tuple[dict, str]:
- """The merged raw mapping for *spec* and every template it extends."""
- raw, source, folder = _read(spec, relative_to)
- if source in seen:
- chain = " -> ".join((*seen, source))
+def _load_chain(
+ spec: str | Path, *, seen: tuple[tuple[str, str], ...], relative_to: Path | None = None
+) -> tuple[dict, str]:
+ """The merged raw mapping for *spec* and every template it extends.
+
+ *seen* holds the (identity, label) of each template on the chain so far.
+ A file's identity is its resolved path, so ``../dir/a.yaml`` and
+ ``a.yaml`` — or a symbolic link — are one template and a cycle through
+ them is found."""
+ raw, source, folder, identity = _read(spec, relative_to)
+ if any(identity == earlier for earlier, _ in seen):
+ chain = " -> ".join((*(label for _, label in seen), source))
raise StyleError(source, [f"extends: circular chain {chain}"])
+ here = (*seen, (identity, source))
parent = raw.get("extends")
if parent is None:
if source == "'default'":
# Start from every built-in default, so that a partial mapping —
# one heading level's alignment, say — merges field by field.
return _merge(style_to_dict(Style()), raw), source
- base, _ = _load_chain("default", seen=(*seen, source))
+ base, _ = _load_chain("default", seen=here)
elif isinstance(parent, str) and parent:
- base, _ = _load_chain(parent, seen=(*seen, source), relative_to=folder)
+ base, _ = _load_chain(parent, seen=here, relative_to=folder)
else:
raise StyleError(source, ["extends: must be a style name or a path"])
return _merge(base, raw), source
-def _read(spec: str | Path, relative_to: Path | None) -> tuple[dict, str, Path | None]:
- """The raw mapping in *spec*, a label for messages, and the folder that
- relative ``extends`` paths are resolved against."""
+def _read(spec: str | Path, relative_to: Path | None) -> tuple[dict, str, Path | None, str]:
+ """The raw mapping in *spec*, a label for messages, the folder that
+ relative ``extends`` paths are resolved against, and the template's
+ identity (its resolved path, or the quoted name of a built-in)."""
name = str(spec)
is_path = isinstance(spec, Path) or name.endswith((".yaml", ".yml")) or "/" in name or "\\" in name
if not is_path:
@@ -104,7 +123,7 @@ def _read(spec: str | Path, relative_to: Path | None) -> tuple[dict, str, Path |
raise StyleError(f"'{name}'", [f"no built-in style named '{name}' (built-in: {known}); "
f"to load a file, give a path ending in .yaml"])
text = resources.files(__package__).joinpath("builtin", f"{name}.yaml").read_text("utf-8")
- return _parse(text, f"'{name}'"), f"'{name}'", None
+ return _parse(text, f"'{name}'"), f"'{name}'", None, f"'{name}'"
path = Path(spec)
if relative_to is not None and not path.is_absolute():
path = relative_to / path
@@ -117,7 +136,16 @@ def _read(spec: str | Path, relative_to: Path | None) -> tuple[dict, str, Path |
f"at offset {error.start})"]) from None
raw = _parse(text, str(path))
raw.setdefault("name", path.stem)
- return raw, str(path), path.parent
+ return raw, str(path), path.parent, _identity(path)
+
+
+def _identity(path: Path) -> str:
+ """*path* made absolute, with ``..`` and symbolic links followed, so two
+ spellings of one file compare equal."""
+ try:
+ return str(path.resolve())
+ except (OSError, RuntimeError): # a symbolic link loop
+ return os.path.abspath(path)
def _parse(text: str, source: str) -> dict:
@@ -167,6 +195,23 @@ def _expand_dotted(overrides: Mapping[str, Any]) -> dict:
return expanded
+#: Top-level settings that describe the template file, not how a document
+#: looks; a job's overrides cannot change them.
+_META_KEYS = {
+ "version": "the style format version belongs to the template file",
+ "extends": "choose the template with --style (or RenderOptions.style) instead",
+}
+
+
+def _refuse_meta_overrides(expanded: Mapping) -> None:
+ """Overrides are applied after the ``extends`` chain is resolved and the
+ version checked, so an override of either would be silently ignored — or,
+ for ``--print-style``, written into a style that no longer loads."""
+ problems = [f"{key}: cannot be overridden; {reason}" for key, reason in _META_KEYS.items() if key in expanded]
+ if problems:
+ raise StyleError("overrides", problems)
+
+
def _copy(value: Any) -> Any:
"""A deep copy of *value* with every mapping made a plain dict."""
if isinstance(value, Mapping):
@@ -184,7 +229,8 @@ def parse_override(key: str, text: str) -> Any:
keep quotation marks around a value, wrap it in the other kind
(``'"{designation}"'``). Other settings read it as YAML, so that
``true``, ``2``, or ``[...]`` mean what they say. ``null`` clears a
- setting that may be unset, such as ``locale``.
+ setting that may be unset, such as ``locale``. A value that is not valid
+ YAML raises :class:`StyleError`, naming *key* and the YAML problem.
"""
hint = _hint_for(key.split("."))
if hint is not None and _is_text(hint):
@@ -197,8 +243,19 @@ def parse_override(key: str, text: str) -> Any:
return text
try:
return yaml.safe_load(text) if text.strip() else ""
- except yaml.YAMLError:
- return text
+ except yaml.YAMLError as error:
+ raise StyleError("overrides", [f"{key}: the value is not valid YAML: {_yaml_problem(error)}"]) from None
+
+
+def _yaml_problem(error: yaml.YAMLError) -> str:
+ """What is wrong in a YAML text, on one line: the problem and where it is."""
+ problem = getattr(error, "problem", None)
+ if not problem:
+ return " ".join(str(error).split())
+ context = getattr(error, "context", None)
+ mark = getattr(error, "problem_mark", None)
+ where = f" (line {mark.line + 1}, column {mark.column + 1})" if mark is not None else ""
+ return f"{context}, {problem}{where}" if context else f"{problem}{where}"
def _hint_for(parts: list[str]) -> Any:
@@ -234,14 +291,37 @@ def _is_text(hint: Any) -> bool:
# ---------------------------------------------------------------------------
+#: ``{name}`` as the resolver's ``fill`` and label substitution read it.
+_FIELD_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
+
+
def _build(data: Mapping, source: str) -> Style:
problems: list[str] = []
style = _convert(data, Style, "", problems)
+ problems.extend(_template_problems(style))
if problems:
raise StyleError(source, problems)
return style
+def _template_problems(style: Style) -> list[str]:
+ """What is wrong with the ``{field}`` names in the style's text templates
+ (:func:`~.model.template_settings`): an identifier-shaped field the
+ setting does not accept (a typo would be shown literally), or a required
+ field it leaves out (``labels.condition`` without ``{condition}`` would
+ hide the condition, which §15.8 requires to be shown)."""
+ problems = []
+ for key, template, fields in template_settings(style):
+ found = set(_FIELD_RE.findall(template))
+ accepted = ", ".join(f"{{{name}}}" for name in sorted(fields.accepted))
+ for name in sorted(found - fields.accepted):
+ takes = f"accepts {accepted}" if accepted else "takes no fields"
+ problems.append(f"{key}: unknown field {{{name}}}; this setting {takes}")
+ for name in sorted(fields.required - found):
+ problems.append(f"{key}: must contain {{{name}}}" + (f" — {fields.why}" if fields.why else ""))
+ return problems
+
+
def _convert(value: Any, hint: Any, path: str, problems: list[str]) -> Any:
label = path or "(root)"
origin = get_origin(hint)
diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py
index 1a0f464..6903be2 100644
--- a/src/legaldown_render/style/model.py
+++ b/src/legaldown_render/style/model.py
@@ -15,7 +15,8 @@
"""
from __future__ import annotations
-from dataclasses import dataclass, field
+from collections.abc import Iterator
+from dataclasses import dataclass, field, fields
from typing import Literal
Counter = Literal["decimal", "lower-alpha", "upper-alpha", "lower-roman", "upper-roman"]
@@ -283,3 +284,61 @@ class Style:
headings: dict[int, HeadingStyle] = field(default_factory=_default_headings)
page: Page = field(default_factory=Page)
html: Html = field(default_factory=Html)
+
+
+# ---------------------------------------------------------------------------
+# Text templates
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True, slots=True)
+class TemplateFields:
+ """The ``{field}`` names a text template setting accepts and requires.
+
+ Only identifier-shaped braces (``{n}``, ``{designation}``) are fields; any
+ other braces (``{}``, ``{1}``, ``{a b}``) are literal text, as the
+ resolver keeps them. ``why`` says what a required field is for, in the
+ message about a template that leaves it out."""
+
+ accepted: frozenset[str] = frozenset()
+ required: frozenset[str] = frozenset()
+ why: str = ""
+
+
+#: The designation-building settings are read in :mod:`..resolve.numbering`
+#: (``fill``) and :mod:`..resolve.resolver`. A heading's ``label`` sees its own
+#: counter and its designation; its ``ref`` is one piece of a designation, so
+#: it sees only the counter.
+HEADING_LABEL = TemplateFields(frozenset({"n", "path"}))
+HEADING_REF = TemplateFields(frozenset({"n"}), frozenset({"n"}), "each level must tell its sections apart (§13.3)")
+#: List items and paragraphs also see the containing section's designation.
+ITEM_LABEL = TemplateFields(frozenset({"n", "path", "section"}))
+ITEM_REF = TemplateFields(frozenset({"n", "section"}), frozenset({"n"}), "each item must tell itself apart (§13.3)")
+REFERENCE_FORMAT = TemplateFields(
+ frozenset({"designation"}), frozenset({"designation"}), "every reference would read the same (§13.3)"
+)
+#: Labels are plain words, except for the two that carry a value.
+LABEL_FIELDS: dict[str, TemplateFields] = {
+ "condition": TemplateFields(
+ frozenset({"condition"}), frozenset({"condition"}), "a conditional unit's condition must be shown (§15.8)"
+ ),
+ "attachment_file": TemplateFields(frozenset({"file"})),
+}
+
+
+def template_settings(style: Style) -> Iterator[tuple[str, str, TemplateFields]]:
+ """Every text template setting of *style* that has been set, as
+ ``(key path, template, fields)``. A label left unset (``None``) takes a
+ built-in one, which is correct by construction."""
+ for name, kind_label, kind_ref in (("numbering", HEADING_LABEL, HEADING_REF),
+ ("enumeration", ITEM_LABEL, ITEM_REF)):
+ for index, level in enumerate(getattr(style, name).levels):
+ yield f"{name}.levels[{index}].label", level.label, kind_label
+ yield f"{name}.levels[{index}].ref", level.ref, kind_ref
+ yield "paragraphs.format.label", style.paragraphs.format.label, ITEM_LABEL
+ yield "paragraphs.format.ref", style.paragraphs.format.ref, ITEM_REF
+ yield "references.format", style.references.format, REFERENCE_FORMAT
+ for item in fields(Labels):
+ template = getattr(style.labels, item.name)
+ if template is not None:
+ yield f"labels.{item.name}", template, LABEL_FIELDS.get(item.name, TemplateFields())
diff --git a/tests/test_style.py b/tests/test_style.py
index 44f71bd..c0e5495 100644
--- a/tests/test_style.py
+++ b/tests/test_style.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from dataclasses import replace
from pathlib import Path
import pytest
@@ -68,7 +69,8 @@ def test_dump_round_trips(tmp_path: Path) -> None:
style = load_style("continental", overrides={"locale": "de-DE"})
path = tmp_path / "dumped.yaml"
path.write_text(dump_style(style))
- assert load_style(path) == style
+ # The dump is flattened (S8): the same style, without the record of what it extended.
+ assert load_style(path) == replace(style, extends=None)
def test_command_line_values_are_typed_by_their_setting() -> None:
diff --git a/tests/test_wp6_style.py b/tests/test_wp6_style.py
new file mode 100644
index 0000000..870e752
--- /dev/null
+++ b/tests/test_wp6_style.py
@@ -0,0 +1,282 @@
+"""Style validation: an empty blank falls back to ``[TBD: id]`` (R6), ``extends``
+cycles are found on resolved paths (S7), ``--print-style`` is self-contained
+(S8), ``version`` and ``extends`` cannot be overridden (S9), a YAML error in a
+``--set`` value is reported (S10, loader side), and the ``{field}``
+placeholders of every text template are validated (S11)."""
+from __future__ import annotations
+
+import dataclasses
+import os
+import subprocess
+import sys
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+
+from legaldown_render import RenderOptions, StyleError, builtin_styles, dump_style, load_style, render
+from legaldown_render.style import parse_override
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+DOC = "---\nlegaldown: '0.2'\ntitle: T\n---\n\n# One {#one}\n\nAmount {{placeholder: fee}} and {{ref: one}}.\n"
+
+
+def _cli(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
+ env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONUTF8="1")
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
+ capture_output=True, text=True, env=env, check=False)
+
+
+# --- R6: an empty blank --------------------------------------------------------------------------
+
+@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_an_empty_blank_falls_back_to_tbd(blank: str, fmt: str) -> None:
+ """§13.5 rule 6: no visual blank means ``[TBD: id]``."""
+ result = render(DOC, RenderOptions(format=fmt, overrides={"placeholders.blank": blank}))
+ assert "[TBD: fee]" in result.output
+
+
+def test_a_visible_blank_is_kept() -> None:
+ result = render(DOC, RenderOptions(format="text", overrides={"placeholders.blank": "[__]"}))
+ assert "[__]" in result.output
+ assert "TBD" not in result.output
+
+
+# --- S7: extends cycles ----------------------------------------------------------------------------
+
+def test_a_cycle_through_a_parent_directory_is_found(tmp_path: Path) -> None:
+ (tmp_path / "dir").mkdir()
+ (tmp_path / "dir" / "a.yaml").write_text("version: 1\nextends: ../dir/a.yaml\n")
+ with pytest.raises(StyleError, match="circular"):
+ load_style(tmp_path / "dir" / "a.yaml")
+
+
+def test_a_cycle_through_two_spellings_of_one_file_is_found(tmp_path: Path) -> None:
+ (tmp_path / "x").mkdir()
+ (tmp_path / "x" / "a.yaml").write_text("version: 1\nextends: ../x/b.yaml\n")
+ (tmp_path / "x" / "b.yaml").write_text("version: 1\nextends: ./a.yaml\n")
+ with pytest.raises(StyleError, match="circular"):
+ load_style(tmp_path / "x" / "../x/a.yaml")
+
+
+def test_a_cycle_through_a_symlink_is_found(tmp_path: Path) -> None:
+ (tmp_path / "a.yaml").write_text("version: 1\nextends: link.yaml\n")
+ try:
+ (tmp_path / "link.yaml").symlink_to(tmp_path / "a.yaml")
+ except OSError:
+ pytest.skip("symlinks are unavailable")
+ with pytest.raises(StyleError, match="circular"):
+ load_style(tmp_path / "a.yaml")
+
+
+def test_the_same_base_used_twice_is_not_a_cycle(tmp_path: Path) -> None:
+ (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n")
+ (tmp_path / "mid.yaml").write_text("version: 1\nextends: base.yaml\n")
+ (tmp_path / "top.yaml").write_text("version: 1\nextends: mid.yaml\n")
+ assert load_style(tmp_path / "top.yaml").numbering.scheme == "none"
+
+
+# --- S8: a dump is self-contained ----------------------------------------------------------------------
+
+def test_a_dump_does_not_extend_anything(tmp_path: Path) -> None:
+ (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n")
+ (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\nlocale: cs-CZ\n")
+ style = load_style(tmp_path / "child.yaml")
+ text = dump_style(style)
+ assert "extends" not in text
+ elsewhere = tmp_path / "elsewhere" / "deep"
+ elsewhere.mkdir(parents=True)
+ dumped = elsewhere / "dumped.yaml"
+ dumped.write_text(text)
+ reloaded = load_style(dumped)
+ assert reloaded.numbering.scheme == "none"
+ assert reloaded.locale == "cs-CZ"
+ assert reloaded.name == style.name
+
+
+@pytest.mark.parametrize("name", builtin_styles())
+def test_a_dumped_builtin_reloads_equal(name: str, tmp_path: Path) -> None:
+ style = load_style(name)
+ path = tmp_path / "dump.yaml"
+ path.write_text(dump_style(style))
+ assert "extends" not in path.read_text()
+ # The dump is flattened: it is the same style, minus the record of what it extended.
+ assert load_style(path) == replace(style, extends=None)
+
+
+def test_print_style_of_a_relative_extends_reloads_from_anywhere(tmp_path: Path) -> None:
+ (tmp_path / "base.yaml").write_text("version: 1\nplaceholders: {blank: '[__]'}\n")
+ (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\n")
+ done = _cli("--print-style", "--style", str(tmp_path / "child.yaml"))
+ assert done.returncode == 0, done.stderr
+ assert "extends" not in done.stdout
+ elsewhere = tmp_path / "other"
+ elsewhere.mkdir()
+ (elsewhere / "dump.yaml").write_text(done.stdout)
+ assert load_style(elsewhere / "dump.yaml").placeholders.blank == "[__]"
+
+
+# --- S9: version and extends are not overridable ------------------------------------------------------------
+
+@pytest.mark.parametrize("key", ["version", "extends"])
+def test_version_and_extends_cannot_be_overridden(key: str) -> None:
+ with pytest.raises(StyleError, match=key):
+ load_style("default", overrides={key: "2" if key == "version" else "continental"})
+
+
+def test_a_nested_override_of_version_is_refused_too() -> None:
+ with pytest.raises(StyleError, match="version"):
+ load_style("default", overrides={"version": 1})
+
+
+@pytest.mark.parametrize("setting", ["version=2", "extends=continental"])
+@pytest.mark.parametrize("print_style", [True, False])
+def test_the_cli_refuses_them(setting: str, print_style: bool) -> None:
+ args = ["--print-style"] if print_style else ["-", "-q"]
+ done = _cli(*args, "--set", setting, stdin=DOC)
+ assert done.returncode == 2, done.stdout
+ assert setting.split("=")[0] in done.stderr
+ assert "cannot be overridden" in done.stderr
+ assert "Traceback" not in done.stderr
+
+
+# --- S10: a YAML error in a --set value -------------------------------------------------------------------
+
+def test_a_yaml_error_in_an_override_names_the_key() -> None:
+ with pytest.raises(StyleError) as caught:
+ parse_override("numbering.levels", "[{counter: decimal")
+ message = str(caught.value)
+ assert "numbering.levels" in message
+ assert "YAML" in message
+ assert "flow" in message or "expected" in message or "while" in message # the YAML problem itself
+
+
+def test_a_yaml_error_in_an_override_reaches_stderr() -> None:
+ done = _cli("--print-style", "--set", "numbering.levels=[{counter: decimal")
+ assert done.returncode == 2
+ assert "numbering.levels" in done.stderr
+ assert "YAML" in done.stderr
+ assert "Traceback" not in done.stderr
+ assert "must be a list" not in done.stderr # not the misleading type error
+
+
+def test_a_yaml_error_in_a_render_run_reaches_stderr() -> None:
+ done = _cli("-", "-q", "--set", "enumeration.levels=[{a", stdin=DOC)
+ assert done.returncode != 0
+ assert "enumeration.levels" in done.stderr
+ assert "YAML" in done.stderr
+ assert "Traceback" not in done.stderr
+
+
+def test_valid_yaml_overrides_still_parse() -> None:
+ assert parse_override("title_block.version", "true") is True
+ assert parse_override("contents.depth", "3") == 3
+ assert parse_override("numbering.levels", "[{counter: decimal}]") == [{"counter": "decimal"}]
+
+
+# --- S11: template fields ------------------------------------------------------------------------------------
+
+@pytest.mark.parametrize("name", builtin_styles())
+def test_every_builtin_style_passes(name: str) -> None:
+ load_style(name)
+
+
+@pytest.mark.parametrize("language", ["en", "cs", "de", "fr", "pl", "sk"])
+def test_every_builtin_label_passes(language: str) -> None:
+ from legaldown_render.style import BUILTIN_LABELS
+ labels = BUILTIN_LABELS[language]
+ overrides = {f"labels.{item.name}": getattr(labels, item.name) for item in dataclasses.fields(labels)}
+ assert load_style("default", overrides=overrides).labels == labels
+
+
+def _problems(overrides: dict[str, object]) -> list[str]:
+ with pytest.raises(StyleError) as caught:
+ load_style("default", overrides=overrides)
+ return caught.value.problems
+
+
+def test_labels_condition_must_show_the_condition() -> None:
+ """§15.8: a conditional unit's condition MUST be shown."""
+ problems = _problems({"labels.condition": "Conditional"})
+ assert any(p.startswith("labels.condition:") and "{condition}" in p for p in problems)
+
+
+def test_references_format_must_show_the_designation() -> None:
+ problems = _problems({"references.format": "nope"})
+ assert any(p.startswith("references.format:") and "{designation}" in p for p in problems)
+
+
+def test_a_typo_in_a_field_is_named() -> None:
+ problems = _problems({"references.format": "{desgination}"})
+ assert any(p.startswith("references.format:") and "{desgination}" in p and "{designation}" in p for p in problems)
+
+
+@pytest.mark.parametrize(("setting", "value"), [
+ ("labels.condition", "Only if {condition} {n}"),
+ ("labels.attachment_file", "{condition}"),
+ ("labels.drafting_note", "{note}"),
+ ("labels.colon", "{n}"),
+ ("references.format", "{n}{designation}"),
+ ("numbering.levels", [{"label": "{n}", "ref": "{path}"}]),
+ ("numbering.levels", [{"label": "{section}", "ref": "{n}"}]),
+ ("numbering.levels", [{"label": "{n}", "ref": ".x"}]),
+ ("enumeration.levels", [{"label": "{wrong}", "ref": "{n}"}]),
+ ("enumeration.levels", [{"label": "({n})", "ref": "{path}{n}"}]),
+ ("paragraphs.format", {"label": "{n}", "ref": "{foo}"}),
+ ("paragraphs.format", {"label": "{n}", "ref": "{section}"}),
+])
+def test_invalid_templates_are_refused(setting: str, value: object) -> None:
+ problems = _problems({setting: value})
+ assert any(p.startswith(setting) for p in problems), problems
+
+
+def test_the_problem_names_the_level() -> None:
+ problems = _problems({"numbering.levels": [{"ref": "{n}"}, {"ref": "x"}]})
+ assert any(p.startswith("numbering.levels[1].ref:") for p in problems)
+
+
+@pytest.mark.parametrize(("setting", "value"), [
+ ("labels.condition", "{condition}"),
+ ("labels.condition", "Only if: {condition} (see {condition})"),
+ ("labels.condition", "[{condition}] {x y} { } {} {1} {{ }} {-n}"),
+ ("labels.attachment_file", "no field at all"),
+ ("labels.attachment_file", "{file}"),
+ ("labels.drafting_note", "Note {1} {} {a b}"),
+ ("labels.colon", ""),
+ ("references.format", "Section {designation}"),
+ ("references.format", "{designation} {a.b} {0}"),
+ ("numbering.levels", [{"label": "{path}.", "ref": "{n}"}]),
+ ("numbering.levels", [{"label": "Article {n} ({path})", "ref": ".{n}"}]),
+ ("enumeration.levels", [{"label": "({n})", "ref": "{section}({n})"}]),
+ ("enumeration.levels", [{"label": "{section}.{path}.{n}", "ref": "({n})"}]),
+ ("paragraphs.format", {"label": "{section}.{n}", "ref": ".{n}"}),
+ ("paragraphs.format", {"label": "{path} {n} {section}", "ref": "{section}.{n}"}),
+])
+def test_valid_templates_are_accepted(setting: str, value: object) -> None:
+ load_style("default", overrides={setting: value})
+
+
+def test_unset_labels_are_not_checked() -> None:
+ assert load_style("default", overrides={"labels.condition": None}).labels.condition is None
+
+
+def test_braces_that_are_not_identifiers_stay_literal() -> None:
+ result = render(DOC, RenderOptions(format="text", overrides={"references.format": "{{{designation}}} {0} {}"}))
+ assert "{{1}} {0} {}" in result.output
+
+
+def test_a_style_object_is_checked_too() -> None:
+ from dataclasses import replace
+
+ from legaldown_render.style import References
+ style = replace(load_style("default"), references=References(format="oops"))
+ with pytest.raises(StyleError, match="references.format"):
+ load_style(style)
+
+
+def test_all_template_problems_are_reported_together() -> None:
+ problems = _problems({"references.format": "x", "labels.condition": "y", "placeholders.blank": ["x"]})
+ assert any(p.startswith("references.format") for p in problems)
+ assert any(p.startswith("labels.condition") for p in problems)
+ assert any(p.startswith("placeholders.blank") for p in problems)
From e035a29ebc7fdadb8e8b3437db273b303af674df Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:48:03 +0000
Subject: [PATCH 009/118] Style templates: an empty labels.condition keeps its
render-time fallback (S11 follow-up)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The required-field check no longer refuses an empty template for a setting
the resolver already replaces when empty: `labels.condition or "{condition}"`
(Resolver._condition) and `labels.attachment_file or "{file}"`. An empty
labels.condition shows the bare condition, which meets §15.8, and refusing it
would break existing styles for no gain. TemplateFields.empty_falls_back marks
exactly these two settings. Settings with no fallback (references.format and
the numbering/enumeration/paragraph refs) still require {designation}/{n};
unknown fields are checked in every non-empty template.
Tests: new tests for the empty condition label (renders the bare condition)
and for empty templates without a fallback. docs/style-templates.md updated.
---
docs/style-templates.md | 2 +-
src/legaldown_render/style/loader.py | 7 ++++++-
src/legaldown_render/style/model.py | 12 +++++++++---
tests/test_wp6_style.py | 16 ++++++++++++++++
4 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/docs/style-templates.md b/docs/style-templates.md
index c0027e1..ac76ff4 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -170,7 +170,7 @@ typo would otherwise be shown literally), and so is a missing required field.
| `enumeration.levels[].label`, `paragraphs.format.label` | `{n}`, `{path}`, `{section}` | none |
| `enumeration.levels[].ref`, `paragraphs.format.ref` | `{n}`, `{section}` | `{n}` |
| `references.format` | `{designation}` | `{designation}`, or every reference reads the same |
-| `labels.condition` | `{condition}` | `{condition}`, as §15.8 requires the condition to be shown |
+| `labels.condition` | `{condition}` | `{condition}`, as §15.8 requires the condition to be shown; an empty value is allowed and shows the bare condition |
| `labels.attachment_file` | `{file}` | none |
| every other label | none | none |
diff --git a/src/legaldown_render/style/loader.py b/src/legaldown_render/style/loader.py
index 56f3498..d53f58a 100644
--- a/src/legaldown_render/style/loader.py
+++ b/src/legaldown_render/style/loader.py
@@ -309,7 +309,10 @@ def _template_problems(style: Style) -> list[str]:
(:func:`~.model.template_settings`): an identifier-shaped field the
setting does not accept (a typo would be shown literally), or a required
field it leaves out (``labels.condition`` without ``{condition}`` would
- hide the condition, which §15.8 requires to be shown)."""
+ hide the condition, which §15.8 requires to be shown). An empty template
+ is exempt from the required fields where the resolver falls back to the
+ bare field for it (``labels.condition``): it already shows the condition.
+ Where there is no such fallback, an empty template lacks its fields."""
problems = []
for key, template, fields in template_settings(style):
found = set(_FIELD_RE.findall(template))
@@ -317,6 +320,8 @@ def _template_problems(style: Style) -> list[str]:
for name in sorted(found - fields.accepted):
takes = f"accepts {accepted}" if accepted else "takes no fields"
problems.append(f"{key}: unknown field {{{name}}}; this setting {takes}")
+ if fields.empty_falls_back and not template:
+ continue
for name in sorted(fields.required - found):
problems.append(f"{key}: must contain {{{name}}}" + (f" — {fields.why}" if fields.why else ""))
return problems
diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py
index 6903be2..8032faf 100644
--- a/src/legaldown_render/style/model.py
+++ b/src/legaldown_render/style/model.py
@@ -298,11 +298,15 @@ class TemplateFields:
Only identifier-shaped braces (``{n}``, ``{designation}``) are fields; any
other braces (``{}``, ``{1}``, ``{a b}``) are literal text, as the
resolver keeps them. ``why`` says what a required field is for, in the
- message about a template that leaves it out."""
+ message about a template that leaves it out. ``empty_falls_back`` marks
+ a setting the resolver replaces with the bare field when it is empty
+ (``labels.condition or "{condition}"``), so an empty template is not
+ missing its required field: it shows the condition as written."""
accepted: frozenset[str] = frozenset()
required: frozenset[str] = frozenset()
why: str = ""
+ empty_falls_back: bool = False
#: The designation-building settings are read in :mod:`..resolve.numbering`
@@ -320,9 +324,11 @@ class TemplateFields:
#: Labels are plain words, except for the two that carry a value.
LABEL_FIELDS: dict[str, TemplateFields] = {
"condition": TemplateFields(
- frozenset({"condition"}), frozenset({"condition"}), "a conditional unit's condition must be shown (§15.8)"
+ frozenset({"condition"}), frozenset({"condition"}), "a conditional unit's condition must be shown (§15.8)",
+ empty_falls_back=True, # Resolver._condition: ``self.labels.condition or "{condition}"``
),
- "attachment_file": TemplateFields(frozenset({"file"})),
+ # Resolver: ``self.labels.attachment_file or "{file}"``; it requires nothing, so the fallback is moot.
+ "attachment_file": TemplateFields(frozenset({"file"}), empty_falls_back=True),
}
diff --git a/tests/test_wp6_style.py b/tests/test_wp6_style.py
index 870e752..f0cdfd5 100644
--- a/tests/test_wp6_style.py
+++ b/tests/test_wp6_style.py
@@ -257,6 +257,22 @@ def test_valid_templates_are_accepted(setting: str, value: object) -> None:
load_style("default", overrides={setting: value})
+def test_an_empty_condition_label_keeps_its_render_time_fallback() -> None:
+ """The resolver shows the bare condition for an empty ``labels.condition``
+ (§15.8 is met), so the style is not refused."""
+ style = load_style("default", overrides={"labels.condition": ""})
+ assert style.labels.condition == ""
+ doc = "---\nlegaldown: '0.2'\ntitle: T\n---\n\n# One {#one when=forum:courts}\n\nText.\n"
+ assert "forum:courts" in render(doc, RenderOptions(format="text", overrides={"labels.condition": ""})).output
+
+
+def test_an_empty_template_without_a_fallback_still_lacks_its_field() -> None:
+ for setting in ("references.format", "numbering.levels", "paragraphs.format"):
+ value: object = {"label": "{n}", "ref": ""} if setting == "paragraphs.format" else (
+ [{"ref": ""}] if setting == "numbering.levels" else "")
+ assert any(p.startswith(setting) for p in _problems({setting: value}))
+
+
def test_unset_labels_are_not_checked() -> None:
assert load_style("default", overrides={"labels.condition": None}).labels.condition is None
From 493c924b74ccfa1e9c55f974d0e7fbcf9747623a Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:48:41 +0000
Subject: [PATCH 010/118] Links and images: relative image paths only,
deny-list for links, empty links (B6, W3, B9/W8)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
B6 (§2.3): an image whose path has a URI scheme (data: included), starts with
/ or \ (//host too) or is a Windows drive path is replaced by its alt text in
the resolver (Resolver._resolve_inline) and reported as the Core Error
path-not-relative, which --strict refuses. The validator 0.4 does not check
image paths; the renderer's report is skipped when the validator names the same
path. ../ stays allowed (no default document root). The builder now keeps
javascript:/file:/data: links and images as nodes (markdown-it validateLink off)
so they can be reported and shown as text instead of as literal Markdown.
W3 (§8.7): the HTML allow-list becomes a deny-list (javascript, vbscript, file,
data) after the WHATWG normalisation; new urls.py shared by resolver and both
writers so they agree. A refused link is its text only in both writers.
B9/W8: an empty href is just the link text; a link with no text shows its URL;
the text writer omits when it equals the text minus mailto:/tel:.
Changed goldens: features.{default,continental,outline}.txt and
features.default.html: the "[an unsafe one](javascript:alert(1))" source text is
now "an unsafe one" (the link is refused, its text kept).
---
CONFORMANCE.md | 6 +
docs/architecture.md | 21 ++-
docs/concepts.md | 5 +-
src/legaldown_render/build.py | 7 +
src/legaldown_render/resolve/resolver.py | 44 ++++-
src/legaldown_render/tree.py | 9 +
src/legaldown_render/urls.py | 62 +++++++
src/legaldown_render/writers/html.py | 31 ++--
src/legaldown_render/writers/text.py | 23 ++-
tests/golden/features.continental.txt | 2 +-
tests/golden/features.default.html | 2 +-
tests/golden/features.default.txt | 2 +-
tests/golden/features.outline.txt | 2 +-
tests/test_wp3_links.py | 216 +++++++++++++++++++++++
14 files changed, 397 insertions(+), 35 deletions(-)
create mode 100644 src/legaldown_render/urls.py
create mode 100644 tests/test_wp3_links.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 542eca8..d8ff65d 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -38,6 +38,8 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ |
| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ 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 |
+| Links (§8.7) | ✅ Every link is a hyperlink in HTML and shows `text ` in plain text, whatever its scheme (`ftp:`, `sip:`, `urn:`, ...), except one whose URL is empty or uses `javascript:`, `vbscript:`, `file:`, or `data:`, which shows its text alone. A link with no text shows its URL; the text writer omits `` for an autolink |
+| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses |
| 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 |
@@ -50,6 +52,10 @@ It is checked against the specification's own examples and fixtures corpus (`tes
- **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)).
+- **`path-not-relative` for images.** The renderer reports the Core rule `path-not-relative`
+ (§2.3, §16.2) for an image path, because the validator (0.4) does not check image paths; it
+ leaves its own report out when the validator names the same path. The Error has no line number.
+ The containment rule (`path-outside-root`) is not checked: no document root is configured.
- **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,
diff --git a/docs/architecture.md b/docs/architecture.md
index 7d7d03d..afddb73 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -232,7 +232,8 @@ turns into an error.
- **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`, a specification rule id, plus renderer-specific ids prefixed `render-`:
+`ref-not-enumerated` and `path-not-relative` (an Error for an image path that is not relative, §2.3,
+reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
`render-not-processed`, `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), 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: they are about a directive within a block, and the validator makes only a block's
@@ -242,8 +243,21 @@ line public (`Document.line_of`, U2).
- Raw HTML in the source is never emitted (§8.7).
- Every text node and attribute is escaped.
-- Links and images whose URL has a scheme other than `http`, `https`, `mailto`, or `tel` render
- as text. markdown-it-py also refuses `javascript:`-style URLs when parsing.
+- A link is a hyperlink (§8.7) unless its URL is empty or its scheme is `javascript`, `vbscript`,
+ `file`, or `data` (a deny-list, `urls.DENIED_SCHEMES`, judged as a browser reads the URL: tabs,
+ newlines, controls, and case are ignored). Every other scheme (`ftp:`, `sip:`, `urn:`, ...) is
+ linked. A link that is not made renders as its text alone, in HTML and in text alike. The
+ builder keeps such links as nodes (it turns off markdown-it-py's own `validateLink`, which
+ would leave them as literal Markdown), so the writers' check is the one that applies;
+ `is_safe_href` stays as defence in depth for a tree built another way. A link with no text shows
+ its URL; the text writer leaves out ` ` when the URL, less a `mailto:` or `tel:` prefix,
+ is the text.
+- An image path must be relative with no URI scheme (§2.3), so a remote resource is never fetched
+ or embedded. Resolution, not a writer, enforces it: an image whose path has a scheme (`https:`,
+ `file:`, `data:`, a Windows drive such as `C:\x`) or starts with `/` or `\` (`//host`) is
+ replaced by its alt text and reported as the Core Error `path-not-relative`, which `--strict`
+ refuses. `../` stays allowed: §2.3 defines no default document root. The writers' image check
+ is a second line.
- At Rendering level, the only files read are the document, the style template, and the answers
file the CLI reads with `--answers` (the Python API takes answers as a mapping). Attachment
files and includes are never opened.
@@ -256,6 +270,7 @@ line public (`Document.line_of`, U2).
|---|---|
| `tests/test_golden.py` | The test documents under each built-in style, compared byte for byte with `tests/golden/`. Regenerate with `pytest --update-golden` and review the diff |
| `tests/test_render.py` | Numbering schemes, designations, fallbacks, every failure marker, template view, labels by language, HTML safety, unique anchors, determinism, strict mode, locales |
+| `tests/test_wp3_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme deny-list, empty URLs and empty link text |
| `tests/test_style.py`, `tests/test_values.py` | Style layering and validation; counters and locale formatting |
| `tests/test_cli.py` | The command |
| `tests/conformance/` | Every example and fixture in the specification repository renders, every internal link has a target, and each invalid fixture renders its rule's failure marker. Needs `$LEGALDOWN_SPEC_DIR` or a sibling `../LegalDown` checkout |
diff --git a/docs/concepts.md b/docs/concepts.md
index 4240eac..1509192 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -83,8 +83,9 @@ no random ids, stable anchor names, stable ordering. This makes golden-file test
lets rendered output be diffed in Git just like the source.
**6. It is safe with documents you did not write.** Raw HTML in the source is never emitted
-(§8.7). All text is escaped for the target format. Link URLs are checked against an allow-list of
-schemes. At Rendering level the renderer reads no files other than the document, the chosen
+(§8.7). All text is escaped for the target format. A link whose URL is empty or uses a script,
+`file:`, or `data:` scheme is shown as its text, and an image whose path is not relative is shown as
+its alt text and reported (§2.3). At Rendering level the renderer reads no files other than the document, the chosen
style template, and the answers file the CLI is given with `--answers`.
## Vocabulary
diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py
index a07daa4..c9aa8e5 100644
--- a/src/legaldown_render/build.py
+++ b/src/legaldown_render/build.py
@@ -180,6 +180,13 @@ def __init__(self, document: Document, result: ValidationResult) -> None:
self.language = document.metadata.language or "en"
# Inline parsing only: block structure is the validator's.
self.md = MarkdownIt("commonmark", {"html": True})
+ # markdown-it would leave ``[x](javascript:...)`` as literal text and
+ # ```` unparsed, so neither could be told apart from
+ # prose. The builder keeps the node instead: the resolver reports an
+ # image path that is not relative (§2.3) and replaces the image with
+ # its alt text, and the writers show a refused link as its text
+ # (`urls.DENIED_SCHEMES`), which is the safety markdown-it was for.
+ self.md.validateLink = lambda url: True
self.env: dict = {}
self.payloads: list[_Payload] = []
# The markers the validator placed, by (section index or None for
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index da229c4..6753141 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -16,13 +16,16 @@
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``, ``render-ref-ambiguous``,
-and ``render-not-processed`` for constructs beyond the Rendering level (§17.5).
+and ``render-not-processed`` for constructs beyond the Rendering level (§17.5),
+and ``path-not-relative`` for an image whose path is not relative (§2.3), which
+the validator does not check yet.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, replace
from typing import Any, NamedTuple
+from urllib.parse import unquote
from legaldown import Diagnostic, Document, ValidationResult
from legaldown.grammar import (
@@ -81,6 +84,7 @@
plain_text,
unlinked,
)
+from ..urls import is_relative_path
from .numbering import RENUMBERED, extend, fill, format_counter, heading_levels
from .values import DURATION_UNITS, Formatter
@@ -510,11 +514,13 @@ def _resolve_inlines(self, inlines: tuple[Inline, ...]) -> tuple[Inline, ...]:
out: list[Inline] = []
for inline in inlines:
resolved = self._resolve_inline(inline)
- if resolved is not None:
+ if isinstance(resolved, tuple):
+ out.extend(resolved)
+ elif resolved is not None:
out.append(resolved)
return tuple(out)
- def _resolve_inline(self, inline: Inline) -> Inline | None:
+ def _resolve_inline(self, inline: Inline) -> Inline | tuple[Inline, ...] | None:
match inline:
case DirectiveSource(directive=directive):
return self._directive(directive)
@@ -530,9 +536,16 @@ def _resolve_inline(self, inline: Inline) -> Inline | None:
case Link(children=children, title=title):
return replace(inline, children=self._resolve_inlines(children),
title=self._resolve_inlines(_attribute_text(title)))
- case Image(children=children, title=title):
- return replace(inline, children=self._resolve_inlines(_attribute_text(children)),
- title=self._resolve_inlines(_attribute_text(title)))
+ case Image(src=src, children=children, title=title):
+ alt = self._resolve_inlines(_attribute_text(children))
+ if not is_relative_path(src):
+ # §2.3: an image path is relative and has no URI scheme, so a
+ # remote or absolute image is never fetched or embedded. Its
+ # alt text stands in; the validator (0.4) does not check
+ # image paths, so the renderer reports the Core rule.
+ self._not_relative(src)
+ return alt
+ return replace(inline, children=alt, title=self._resolve_inlines(_attribute_text(title)))
case _:
return inline
@@ -838,6 +851,25 @@ def _warn(self, rule: str, message: str) -> None:
if diagnostic not in self.diagnostics:
self.diagnostics.append(diagnostic)
+ def _not_relative(self, path: str) -> None:
+ """Report an image *path* that is not relative as the Core Error
+ ``path-not-relative`` (§2.3, §16.2), unless the validator has
+ reported it. The renderer has no line for an image, so a path the
+ validator named is the one it has covered. markdown-it has
+ percent-encoded the path ("C:\\x" reads "C:%5Cx"), so it is decoded
+ to name the path as written."""
+ path = unquote(path)
+ if any(d.rule == "path-not-relative" and path in d.message for d in self.result.diagnostics):
+ return
+ diagnostic = Diagnostic(
+ rule="path-not-relative",
+ level="error",
+ message=f"Image path '{path}' is not relative (§2.3): an image path is a relative path with no URI "
+ f"scheme, and a remote resource is never fetched. Its alt text is shown instead.",
+ )
+ if diagnostic not in self.diagnostics:
+ self.diagnostics.append(diagnostic)
+
_HANDLERS: dict[str, Callable[[Resolver, Directive, str], Inline | None]] = {
"ref": Resolver._ref,
diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py
index edce701..7e88575 100644
--- a/src/legaldown_render/tree.py
+++ b/src/legaldown_render/tree.py
@@ -509,3 +509,12 @@ def plain_text(inlines: tuple[Inline, ...]) -> str:
case _ if getattr(inline, "children", None) is not None:
parts.append(plain_text(inline.children))
return "".join(parts)
+
+
+def has_visible_content(inlines: tuple[Inline, ...]) -> bool:
+ """True if resolved *inlines* show anything: text other than white space,
+ or an image (which has a picture even when its alt text is empty)."""
+ if plain_text(inlines).strip():
+ return True
+ return any(isinstance(inline, Image) or has_visible_content(getattr(inline, "children", None) or ())
+ for inline in inlines)
diff --git a/src/legaldown_render/urls.py b/src/legaldown_render/urls.py
new file mode 100644
index 0000000..c0282d3
--- /dev/null
+++ b/src/legaldown_render/urls.py
@@ -0,0 +1,62 @@
+"""Reading URLs the way the renderer needs to: which schemes a link may use
+(§8.7), and whether an image path is a relative one (§2.3).
+
+The checks are syntactic, like the validator's (§2.3). They are shared by the
+resolver, which decides what an image is, and by both writers, which decide
+what a link is, so that the outputs agree.
+"""
+from __future__ import annotations
+
+import re
+from urllib.parse import unquote
+
+#: Schemes whose links are never made: script (``javascript:``, ``vbscript:``),
+#: local files (``file:``), and ``data:``. markdown-it refuses the same
+#: schemes while parsing (``validateLink``, which lets some ``data:`` images
+#: through; a link may not); this list is the second line, for a URL that
+#: reaches a writer by another way. Every other scheme (``ftp:``, ``sip:``,
+#: ``urn:``, ...) is a hyperlink, since §8.7 requires links to be rendered as
+#: hyperlinks.
+DENIED_SCHEMES = frozenset({"javascript", "vbscript", "file", "data"})
+
+_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
+# What the WHATWG URL parser ignores before it reads a scheme: tabs and
+# newlines anywhere, and C0 controls and spaces at either end. Checking the
+# URL as a browser will read it means "java\tscript:" cannot slip through.
+_URL_IGNORED_RE = re.compile(r"[\t\n\r]")
+_URL_TRIMMED = "".join(chr(code) for code in range(0x21))
+
+
+def _as_read(url: str) -> str:
+ """*url* as a browser reads it, before the scheme."""
+ return _URL_IGNORED_RE.sub("", url).strip(_URL_TRIMMED)
+
+
+def url_scheme(url: str) -> str | None:
+ """The lower-cased scheme of *url* as a browser reads it, or None."""
+ scheme = _SCHEME_RE.match(_as_read(url))
+ return scheme.group(1).lower() if scheme else None
+
+
+def is_safe_href(href: str) -> bool:
+ """True unless *href* uses a scheme in :data:`DENIED_SCHEMES`, judged as a
+ browser parses the URL."""
+ return url_scheme(href) not in DENIED_SCHEMES
+
+
+def is_link_target(href: str) -> bool:
+ """True if a link to *href* is made: it names something, and is safe.
+ An empty destination would be a link to the page itself."""
+ return bool(_as_read(href)) and is_safe_href(href)
+
+
+def is_relative_path(path: str) -> bool:
+ """True if *path*, an image's, is a relative path (§2.3). Not relative: a
+ path with a URI scheme (``https://``, ``file://``, ``data:``), which
+ includes a Windows drive path (``C:\\x``, ``C:/x``, a scheme as far as a
+ parser can tell), and one that starts with ``/`` or ``\\`` (so also
+ ``//host``). A leading ``../`` is relative: §2.3 defines no default
+ document root. A percent-encoded character counts as the character, since
+ a path may be encoded by the time it is read."""
+ read = _as_read(unquote(path))
+ return not read or (_SCHEME_RE.match(read) is None and read[0] not in "/\\")
diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py
index b365e6f..b6989a6 100644
--- a/src/legaldown_render/writers/html.py
+++ b/src/legaldown_render/writers/html.py
@@ -9,7 +9,12 @@
Safety (docs/architecture.md): every text node and attribute is escaped, raw
HTML from the source never reaches this writer (§8.7), and a link whose URL
-uses a scheme outside :data:`SAFE_SCHEMES` renders as plain text.
+is empty or uses a script, ``file:``, or ``data:`` scheme
+(:data:`~legaldown_render.urls.DENIED_SCHEMES`, judged as a browser reads the
+URL) renders as its text alone. Every other scheme is a hyperlink (§8.7).
+markdown-it refuses such URLs first, while parsing; this is the second line.
+An image path that is not relative (§2.3) never gets here: resolution has
+replaced the image with its alt text and reported it.
"""
from __future__ import annotations
@@ -47,17 +52,12 @@
TermRef,
Text,
Value,
+ has_visible_content,
plain_text,
)
+from ..urls import is_link_target, is_safe_href
-SAFE_SCHEMES = frozenset({"http", "https", "mailto", "tel"})
-_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
-# What the WHATWG URL parser ignores before it reads a scheme: tabs and
-# newlines anywhere, and C0 controls and spaces at either end. Checking the
-# URL as a browser will read it means "java\tscript:" cannot slip through.
-_URL_IGNORED_RE = re.compile(r"[\t\n\r]")
-_URL_TRIMMED = "".join(chr(code) for code in range(0x21))
-
+__all__ = ["HtmlWriter", "is_safe_href", "stylesheet"]
def _text(value: str) -> str:
return escape(value, quote=False)
@@ -67,13 +67,6 @@ def _attr(value: str) -> str:
return escape(value, quote=True)
-def is_safe_href(href: str) -> bool:
- """True for relative URLs, fragments, and the schemes in SAFE_SCHEMES,
- judged as a browser parses the URL."""
- scheme = _SCHEME_RE.match(_URL_IGNORED_RE.sub("", href).strip(_URL_TRIMMED))
- return scheme is None or scheme.group(1).lower() in SAFE_SCHEMES
-
-
class HtmlWriter:
"""Writes a resolved tree as HTML."""
@@ -319,11 +312,13 @@ def inline(self, inline: Inline) -> str:
case Code(text=text):
return f"{_text(text)}"
case Link(href=href, children=children, title=title):
- if not is_safe_href(href):
+ # A link with no text shows its URL (§8.7: it is still a hyperlink).
+ shown = children if has_visible_content(children) else (Text(href.strip()),)
+ if not is_link_target(href):
return self.inlines(children)
title_attr = f' title="{_attr(plain_text(title))}"' if title else ""
outer, self._in_link = self._in_link, True
- content = self.inlines(children)
+ content = self.inlines(shown)
self._in_link = outer
return f'{content}'
case Image(src=src, children=children, title=title):
diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py
index 40167d4..e8fb00c 100644
--- a/src/legaldown_render/writers/text.py
+++ b/src/legaldown_render/writers/text.py
@@ -35,8 +35,28 @@
Text,
Value,
)
+from ..urls import is_link_target
_INDENT = " "
+_ADDRESS_SCHEMES = ("mailto:", "tel:")
+
+
+def _link_text(href: str, text: str) -> str:
+ """A link as plain text: its text, followed by ```` unless the URL is
+ no more than the text says. The same links are links as in HTML
+ (:func:`~legaldown_render.urls.is_link_target`); one that is not shows its
+ text alone, and one with no text shows its URL."""
+ if not is_link_target(href):
+ return text
+ url = href.strip()
+ if not text.strip():
+ return url
+ address = url
+ for prefix in _ADDRESS_SCHEMES:
+ if url.lower().startswith(prefix):
+ address = url[len(prefix):]
+ # An internal link has no URL worth printing; an autolink's text is its URL.
+ return text if url.startswith("#") or text in (url, address) else f"{text} <{url}>"
def inline_text(inlines: tuple[Inline, ...]) -> str:
@@ -55,8 +75,7 @@ def inline_text(inlines: tuple[Inline, ...]) -> str:
case Emphasis(children=children) | Strong(children=children) | DefinedTerm(children=children):
parts.append(inline_text(children))
case Link(href=href, children=children):
- text = inline_text(children)
- parts.append(text if href.startswith("#") or href == text else f"{text} <{href}>")
+ parts.append(_link_text(href, inline_text(children)))
case Image(children=children):
parts.append(inline_text(children))
case Choice(options=options, separator=separator):
diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt
index 5c995c3..0299a7e 100644
--- a/tests/golden/features.continental.txt
+++ b/tests/golden/features.continental.txt
@@ -63,7 +63,7 @@ This Services Agreement (this Agreement) is made between Acme and Beta Industrie
(1) These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], [INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], [INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], [INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], and [NOT PROCESSED: include includes/extra.lgd].
-(2) Raw HTML is dropped. A safe link stays; [an unsafe one](javascript:alert(1)) does not.
+(2) Raw HTML is dropped. A safe link stays; an unsafe one does not.
---
diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html
index a6d53fb..a6610a2 100644
--- a/tests/golden/features.default.html
+++ b/tests/golden/features.default.html
@@ -169,7 +169,7 @@
4.[INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee],
and [NOT PROCESSED: include includes/extra.lgd].
Raw HTML is dropped. A safe link stays;
-[an unsafe one](javascript:alert(1)) does not.
+an unsafe one does not.
Attachments
diff --git a/tests/golden/features.default.txt b/tests/golden/features.default.txt
index b302f63..4610d64 100644
--- a/tests/golden/features.default.txt
+++ b/tests/golden/features.default.txt
@@ -63,7 +63,7 @@ Inline code keeps {{term: services}} literal.
These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], [INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], [INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], [INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], and [NOT PROCESSED: include includes/extra.lgd].
-Raw HTML is dropped. A safe link stays; [an unsafe one](javascript:alert(1)) does not.
+Raw HTML is dropped. A safe link stays; an unsafe one does not.
---
diff --git a/tests/golden/features.outline.txt b/tests/golden/features.outline.txt
index 7f5255d..52d599e 100644
--- a/tests/golden/features.outline.txt
+++ b/tests/golden/features.outline.txt
@@ -63,7 +63,7 @@ IV. Broken References
These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVALID DATE: 2026-13-45], [INVALID AMOUNT], [UNKNOWN CURRENCY: XYZ], [UNKNOWN PARTY: nobody], [INVALID PARTY: Bad_Name], [UNKNOWN SIDE: nobody], [INVALID DURATION: -1], [INVALID DURATION UNIT: M], [INVALID FIELD: value], [UNKNOWN DIRECTIVE: trem], [UNKNOWN ATTACHMENT: schedule-z], [INVALID PLACEHOLDER: fee], and [NOT PROCESSED: include includes/extra.lgd].
-Raw HTML is dropped. A safe link stays; [an unsafe one](javascript:alert(1)) does not.
+Raw HTML is dropped. A safe link stays; an unsafe one does not.
---
diff --git a/tests/test_wp3_links.py b/tests/test_wp3_links.py
new file mode 100644
index 0000000..b848b52
--- /dev/null
+++ b/tests/test_wp3_links.py
@@ -0,0 +1,216 @@
+"""Links and images (WP3): which image paths are shown (B6, §2.3), which links
+are links (W3, §8.7), and links with no URL or no text (B9/W8)."""
+from __future__ import annotations
+
+import pytest
+
+from legaldown_render import RenderRefused, render
+from legaldown_render.writers.html import is_safe_href
+
+FRONT = "---\ntitle: T\n---\n"
+
+
+def text(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="text", **settings).output
+
+
+def html(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="html", **settings).output
+
+
+# -- B6: image paths (§2.3) ---------------------------------------------------
+
+NON_RELATIVE = [
+ "https://example.com/p.png",
+ "HTTP://example.com/p.png",
+ "ftp://example.com/p.png",
+ "file:///etc/passwd",
+ "data:image/png;base64,AAAA",
+ "data:text/html,x",
+ "javascript:alert(1)",
+ "tel:123",
+ "/etc/passwd",
+ "/abs/p.png",
+ "//host/p.png",
+ "\\\\host\\share\\p.png",
+ "\\abs.png",
+ "C:\\x\\p.png",
+ "C:/x/p.png",
+ "c:p.png",
+]
+
+
+@pytest.mark.parametrize("path", NON_RELATIVE)
+def test_a_non_relative_image_shows_its_alt_text(path: str) -> None:
+ source = f"# A\n\nBefore  after.\n"
+ for fmt in ("html", "text"):
+ result = render(FRONT + source, format=fmt)
+ assert " None:
+ result = render(FRONT + "# A\n\n\n", format="html")
+ assert "data:" not in result.output
+ assert " None:
+ result = render(FRONT + f"# A\n\n\n", format="html")
+ assert f'' in result.output
+ assert not [d for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert result.ok
+
+
+def test_a_disguised_scheme_is_still_a_scheme() -> None:
+ # WHATWG ignores tabs and newlines, and a percent-encoded backslash is a backslash.
+ for path in ("ht\ttps://example.com/p.png", "%5Chost%5Cp.png", "%5C%5Chost%5Cp.png"):
+ result = render(FRONT + f"# A\n\n\n", format="html")
+ assert " None:
+ result = render(FRONT + "# A {#a}\n\nSee .\n", format="html")
+ assert "Acme (" in result.output
+
+
+def test_an_image_inside_a_link_keeps_the_link() -> None:
+ result = render(FRONT + "# A\n\n[](https://example.com)\n", format="html")
+ assert 'logo' in result.output
+
+
+def test_the_same_image_path_is_reported_once() -> None:
+ result = render(FRONT + "# A\n\n  \n")
+ errors = [d for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert len(errors) == 2 # the same path twice is one finding
+
+
+def test_strict_refuses_a_non_relative_image() -> None:
+ with pytest.raises(RenderRefused) as refused:
+ render(FRONT + "# A\n\n\n", strict=True)
+ assert "path-not-relative" in {d.rule for d in refused.value.diagnostics}
+ render(FRONT + "# A\n\n\n", strict=True)
+
+
+def test_the_validators_own_report_is_not_repeated(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown import Diagnostic
+ from legaldown import validate as real_validate
+
+ import legaldown_render.api as api
+
+ def validate(document, **kwargs): # type: ignore[no-untyped-def]
+ result = real_validate(document, **kwargs)
+ found = Diagnostic(rule="path-not-relative", level="error", line=5,
+ message="Image path 'https://x/p.png' is not relative (§2.3).")
+ result.diagnostics.append(found)
+ return result
+
+ monkeypatch.setattr(api, "validate", validate)
+ result = render(FRONT + "# A\n\n \n")
+ errors = [d for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert len(errors) == 2 # the validator's, and ours for the path it did not name
+ assert any(d.line == 5 for d in errors)
+ assert any("/other.png" in d.message for d in errors)
+
+
+# -- W3: which links are links (§8.7) -----------------------------------------
+
+
+@pytest.mark.parametrize("href", [
+ "ftp://example.com/f", "sip:alice@example.com", "urn:isbn:0451450523", "https://example.com",
+ "mailto:a@example.com", "tel:+420123456789", "magnet:?xt=urn:btih:x", "attachments/a.pdf", "../a.pdf", "#top",
+])
+def test_every_link_but_the_dangerous_ones_is_a_hyperlink(href: str) -> None:
+ output = html(f"# A\n\nSee [the link](<{href}>).\n")
+ assert f'the link' in output
+
+
+@pytest.mark.parametrize("href", [
+ "javascript:alert(1)", "JavaScript:alert(1)", " javascript:alert(1)", "java\tscript:alert(1)",
+ "jav\nascript:x", "vbscript:x", "file:///etc/passwd", "data:text/html,x", "data:image/png;base64,AAAA",
+])
+def test_a_refused_link_is_its_text_in_both_writers(href: str) -> None:
+ # The builder is handed the URL directly: markdown-it refuses some of these
+ # itself when parsing (§8.7), which is why is_safe_href is only the second line.
+ from legaldown_render.style import load_style
+ from legaldown_render.tree import Link, Text
+ from legaldown_render.writers.html import HtmlWriter
+ from legaldown_render.writers.text import inline_text
+
+ inlines = (Link(href, (Text("click"),)),)
+ assert HtmlWriter(load_style(None)).inlines(inlines) == "click"
+ assert inline_text(inlines) == "click"
+ assert not is_safe_href(href)
+
+
+def test_the_deny_list_is_judged_as_a_browser_reads_the_url() -> None:
+ for href in ("java\tscript:alert(1)", "jav\nascript:x", "\x01javascript:x", " JAVASCRIPT:x", "data:text/html,x",
+ "VBScript:x", "FILE:///x"):
+ assert not is_safe_href(href), repr(href)
+ for href in ("https://example.com", "#section", "attachments/a.pdf", "mailto:a@example.com", "ftp://x/y",
+ "sip:a@b", "urn:x:y", "a:b", "x/data:y", "javascript"):
+ assert is_safe_href(href), href
+
+
+def test_a_refused_link_in_a_document_is_its_text() -> None:
+ # markdown-it would leave these as literal Markdown; the builder keeps them
+ # as links so that both writers can show their text alone.
+ body = "# A\n\nSee [click](javascript:alert(1)) and [the file](file:///x) and [d](data:text/html,x).\n"
+ assert "See click and the file and d." in text(body)
+ output = html(body)
+ assert "See click and the file and d." in output
+ assert "href=" not in output.split("")[1]
+ assert "alert" not in output
+
+
+def test_a_link_with_an_unusual_scheme_shows_its_url_in_text() -> None:
+ assert "the file " in text("# A\n\nGet [the file](ftp://example.com/f).\n")
+
+
+# -- B9/W8: links without a URL or without text -----------------------------
+
+
+def test_a_link_with_no_url_is_its_text() -> None:
+ assert "")[1]
+ assert 'href=""' not in html("# A\n\nSee [x]().\n")
+ assert "See x." in text("# A\n\nSee [x]().\n")
+ assert "x <>" not in text("# A\n\nSee [x]().\n")
+ assert "See x." in text("# A\n\nSee [x](<>).\n")
+ assert 'href=""' not in html("# A\n\nSee [x](< >).\n")
+
+
+def test_a_link_with_no_url_and_no_text_is_nothing() -> None:
+ assert "See ." in text("# A\n\nSee [](<>).\n")
+ assert ").\n").split("")[1]
+
+
+def test_a_link_with_no_text_shows_its_url() -> None:
+ assert 'http://z.y' in html("# A\n\nSee [](http://z.y).\n")
+ assert "See http://z.y." in text("# A\n\nSee [](http://z.y).\n")
+
+
+def test_an_autolink_is_its_address_alone_in_text() -> None:
+ assert "Mail a@b.c now" in text("# A\n\nMail now\n")
+ assert "Call tel:+1 now" in text("# A\n\nCall [tel:+1](tel:+1) now\n")
+ assert "Call +1 now" in text("# A\n\nCall [+1](tel:+1) now\n")
+ assert "See https://x.y/z." in text("# A\n\nSee .\n")
+ assert 'href="mailto:a@b.c"' in html("# A\n\nMail now\n")
+
+
+def test_a_link_text_that_differs_from_its_url_shows_both_in_text() -> None:
+ assert "the site " in text("# A\n\nSee [the site](https://x.y/z)\n")
+ assert "Mail a " in text("# A\n\nMail [a](mailto:a@b.c)\n")
+
+
+def test_a_link_around_a_picture_keeps_the_picture_not_its_url() -> None:
+ output = html("# A\n\n[](https://example.com)\n")
+ assert '' in output
From 750cba4bf79f50bd013201c7c01ba8448d4a6ea8 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:50:15 +0000
Subject: [PATCH 011/118] Empty image path shows its alt text; deny-list
comments say it is the gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An image with an empty or whitespace-only path is replaced by its alt text in
the resolver (no , which requests the page itself) and reports
nothing: §2.3 has no rule for an empty path. Tests for both writers.
urls.py, html.py and docs/architecture.md no longer say markdown-it refuses
unsafe schemes while parsing: its validateLink is off in the builder, and the
deny-list is the gate for every href/src the HTML writer emits (Link, Image,
Value).
---
CONFORMANCE.md | 2 +-
docs/architecture.md | 10 ++++++----
src/legaldown_render/resolve/resolver.py | 6 +++++-
src/legaldown_render/urls.py | 18 ++++++++++++------
src/legaldown_render/writers/html.py | 3 ++-
tests/test_wp3_links.py | 10 ++++++++++
6 files changed, 36 insertions(+), 13 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index d8ff65d..a0dd3ed 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -39,7 +39,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| `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 |
| Links (§8.7) | ✅ Every link is a hyperlink in HTML and shows `text ` in plain text, whatever its scheme (`ftp:`, `sip:`, `urn:`, ...), except one whose URL is empty or uses `javascript:`, `vbscript:`, `file:`, or `data:`, which shows its text alone. A link with no text shows its URL; the text writer omits `` for an autolink |
-| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses |
+| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
| 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 |
diff --git a/docs/architecture.md b/docs/architecture.md
index afddb73..c72fe12 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -249,14 +249,16 @@ line public (`Document.line_of`, U2).
linked. A link that is not made renders as its text alone, in HTML and in text alike. The
builder keeps such links as nodes (it turns off markdown-it-py's own `validateLink`, which
would leave them as literal Markdown), so the writers' check is the one that applies;
- `is_safe_href` stays as defence in depth for a tree built another way. A link with no text shows
- its URL; the text writer leaves out ` ` when the URL, less a `mailto:` or `tel:` prefix,
- is the text.
+ that deny-list is the gate for every `href` and `src` the HTML writer emits (links, images,
+ value links), and markdown-it-py's check is off so that the resolver can report §2.3 paths.
+ A link with no text shows its URL; the text writer leaves out ` ` when the URL, less a
+ `mailto:` or `tel:` prefix, is the text.
- An image path must be relative with no URI scheme (§2.3), so a remote resource is never fetched
or embedded. Resolution, not a writer, enforces it: an image whose path has a scheme (`https:`,
`file:`, `data:`, a Windows drive such as `C:\x`) or starts with `/` or `\` (`//host`) is
replaced by its alt text and reported as the Core Error `path-not-relative`, which `--strict`
- refuses. `../` stays allowed: §2.3 defines no default document root. The writers' image check
+ refuses. An image with an empty path shows its alt text too, with no diagnostic (§2.3 has no
+ rule for it). `../` stays allowed: §2.3 defines no default document root. The writers' image check
is a second line.
- At Rendering level, the only files read are the document, the style template, and the answers
file the CLI reads with `--answers` (the Python API takes answers as a mapping). Attachment
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 6753141..dfe80e9 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -84,7 +84,7 @@
plain_text,
unlinked,
)
-from ..urls import is_relative_path
+from ..urls import is_empty_path, is_relative_path
from .numbering import RENUMBERED, extend, fill, format_counter, heading_levels
from .values import DURATION_UNITS, Formatter
@@ -538,6 +538,10 @@ def _resolve_inline(self, inline: Inline) -> Inline | tuple[Inline, ...] | None:
title=self._resolve_inlines(_attribute_text(title)))
case Image(src=src, children=children, title=title):
alt = self._resolve_inlines(_attribute_text(children))
+ if is_empty_path(src):
+ # No path, no picture: would request the page
+ # itself. §2.3 says nothing of an empty path, so no rule fires.
+ return alt
if not is_relative_path(src):
# §2.3: an image path is relative and has no URI scheme, so a
# remote or absolute image is never fetched or embedded. Its
diff --git a/src/legaldown_render/urls.py b/src/legaldown_render/urls.py
index c0282d3..a76ef7c 100644
--- a/src/legaldown_render/urls.py
+++ b/src/legaldown_render/urls.py
@@ -11,12 +11,12 @@
from urllib.parse import unquote
#: Schemes whose links are never made: script (``javascript:``, ``vbscript:``),
-#: local files (``file:``), and ``data:``. markdown-it refuses the same
-#: schemes while parsing (``validateLink``, which lets some ``data:`` images
-#: through; a link may not); this list is the second line, for a URL that
-#: reaches a writer by another way. Every other scheme (``ftp:``, ``sip:``,
-#: ``urn:``, ...) is a hyperlink, since §8.7 requires links to be rendered as
-#: hyperlinks.
+#: local files (``file:``), and ``data:``. This list is the gate: markdown-it's
+#: own ``validateLink`` is turned off in the builder, so that the resolver can
+#: see and report an image path that is not relative (§2.3), and the HTML
+#: writer applies this list to every ``href`` and ``src`` it emits (a link, an
+#: image, a value's link). Every other scheme (``ftp:``, ``sip:``, ``urn:``,
+#: ...) is a hyperlink, since §8.7 requires links to be rendered as hyperlinks.
DENIED_SCHEMES = frozenset({"javascript", "vbscript", "file", "data"})
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
@@ -50,6 +50,12 @@ def is_link_target(href: str) -> bool:
return bool(_as_read(href)) and is_safe_href(href)
+def is_empty_path(path: str) -> bool:
+ """True if *path* names nothing: empty, or only what a browser ignores. As
+ an image's source it would request the page itself."""
+ return not _as_read(unquote(path))
+
+
def is_relative_path(path: str) -> bool:
"""True if *path*, an image's, is a relative path (§2.3). Not relative: a
path with a URI scheme (``https://``, ``file://``, ``data:``), which
diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py
index b6989a6..bf04ff2 100644
--- a/src/legaldown_render/writers/html.py
+++ b/src/legaldown_render/writers/html.py
@@ -12,7 +12,8 @@
is empty or uses a script, ``file:``, or ``data:`` scheme
(:data:`~legaldown_render.urls.DENIED_SCHEMES`, judged as a browser reads the
URL) renders as its text alone. Every other scheme is a hyperlink (§8.7).
-markdown-it refuses such URLs first, while parsing; this is the second line.
+This check is the gate for every ``href`` and ``src`` the writer emits (a link,
+an image, a value's link): the builder turns markdown-it's own check off.
An image path that is not relative (§2.3) never gets here: resolution has
replaced the image with its alt text and reported it.
"""
diff --git a/tests/test_wp3_links.py b/tests/test_wp3_links.py
index b848b52..e745292 100644
--- a/tests/test_wp3_links.py
+++ b/tests/test_wp3_links.py
@@ -214,3 +214,13 @@ def test_a_link_text_that_differs_from_its_url_shows_both_in_text() -> None:
def test_a_link_around_a_picture_keeps_the_picture_not_its_url() -> None:
output = html("# A\n\n[](https://example.com)\n")
assert '' in output
+
+
+@pytest.mark.parametrize("empty", ["", " ", "%20"])
+def test_an_image_with_no_path_is_its_alt_text(empty: str) -> None:
+ for fmt in ("html", "text"):
+ result = render(FRONT + f"# A\n\nBefore  after.\n", format=fmt, strict=True)
+ assert "
Date: Tue, 27 Oct 2026 17:54:36 +0000
Subject: [PATCH 012/118] Text writer fidelity: attachment separators, tables,
quotes, list items, blank labels (W1, W4-W7)
W1: TextWriter takes the style's attachments.separator (api._writer hands
it over; TextWriter() keeps the default style's "page-break"). "none" prints
no separator; "rule" and "page-break" both print "---" before EACH
attachment (plain text has no pages). The "---" that used to precede the
"Attachments" heading is gone; the heading now stands directly after the body.
W4: "|" in a table cell is escaped as "\|"; the separator row carries
alignment colons from Table.align (":---", ":---:", "---:"), unpadded.
W5: _prefix gives a blank line the right-trimmed prefix, so one quote stays
one quote (">" and "> >" on blank lines). Blocks of a list item are separated
by a blank line; a nested list directly after a paragraph (or another list)
stays tight as before.
W6: an item's content indent is len(label or bullet) + 1, whatever its
condition label.
W7 (text only): a style-blanked label drops the "label: " prefix in the title
block and party details. HTML keeps its empty
.
Existing tests changed because of W5:
- test_nested_lists_line_up_with_their_item_s_text: blank line between the
item's paragraphs.
- test_a_later_paragraph_in_a_list_item_is_the_item_s: blank line before the
item's second paragraph.
- test_a_heading_in_a_quote_or_item_is_a_bold_line_not_a_section: the
two-block quotes printed as two quotes; now one quote with a ">" line.
Goldens changed (features.default/continental/outline.txt, each hunk read):
- W1: "---" moved from before the "Attachments" heading to before each of the
two attachments.
- W4: the table separator row is now "|:---|---:|:---:|" (the document's
table is left/right/center aligned).
template.default.txt is unchanged.
---
CONFORMANCE.md | 1 +
docs/architecture.md | 2 +-
docs/style-templates.md | 2 +-
src/legaldown_render/api.py | 2 +-
src/legaldown_render/writers/text.py | 82 +++++++++-----
tests/golden/features.continental.txt | 8 +-
tests/golden/features.default.txt | 8 +-
tests/golden/features.outline.txt | 8 +-
tests/test_render.py | 8 +-
tests/test_wp5_text.py | 150 ++++++++++++++++++++++++++
10 files changed, 231 insertions(+), 40 deletions(-)
create mode 100644 tests/test_wp5_text.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 4c7ead4..ae7a9fe 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -41,6 +41,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
| Links (§8.7) | ✅ Every link is a hyperlink in HTML and shows `text ` in plain text, whatever its scheme (`ftp:`, `sip:`, `urn:`, ...), except one whose URL is empty or uses `javascript:`, `vbscript:`, `file:`, or `data:`, which shows its text alone. A link with no text shows its URL; the text writer omits `` for an autolink |
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
+| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no separator, `rule` and `page-break` both print a `---` line before each attachment (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block and party details; HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty or only whitespace renders `[TBD: id]` |
| Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` |
diff --git a/docs/architecture.md b/docs/architecture.md
index a3e9d25..378944b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -176,7 +176,7 @@ clamped to 1–5, as the validator numbers them.
| Writer | Notes |
|---|---|
-| `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 |
+| `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, one quote stays one quote, and tables keep escaped cells and alignment. It takes the style's `attachments.separator` (default `page-break`; `rule` and `page-break` both print `---` before each attachment). 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)) |
diff --git a/docs/style-templates.md b/docs/style-templates.md
index ac76ff4..9ce9074 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -110,7 +110,7 @@ placeholders:
title_block: { parties: true, effective_date: true, version: false }
contents: { enabled: false, depth: 2, attachments: true } # table of contents after the title block; depth 1-5
# counts number levels ("2.1" is 2); attachments listed last
-attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none
+attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none (plain text: `---` before each attachment unless none)
signatures: { enabled: true } # the document's include_signatures: false wins
template_view: { choice_separator: " / ", empty_choice: "" }
diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py
index 508ce04..1ece903 100644
--- a/src/legaldown_render/api.py
+++ b/src/legaldown_render/api.py
@@ -194,7 +194,7 @@ def _resolve(document: Document, result: ValidationResult, style: Style) -> tupl
def _writer(options: RenderOptions, style: Style) -> HtmlWriter | TextWriter:
if options.format == "text":
- return TextWriter()
+ return TextWriter(style.attachments.separator)
from . import __version__
return HtmlWriter(style, standalone=options.standalone, generator=f"legaldown-render {__version__}")
diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py
index e8fb00c..3e1a873 100644
--- a/src/legaldown_render/writers/text.py
+++ b/src/legaldown_render/writers/text.py
@@ -4,6 +4,12 @@
doubles as the test oracle for resolution (docs/architecture.md, Testing).
Line breaks inside a paragraph are joined, so output does not depend on how
the source was wrapped.
+
+Structure survives the plain layout: one quote stays one quote (every line of
+it, blank ones included, carries the ``>`` marker), the blocks of a list item
+are set apart by blank lines and indented under the item's label, and a table
+is a pipe table whose cells escape ``|`` and whose separator row carries the
+column alignment.
"""
from __future__ import annotations
@@ -59,6 +65,12 @@ def _link_text(href: str, text: str) -> str:
return text if url.startswith("#") or text in (url, address) else f"{text} <{url}>"
+def _labelled(label: str, colon: str, value: str) -> str:
+ """A ``label: value`` row. A style that blanks the label (§13.5) leaves the
+ value alone rather than a bare ``: value``."""
+ return f"{label}{colon}{value}" if label else value
+
+
def inline_text(inlines: tuple[Inline, ...]) -> str:
parts: list[str] = []
for inline in inlines:
@@ -84,11 +96,20 @@ def inline_text(inlines: tuple[Inline, ...]) -> str:
class TextWriter:
- """Writes a resolved tree as plain text."""
+ """Writes a resolved tree as plain text.
+
+ *attachment_separator* is the style's ``attachments.separator`` (§13.8):
+ ``none`` prints nothing between attachments; ``rule`` and ``page-break``
+ both print a ``---`` line before each attachment, as plain text has no
+ pages to break. The default is the default style's value.
+ """
extension = "txt"
media_type = "text/plain"
+ def __init__(self, attachment_separator: str = "page-break") -> None:
+ self.attachment_separator = attachment_separator
+
def write(self, tree: RenderTree) -> str:
chunks: list[str] = []
title = inline_text(tree.title).strip()
@@ -98,12 +119,12 @@ def write(self, tree: RenderTree) -> str:
if subtitle:
chunks.append(subtitle)
if tree.header:
- chunks.append("\n".join(f"{label}{tree.colon}{inline_text(value)}" for label, value in tree.header))
+ chunks.append("\n".join(_labelled(label, tree.colon, inline_text(value)) for label, value in tree.header))
for side in tree.sides:
lines = [inline_text(side.label)]
for party in side.parties:
lines.append(_INDENT + inline_text(party.name))
- lines += [f"{_INDENT}{label}{tree.colon}{inline_text(value)}" for label, value in party.details]
+ lines += [_INDENT + _labelled(label, tree.colon, inline_text(value)) for label, value in party.details]
chunks.append("\n".join(lines))
if tree.contents:
lines = [tree.contents_label] if tree.contents_label else []
@@ -121,9 +142,10 @@ def write(self, tree: RenderTree) -> str:
chunks.append(f"[{section.condition_label}]")
chunks += [self.block(block) for block in section.blocks]
if tree.attachments:
- chunks.append("---")
chunks.append(tree.attachments_label)
for attachment in tree.attachments:
+ if self.attachment_separator != "none":
+ chunks.append("---")
lines = [inline_text(attachment.title)]
if attachment.condition_label:
lines.append(f"[{attachment.condition_label}]")
@@ -159,38 +181,46 @@ def block(self, block: Block) -> 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."""
+ else in an item, a nested list included, lines up with its text, which
+ starts one column after the item's own label (or bullet): a condition
+ label (§15.8) does not move it. Blocks of an item are set apart by a
+ blank line, except that a nested list stays tight under the paragraph
+ or list it follows."""
lines: list[str] = []
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
+ label = item.label if item.label else "-"
+ marker = f"{label} [{item.condition_label}]" if item.condition_label else label
+ content = indent + " " * (len(label) + 1)
+ previous: Block | None = None
for child in item.blocks:
+ if previous is not None and not (isinstance(child, List) and isinstance(previous, Paragraph | List)):
+ lines.append("")
if isinstance(child, List):
- if first:
+ if previous is None:
# The item opens with a nested list: its own label
# still comes first, on a line of its own.
lines.append(indent + marker)
- first = False
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
+ elif previous is None:
+ lines.append(_prefix(f"{marker} {self.block(child)}", indent, hanging=" " * (len(label) + 1)))
else:
- lines.append(_prefix(text, content))
- if first:
+ lines.append(_prefix(self.block(child), content))
+ previous = child
+ if previous is None:
lines.append(indent + marker)
return lines
def table(self, table: Table) -> str:
- header = [inline_text(cell) for cell in table.header]
- rows = [[inline_text(cell) for cell in row] for row in table.rows]
- lines = ["| " + " | ".join(header) + " |", "|" + "|".join("---" for _ in header) + "|"]
- lines += ["| " + " | ".join(row) + " |" for row in rows]
+ """A pipe table, unpadded. ``|`` in a cell is escaped so a cell stays
+ a cell; the separator row marks each column's alignment."""
+ def cell(inlines: tuple[Inline, ...]) -> str:
+ return inline_text(inlines).replace("|", "\\|")
+
+ rule = {"left": ":---", "center": ":---:", "right": "---:"}
+ align = table.align + ("",) * (len(table.header) - len(table.align))
+ lines = ["| " + " | ".join(cell(c) for c in table.header) + " |",
+ "|" + "|".join(rule.get(a, "---") for a in align) + "|"]
+ lines += ["| " + " | ".join(cell(c) for c in row) + " |" for row in table.rows]
return "\n".join(lines)
def signature(self, signature: SignatureParty, labels: dict[str, str], colon: str) -> str:
@@ -209,7 +239,9 @@ def signature(self, signature: SignatureParty, labels: dict[str, str], colon: st
def _prefix(text: str, prefix: str, *, hanging: str | None = None) -> str:
"""Every line of *text* prefixed; with *hanging*, lines after the first
- get *prefix* plus *hanging* instead."""
+ get *prefix* plus *hanging* instead. A blank line gets the prefix with its
+ trailing space trimmed: ``>`` keeps a quote one quote, an indent leaves the
+ line empty."""
lines = text.split("\n")
rest = prefix + hanging if hanging is not None else prefix
- return "\n".join([prefix + lines[0]] + [(rest + line) if line else line for line in lines[1:]])
+ return "\n".join([prefix + lines[0]] + [(rest + line) if line else rest.rstrip() for line in lines[1:]])
diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt
index 0299a7e..38d3d91 100644
--- a/tests/golden/features.continental.txt
+++ b/tests/golden/features.continental.txt
@@ -47,7 +47,7 @@ This Services Agreement (this Agreement) is made between Acme and Beta Industrie
(1) The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789.
| Service | Fee | Notes |
-|---|---|---|
+|:---|---:|:---:|
| Hosting | $500.00 | per month |
| Support | $250.75 | see 3 |
@@ -65,13 +65,15 @@ This Services Agreement (this Agreement) is made between Acme and Beta Industrie
(2) Raw HTML is dropped. A safe link stays; an unsafe one does not.
----
-
Attachments
+---
+
Schedule A: Service Description
File: attachments/service-description.lgd
+---
+
Exhibit 1: Price List
File: attachments/price-list.pdf
diff --git a/tests/golden/features.default.txt b/tests/golden/features.default.txt
index 4610d64..2f88b4a 100644
--- a/tests/golden/features.default.txt
+++ b/tests/golden/features.default.txt
@@ -47,7 +47,7 @@ A change under item 2.1(b)(i) or step 2.1(2) is made in writing under Section 2.
The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789.
| Service | Fee | Notes |
-|---|---|---|
+|:---|---:|:---:|
| Hosting | $500.00 | per month |
| Support | $250.75 | see 3 |
@@ -65,13 +65,15 @@ These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVA
Raw HTML is dropped. A safe link stays; an unsafe one does not.
----
-
Attachments
+---
+
Schedule A: Service Description
File: attachments/service-description.lgd
+---
+
Exhibit 1: Price List
File: attachments/price-list.pdf
diff --git a/tests/golden/features.outline.txt b/tests/golden/features.outline.txt
index 52d599e..d28ca11 100644
--- a/tests/golden/features.outline.txt
+++ b/tests/golden/features.outline.txt
@@ -47,7 +47,7 @@ III. Fees
The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789.
| Service | Fee | Notes |
-|---|---|---|
+|:---|---:|:---:|
| Hosting | $500.00 | per month |
| Support | $250.75 | see III |
@@ -65,13 +65,15 @@ These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVA
Raw HTML is dropped. A safe link stays; an unsafe one does not.
----
-
Attachments
+---
+
Schedule A: Service Description
File: attachments/service-description.lgd
+---
+
Exhibit 1: Price List
File: attachments/price-list.pdf
diff --git a/tests/test_render.py b/tests/test_render.py
index 8aac3c0..37a0676 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -766,7 +766,8 @@ def test_the_first_definition_in_document_order_keeps_the_anchor() -> None:
def test_nested_lists_line_up_with_their_item_s_text() -> None:
output = text("# A\n\n1. one\n\n more\n\n - child\n")
- assert "1. one\n more\n (i) child" in output
+ # Blocks of an item are set apart (W5); a nested list stays tight under its paragraph.
+ assert "1. one\n\n more\n (i) child" in output
def _nested_list(levels: int) -> str:
@@ -978,7 +979,7 @@ def test_a_later_paragraph_in_a_list_item_is_the_item_s() -> None:
# its marker is misplaced (§5.7) and a reference to it is broken, as the
# validator reports.
result = render(FRONT + "# A\n\n- item one\n\n second {#sec}\n\nSee {{ref: sec}}.\n", format="text")
- assert "(a) item one\n second" in result.output
+ assert "(a) item one\n\n second" in result.output
assert "[BROKEN REF: sec]" in result.output
assert {"anchor-misplaced", "ref-broken"} <= {d.rule for d in result.diagnostics}
@@ -1080,7 +1081,8 @@ def test_template_decision_is_the_validators() -> None:
def test_a_heading_in_a_quote_or_item_is_a_bold_line_not_a_section() -> None:
result = render(FRONT + "# A\n\n> # Inner\n> text\n\n- ## Item heading\n\n> title: x\n> ---\n> after\n",
format="text")
- assert "\n\n> Inner\n\n> text\n\n(a) Item heading\n\n> title: x\n\n> after\n" in result.output
+ # One quote stays one quote: its blank line keeps the `>` (W5).
+ assert "\n\n> Inner\n>\n> text\n\n(a) Item heading\n\n> title: x\n>\n> after\n" in result.output
assert [s.designation for s in result.tree.sections] == ["1"]
html = render(FRONT + "# A\n\n> # Inner\n", standalone=False).output
assert "Inner" in html
diff --git a/tests/test_wp5_text.py b/tests/test_wp5_text.py
new file mode 100644
index 0000000..b6c37d9
--- /dev/null
+++ b/tests/test_wp5_text.py
@@ -0,0 +1,150 @@
+"""Text writer fidelity (WP5): attachment separators (W1), table cells and
+alignment (W4), quotes and list items (W5), list indent (W6), blank labels (W7)."""
+from __future__ import annotations
+
+import pytest
+
+from legaldown_render import render
+from legaldown_render.tree import Table, Text
+from legaldown_render.writers import TextWriter
+
+FRONT = """---
+title: T
+sides:
+ - name: providers
+ label: Provider
+ parties:
+ - name: acme
+ label: Acme
+ type: legal_entity
+ legal_name: Acme Corporation
+ identification_number: DE-1
+attachments:
+ - id: sch-a
+ title: Schedule A
+ file: a.lgd
+ - id: sch-b
+ title: Schedule B
+ file: b.pdf
+effective_date: 2026-06-01
+questions:
+ - id: forum
+ type: choice
+ options: [courts, arb]
+---
+"""
+
+
+def text(body: str, **overrides: object) -> str:
+ return render(FRONT + body, format="text", overrides=overrides).output
+
+
+# -- W1: attachments.separator (§13.8) -------------------------------------------
+
+
+@pytest.mark.parametrize("separator", ["rule", "page-break"])
+def test_a_separator_precedes_each_attachment(separator: str) -> None:
+ output = text("# A\n\nBody.\n", **{"attachments.separator": separator})
+ assert "Attachments\n\n---\n\nSchedule A\nFile: a.lgd\n\n---\n\nSchedule B\nFile: b.pdf\n" in output
+
+
+def test_none_prints_no_separator_between_or_before_attachments() -> None:
+ output = text("# A\n\nBody.\n", **{"attachments.separator": "none"})
+ assert "Body.\n\nAttachments\n\nSchedule A\nFile: a.lgd\n\nSchedule B\nFile: b.pdf\n" in output
+ # The one `---` left is the one before the signatures.
+ assert output.count("---") == 1
+ assert output.index("---") > output.index("Schedule B")
+
+
+def test_the_default_style_separates_attachments() -> None:
+ assert text("# A\n\nBody.\n").count("---") >= 2
+
+
+def test_the_writer_needs_no_style_and_defaults_to_the_default_styles_separator() -> None:
+ assert TextWriter().attachment_separator == "page-break"
+ result = render(FRONT + "# A\n\nBody.\n", format="text")
+ assert TextWriter().write(result.tree) == result.output
+
+
+# -- W4: tables ------------------------------------------------------------------
+
+
+def test_a_pipe_in_a_cell_is_escaped() -> None:
+ output = text("# A\n\n| a | b |\n|---|---|\n| x \\| y | z |\n")
+ assert "| a | b |\n|---|---|\n| x \\| y | z |" in output
+
+
+def test_alignment_survives_in_the_separator_row() -> None:
+ output = text("# A\n\n| a | b | c | d |\n|:--|:-:|--:|---|\n| 1 | 2 | 3 | 4 |\n")
+ assert "| a | b | c | d |\n|:---|:---:|---:|---|\n| 1 | 2 | 3 | 4 |" in output
+
+
+def test_a_table_with_fewer_alignments_than_columns_still_prints() -> None:
+ cell = (Text("x"),)
+ table = Table((cell, cell), ((cell, cell),), ("right",))
+ assert TextWriter().table(table) == "| x | x |\n|---:|---|\n| x | x |"
+
+
+# -- W5: quotes and list items ---------------------------------------------------
+
+
+def test_one_quote_stays_one_quote() -> None:
+ output = text("# A\n\n> first\n>\n> second\n")
+ assert "> first\n>\n> second\n" in output
+
+
+def test_nested_quotes_keep_their_markers_on_blank_lines() -> None:
+ output = text("# A\n\n> outer\n>\n> > inner one\n> >\n> > inner two\n>\n> back\n")
+ assert "> outer\n>\n> > inner one\n> >\n> > inner two\n>\n> back\n" in output
+
+
+def test_a_quote_in_a_list_item_stays_one_quote() -> None:
+ output = text("# A\n\n- item\n\n > one\n >\n > two\n")
+ assert "(a) item\n\n > one\n >\n > two\n" in output
+
+
+def test_blocks_of_a_list_item_are_separated() -> None:
+ output = text("# A\n\n1. one\n\n more\n\n and more\n2. two\n")
+ assert "1. one\n\n more\n\n and more\n2. two\n" in output
+ assert " \n" not in output # blank lines carry no trailing space
+
+
+def test_a_nested_list_stays_tight_under_its_paragraph() -> None:
+ output = text("# A\n\n1. one\n\n more\n\n - child\n - child two\n")
+ assert "1. one\n\n more\n (i) child\n (ii) child two\n" in output
+
+
+def test_a_paragraph_after_a_nested_list_is_separated() -> None:
+ output = text("# A\n\n1. one\n - child\n\n after\n")
+ assert "1. one\n (i) child\n\n after\n" in output
+
+
+# -- W6: list indent -------------------------------------------------------------
+
+
+def test_a_conditional_item_indents_by_its_label_not_the_condition_label() -> None:
+ body = ("# A\n\n- first\n- by courts {#i when=forum:courts}\n\n nested\n\n - deep\n"
+ "- by arbitration {#i when=forum:arb}\n- last\n")
+ output = text(body)
+ assert "(b) [Only if: forum:courts] by courts\n\n nested\n (i) deep\n" in output
+
+
+# -- W7: blank labels ------------------------------------------------------------
+
+
+def test_a_blanked_label_drops_its_prefix_in_party_details() -> None:
+ output = text("# A\n\nBody.\n", **{"labels.identification_number": ""})
+ assert "\n DE-1\n" in output
+ assert ": DE-1" not in output
+
+
+def test_a_blanked_title_block_label_drops_its_prefix() -> None:
+ output = text("# A\n\nBody.\n", **{"labels.effective_date": ""})
+ assert "\nJune 1, 2026\n" in output
+ assert ": June 1, 2026" not in output
+
+
+def test_a_label_still_prefixes_its_value() -> None:
+ output = text("# A\n\nBody.\n")
+ assert "Registration No.: DE-1" in output
+ assert "Effective date: June 1, 2026" in output
From 8579abdbb43efd1c12e8d79134fa869c64df6105 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:55:16 +0000
Subject: [PATCH 013/118] {{term:}} resolves the term as the definition does: a
{{ref:}} or placeholder inside it is kept
The term text of a {{term:}} was collected in Resolver._survey with a text-only
pass, before sections and anchors were registered and before inconsistent
placeholder types were known: a {{ref:}} in the term was dropped ("Fee per")
and a placeholder used with conflicting types showed a blank. _survey now
records only the first DefinitionSource per id; Resolver._term_text resolves its
children during inline resolution (all targets known), takes plain text and
caches it, under the existing _terms_open cycle guard. It restores used_anchors,
so a definition nested in a term keeps its document-order anchor; before targets
are known (a none-scheme heading) the text-only pass stands in, uncached.
_warn and _not_relative deduplicate, so no repeated diagnostics. Docs updated.
No existing test or golden changed.
---
CONFORMANCE.md | 3 +-
docs/architecture.md | 2 +-
src/legaldown_render/resolve/resolver.py | 63 +++++++++++----
tests/test_wp9_review_fixes.py | 98 ++++++++++++++++++++++++
4 files changed, 149 insertions(+), 17 deletions(-)
create mode 100644 tests/test_wp9_review_fixes.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 4c7ead4..ac3dc4e 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -68,7 +68,8 @@ It is checked against the specification's own examples and fixtures corpus (`tes
no content at all keeps its place (CommonMark), and so does an empty drafting note, whose label
is the template construct (§15.6).
- **`{{term:}}` text.** A `{{term:}}` shows the term as rendered at its first definition in the
- body, with its Markdown resolved (`"A *b* c" {{def: abc}}` is "A b c"), not the term's raw
+ body, with its Markdown, `{{ref:}}`s and placeholders resolved as at the definition
+ (`"A *b* c" {{def: abc}}` is "A b c", `"Fee per {{ref: h}}"` is "Fee per 1"), not the term's raw
source; `label=` overrides it. A definition not in the body (a template's other alternative)
falls back to the validator's term text.
A term that refers to itself, directly or through other terms (`"A {{term: a}}" {{def: a}}`), is
diff --git a/docs/architecture.md b/docs/architecture.md
index a3e9d25..45fbd2e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -140,7 +140,7 @@ Guessing at lost structure was tried, and it traded each gap for new bugs.
| Directive | Resolves to | Failure markers |
|---|---|---|
| `{{ref:}}` | `CrossRef(designation, anchor)` (§13.3). A ref to an unnumbered item or paragraph falls back to its section's designation, with `ref-not-enumerated` | `[BROKEN REF: id]` |
-| `{{term:}}` | `TermRef(label or the definition's rendered term text, def anchor)` (§7.3, §13.4); the text is collected in `Resolver._survey` | `[UNDEFINED: id]` |
+| `{{term:}}` | `TermRef(label or the definition's rendered term text, def anchor)` (§7.3, §13.4); the text is the first definition's term resolved as at the definition, worked out on first use by `Resolver._term_text` (`_survey` only records which definition is first) | `[UNDEFINED: id]` |
| `"Term" {{def:}}` | `DefinedTerm` without quotation marks, anchored at `def:` (§7.2) | n/a |
| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005): amounts and durations keep every written digit, and a duration without a pattern in the locale falls back to another length, the language alone, then English with `render-locale-fallback` | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` |
| `{{party:}}` `{{side:}}` | `Value(label or display name)` (§3.6) | `[INVALID PARTY: …]`, `[UNKNOWN PARTY: …]`, and the side forms |
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index d542d24..748e907 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -151,13 +151,18 @@ def __init__(
questions = self.metadata.questions
self.questions: dict[str, Any] = questions if isinstance(questions, dict) else {}
self.inconsistent_placeholders: set[str] = set()
- # A definition id -> the plain text of its term as rendered (Markdown
- # resolved), from the first definition in the body (see _survey).
+ # A definition id -> the first {{def:}} of it in the body (see _survey),
+ # and -> the plain text of its term as rendered there, worked out the
+ # first time a {{term:}} needs it (see _term_text).
+ self.term_sources: dict[str, DefinitionSource] = {}
self.term_texts: dict[str, str] = {}
# The definitions whose term text is being worked out, so that a
# {{term:}} inside a term, directly or through other terms, cannot
# send the computation round in a circle.
self._terms_open: list[str] = []
+ # False until every target is registered and the placeholder types are
+ # known (run() sets it just before the inlines are resolved).
+ self._targets_known = False
self.attachment_anchors: dict[str, str | None] = {}
self.section_presences: list[Presence] = []
@@ -172,6 +177,7 @@ def run(self) -> RenderTree:
replace(section, blocks=self._structure_section(section, presence))
for section, presence in zip(sections, self.section_presences, strict=True)
)
+ self._targets_known = True
resolve = self._resolve_inlines
# In document order, so that the first of several definitions of a
# term is the one that keeps its anchor.
@@ -249,22 +255,15 @@ def _settle_term_target(self, inline: Inline) -> Inline:
def _survey(self) -> None:
"""One pass over the tree before resolving: which placeholder ids
- are used with conflicting types (§10.7), and what each defined term
- reads as, for ``{{term:}}`` (§7.3)."""
+ are used with conflicting types (§10.7), and which definition each
+ defined term reads as, for ``{{term:}}`` (§7.3): the term's own text is worked out
+ later, by :meth:`_term_text`, when its references can be resolved."""
types: dict[str, set[str]] = {}
for inline in iter_tree_inlines(self.tree):
if isinstance(inline, DefinitionSource):
# The first definition of an id is the one the validator's
- # lookup and the anchor keep. The term is the text the reader
- # sees at the definition: its Markdown is resolved, not the
- # validator's raw ``anchor.term``.
- identifier = self._definition_id(inline)
- if identifier not in self.term_texts:
- self._terms_open.append(identifier)
- try:
- self.term_texts[identifier] = self._title_text(inline.children)
- finally:
- self._terms_open.pop()
+ # lookup and the anchor keep.
+ self.term_sources.setdefault(self._definition_id(inline), inline)
elif isinstance(inline, DirectiveSource):
directive = inline.directive
if directive.name == "placeholder" and directive.positional and not directive.malformed:
@@ -626,11 +625,45 @@ def _term(self, directive: Directive, definition_id: str) -> Inline:
# The term as rendered at its definition; the validator's own
# text (an empty term reads as its id, a definition not in the
# body) is the fallback.
- text = label or self.term_texts.get(definition_id) or self._plain_open(definition_id, term)
+ text = label or self._term_text(definition_id) or self._plain_open(definition_id, term)
# Settled once everything is resolved (see _settle_term_target).
target = DEFINITION_ANCHOR_PREFIX + definition_id
return TermRef(text, target, self.style.definitions.term_style)
+ def _term_text(self, definition_id: str) -> str:
+ """The plain text of the term at the first definition of *definition_id*
+ in the body, as the reader sees it there: its Markdown, references,
+ placeholders and other terms resolved exactly as at the definition
+ (§7.3), not the validator's raw ``anchor.term``. Empty when the
+ definition is not in the body.
+
+ Worked out the first time it is needed and kept, which is once every
+ target is registered and the placeholder types are known. Before that
+ (a heading read as text by the ``none`` scheme, §13.3) a text-only pass
+ stands in, which leaves references out and is not kept. Resolving the
+ term gives out no anchor: a definition inside a term keeps its place in
+ document order. The definition is open meanwhile, so a ``{{term:}}``
+ inside it, directly or through others, shows its label or id and goes
+ no further."""
+ source = self.term_sources.get(definition_id)
+ if source is None:
+ return ""
+ if definition_id in self.term_texts:
+ return self.term_texts[definition_id]
+ self._terms_open.append(definition_id)
+ try:
+ if not self._targets_known:
+ return self._title_text(source.children)
+ anchors = set(self.used_anchors)
+ try:
+ text = plain_text(self._resolve_inlines(source.children))
+ finally:
+ self.used_anchors = anchors
+ finally:
+ self._terms_open.pop()
+ self.term_texts[definition_id] = text
+ return text
+
def _plain_open(self, definition_id: str, term: str) -> str:
"""The validator's *term* text of a definition as plain text, with
the definition marked open while its directives resolve."""
diff --git a/tests/test_wp9_review_fixes.py b/tests/test_wp9_review_fixes.py
new file mode 100644
index 0000000..b621028
--- /dev/null
+++ b/tests/test_wp9_review_fixes.py
@@ -0,0 +1,98 @@
+"""Review fixes after wave 1 (WP9): a ``{{ref:}}`` or placeholder inside a
+defined term as ``{{term:}}`` shows it, a fraction beyond Python's int/str digit
+limit renders, ``-o`` rejects only the extensions of formats the renderer does
+not write, in-process ``main()`` leaves the streams alone, and output has LF
+line endings."""
+from __future__ import annotations
+
+import io
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from conftest import DOCUMENTS
+
+from legaldown_render import render
+from legaldown_render.cli import main
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+FEATURES = str(DOCUMENTS / "features.lgd")
+FRONT = "---\ntitle: T\n---\n\n"
+
+
+def text(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="text", **settings).output
+
+
+# -- 1: {{term:}} shows what the definition shows -------------------------------
+
+
+def test_a_term_reference_keeps_a_ref_inside_the_term() -> None:
+ output = text('# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n\n{{term: a}}\n')
+ assert output.count("Fee per 1") == 2
+
+
+def test_a_term_reference_before_the_heading_keeps_the_ref() -> None:
+ output = text('{{term: a}}\n\n# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n')
+ assert output.count("Fee per 1") == 2
+
+
+def test_a_term_reference_before_its_definition_and_its_target() -> None:
+ output = text('# First\n\nSee {{term: a}}.\n\n# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n')
+ assert "See Fee per 2." in output
+
+
+def test_a_term_reference_keeps_a_ref_to_a_subsection() -> None:
+ body = '# A\n\n"Fee per {{ref: s}}" {{def: a}}\n\n{{term: a}}\n\n## Sub {#s}\n\nx\n'
+ output = text(body)
+ assert output.count("Fee per 1.1") == 2
+
+
+def test_a_term_reference_in_a_textual_heading_and_in_the_body() -> None:
+ # The none scheme reads a heading as text before every target is known; the
+ # term text must not be fixed from that early pass.
+ body = '# Fee {{term: a}}\n\n"Fee per {{ref: h}}" {{def: a}}\n\n# H {#h}\n\n{{term: a}}\n'
+ output = render(FRONT + body, format="text", overrides={"numbering.scheme": "none"}).output
+ assert output.rstrip().endswith("Fee per H")
+ assert "[BROKEN REF" not in output
+
+
+def test_a_term_reference_shows_the_placeholder_as_the_definition_does() -> None:
+ body = '# A\n\n"Fee {{placeholder: p}}" {{def: a}} {{term: a}} {{placeholder: p, type=money}}\n'
+ result = render(FRONT + body, format="text")
+ line = next(row for row in result.output.splitlines() if "INVALID PLACEHOLDER" in row)
+ assert line.count("Fee [INVALID PLACEHOLDER: p]") == 2
+ assert "[_____]" not in result.output
+
+
+def test_a_definition_that_defines_a_term_twice_uses_the_first() -> None:
+ body = '# H {#h}\n\n"First {{ref: h}}" {{def: t}}\n\n"Second" {{def: t}}\n\n{{term: t}}\n'
+ assert text(body).rstrip().endswith("First 1")
+
+
+def test_a_term_with_a_ref_adds_no_duplicate_diagnostics() -> None:
+ # The term holds an image with a remote path: one report however often it is resolved.
+ body = '# H {#h}\n\n"Fee  {{ref: h}}" {{def: a}}\n\n{{term: a}} {{term: a}}\n'
+ diagnostics = render(FRONT + body, format="text").diagnostics
+ keys = [(d.rule, d.message) for d in diagnostics]
+ assert len(keys) == len(set(keys))
+ assert sum(d.rule == "path-not-relative" for d in diagnostics) == 1
+
+
+def test_a_term_that_holds_a_definition_does_not_take_its_anchor() -> None:
+ body = '# A\n\nSee {{term: outer}}.\n\n"Outer "Inner" {{def: inner}}" {{def: outer}}\n\n{{term: inner}}\n'
+ html = render(FRONT + body, format="html").output
+ assert html.count('id="def:inner"') == 1
+ assert html.count('id="def:outer"') == 1
+
+
+@pytest.mark.parametrize("output_format", ["text", "html"])
+def test_cycles_through_refs_still_terminate(output_format: str) -> None:
+ body = ('# H {#h}\n\n"A {{term: b}} {{ref: h}}" {{def: a}} x.\n\n"B {{term: a}}" {{def: b}} y.\n\n'
+ '"C {{term: c}}" {{def: c}}\n\nSee {{term: a}}, {{term: b}}, {{term: c}}.\n')
+ out = render(FRONT + body, format=output_format).output
+ assert "{{" not in out
+ # Which term shows what inside a cycle follows the order they are first needed in.
+ assert "A b 1" in out and "C c" in out
From 2f0cceb581aef2290afb8960ad08de0bfda57b7f Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:55:21 +0000
Subject: [PATCH 014/118] Preamble labels and print page breaks (R5, W2)
R5: a list item in the preamble has no section, so `{section}` was filled
with "" and the continental label read ".1" while its designation was "1".
`numbering.fill` now removes `{section}` and one following "." when the
section is given as the empty string, so the label is "1". Items in a
section keep "1.1". The text and HTML output of a preamble list change
(".1 first pre" becomes "1 first pre"); no golden contains one.
W2: the print stylesheet broke before each `.ld-attachment`, so the
"Attachments" heading was stranded at the foot of the previous page. The
break now goes before the `.ld-attachments` container (its heading goes
with the first attachment), before each later attachment, and the heading
has `break-after: avoid`. Goldens features.default.html and
template.default.html changed only in those three CSS lines
(`--update-golden`, hunks inspected).
Tests: tests/test_wp7_small.py. Docs: CONFORMANCE.md (preamble label) and
docs/style-templates.md (`{section}` in the preamble).
---
CONFORMANCE.md | 2 +-
docs/style-templates.md | 8 +-
src/legaldown_render/resolve/numbering.py | 8 +-
src/legaldown_render/writers/html.py | 4 +-
tests/golden/features.default.html | 4 +-
tests/golden/template.default.html | 4 +-
tests/test_wp7_small.py | 106 ++++++++++++++++++++++
7 files changed, 128 insertions(+), 8 deletions(-)
create mode 100644 tests/test_wp7_small.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 4c7ead4..e9be48f 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -31,7 +31,7 @@ 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. 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 |
+| Paragraph numbering and section-qualified items (§13.2) | ✅ Style settings; the `continental` style uses both. A preamble item has no section, so its label is "1", not ".1", as its designation is (`{section}` and the `.` after it are dropped) |
| 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 |
diff --git a/docs/style-templates.md b/docs/style-templates.md
index ac76ff4..507e922 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -150,9 +150,11 @@ Headings, list items, and paragraphs are all numbered with the same building blo
| `ref` | This level's piece of a **designation**, the text `{{ref:}}` renders |
Both templates can use `{n}` (this level's counter), `{path}` (the full designation up to this
-level), and `{section}` (the containing section's designation, for items and paragraphs). For
-example, decimal headings use `label: "{path}"` and `ref: ".{n}"`. Legal enumeration uses
-`label: "({n})"` and `ref: "({n})"`, so the second item of Section 2.3 is shown as "(b)" and
+level), and `{section}` (the containing section's designation, for items and paragraphs). In the
+preamble, which has no section, `{section}` is empty and it is removed together with the `.` after
+it, so `label: "{section}.{n}"` shows "1", not ".1". For example, decimal headings use
+`label: "{path}"` and `ref: ".{n}"`. Legal enumeration uses `label: "({n})"` and `ref: "({n})"`, so the
+second item of Section 2.3 is shown as "(b)" and
referenced as "2.3(b)". Under the `none` scheme, designations are heading text, and item pieces
are set off by a space ("Termination (a)", §13.3).
diff --git a/src/legaldown_render/resolve/numbering.py b/src/legaldown_render/resolve/numbering.py
index 61925fa..de95a8e 100644
--- a/src/legaldown_render/resolve/numbering.py
+++ b/src/legaldown_render/resolve/numbering.py
@@ -71,11 +71,17 @@ def heading_levels(numbering: Numbering) -> tuple[LevelFormat, ...]:
_FIELD_RE = re.compile(r"\{(n|path|section)\}")
+_EMPTY_SECTION_RE = re.compile(r"\{section\}\.")
def fill(template: str, **values: str) -> str:
"""*template* with ``{n}``, ``{path}``, and ``{section}`` replaced. Any
- other braces are kept as written."""
+ other braces are kept as written. When *section* is given as the empty
+ string (a preamble item has no section, §4.4), ``{section}`` and ONE
+ immediately following ``.`` are removed first, so "{section}.{n}" gives
+ "1", the same as the item's designation."""
+ if values.get("section") == "":
+ template = _EMPTY_SECTION_RE.sub("", template)
return _FIELD_RE.sub(lambda match: values.get(match.group(1), ""), template)
diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py
index bf04ff2..01a440d 100644
--- a/src/legaldown_render/writers/html.py
+++ b/src/legaldown_render/writers/html.py
@@ -414,7 +414,9 @@ def inline(self, inline: Inline) -> str:
.ld-signature-line { border-top: 1px solid currentColor; width: 80%; margin-bottom: 0.3em; }
@media print {
.ld-document { max-width: none; padding: 0; }
- .ld-separator-page-break .ld-attachment { break-before: page; }
+ .ld-attachments.ld-separator-page-break { break-before: page; }
+ .ld-separator-page-break .ld-attachment + .ld-attachment { break-before: page; }
+ .ld-attachments-heading { break-after: avoid; }
.ld-signatures { break-inside: avoid; }
a { color: inherit; }
}
diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html
index a6610a2..a7a45a5 100644
--- a/tests/golden/features.default.html
+++ b/tests/golden/features.default.html
@@ -58,7 +58,9 @@
.ld-signature-line { border-top: 1px solid currentColor; width: 80%; margin-bottom: 0.3em; }
@media print {
.ld-document { max-width: none; padding: 0; }
- .ld-separator-page-break .ld-attachment { break-before: page; }
+ .ld-attachments.ld-separator-page-break { break-before: page; }
+ .ld-separator-page-break .ld-attachment + .ld-attachment { break-before: page; }
+ .ld-attachments-heading { break-after: avoid; }
.ld-signatures { break-inside: avoid; }
a { color: inherit; }
}
diff --git a/tests/golden/template.default.html b/tests/golden/template.default.html
index 75099b2..c789a6f 100644
--- a/tests/golden/template.default.html
+++ b/tests/golden/template.default.html
@@ -58,7 +58,9 @@
.ld-signature-line { border-top: 1px solid currentColor; width: 80%; margin-bottom: 0.3em; }
@media print {
.ld-document { max-width: none; padding: 0; }
- .ld-separator-page-break .ld-attachment { break-before: page; }
+ .ld-attachments.ld-separator-page-break { break-before: page; }
+ .ld-separator-page-break .ld-attachment + .ld-attachment { break-before: page; }
+ .ld-attachments-heading { break-after: avoid; }
.ld-signatures { break-inside: avoid; }
a { color: inherit; }
}
diff --git a/tests/test_wp7_small.py b/tests/test_wp7_small.py
new file mode 100644
index 0000000..1df7b50
--- /dev/null
+++ b/tests/test_wp7_small.py
@@ -0,0 +1,106 @@
+"""Small fixes (WP7): a label filled with an empty ``{section}`` drops the field
+and its dot, so a preamble item reads "1", not ".1" (R5); the print stylesheet
+breaks before the attachments container, not inside it (W2)."""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from legaldown_render import render
+from legaldown_render.resolve.numbering import fill
+
+FRONT = "---\ntitle: T\n---\n"
+PREAMBLE_AND_SECTION = (
+ "\nPreamble.\n\n- first pre\n- second pre\n\n# A {#a}\n\n- in a\n"
+)
+
+
+def text(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="text", **settings).output
+
+
+def html(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="html", **settings).output
+
+
+# -- R5: an empty section drops its dot (numbering.fill) -----------------------------------------
+
+def test_fill_drops_an_empty_section_with_one_following_dot() -> None:
+ assert fill("{section}.{n}", n="1", section="") == "1"
+ assert fill("{section}.{n}", n="1", section="2") == "2.1"
+ # Only ONE dot goes with the field.
+ assert fill("{section}..{n}", n="1", section="") == ".1"
+
+
+def test_fill_keeps_other_text_of_an_empty_section_as_written() -> None:
+ # No dot right after the field: the field alone is removed, as before.
+ assert fill("({section}){n}", n="a", section="") == "()a"
+ assert fill("{section}", n="1", section="") == ""
+
+
+def test_fill_without_a_section_value_keeps_the_dot() -> None:
+ # The rule applies only when the section value is given and empty.
+ assert fill("{section}.{n}", n="1") == ".1"
+
+
+# -- R5: preamble list items agree with their designation ----------------------------------------
+
+def test_a_preamble_list_is_numbered_without_a_leading_dot_in_text() -> None:
+ output = text(PREAMBLE_AND_SECTION, style="continental")
+ assert "1 first pre" in output
+ assert "2 second pre" in output
+ assert not any(line.startswith(".") for line in output.splitlines())
+
+
+def test_a_preamble_list_is_numbered_without_a_leading_dot_in_html() -> None:
+ output = html(PREAMBLE_AND_SECTION, style="continental")
+ assert '1
first pre
' in output
+ assert '2
second pre
' in output
+ assert 'class="ld-label">.1<' not in output
+
+
+def test_a_list_in_a_section_keeps_its_section_prefix() -> None:
+ output = text(PREAMBLE_AND_SECTION, style="continental")
+ assert "1.1 in a" in output
+ html_output = html(PREAMBLE_AND_SECTION, style="continental")
+ assert '1.1
in a
' in html_output
+
+
+# -- W2: print breaks before the attachments container --------------------------------------------
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+ATTACHED = (
+ "---\ntitle: T\nattachments:\n"
+ " - id: one\n title: First attachment\n file: one.pdf\n"
+ " - id: two\n title: Second attachment\n file: two.pdf\n"
+ "---\n\n# A {#a}\n\nBody.\n"
+)
+
+
+def _cli(*args: str, stdin: str) -> subprocess.CompletedProcess[str]:
+ env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONUTF8="1")
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
+ capture_output=True, text=True, env=env, check=False)
+
+
+def test_a_standalone_page_break_render_has_the_attachment_break_rules() -> None:
+ done = _cli("-", "--set", "attachments.separator=page-break", stdin=ATTACHED)
+ assert done.returncode == 0, done.stderr
+ assert "" in done.stdout
+ assert 'class="ld-attachments ld-separator-page-break"' in done.stdout
+ # The break goes before the container (heading included), then before each later attachment.
+ assert ".ld-attachments.ld-separator-page-break { break-before: page; }" in done.stdout
+ assert ".ld-separator-page-break .ld-attachment + .ld-attachment { break-before: page; }" in done.stdout
+ assert ".ld-attachments-heading { break-after: avoid; }" in done.stdout
+ # The old rule broke before every attachment, the first one included, inside the container.
+ assert ".ld-separator-page-break .ld-attachment { break-before: page; }" not in done.stdout
+
+
+def test_without_a_page_break_separator_the_container_is_not_broken() -> None:
+ done = _cli("-", "--set", "attachments.separator=none", stdin=ATTACHED)
+ assert done.returncode == 0, done.stderr
+ # The rules are scoped by the separator class, which no element carries here.
+ assert 'class="ld-attachments ld-separator-none"' in done.stdout
+ assert 'ld-separator-page-break"' not in done.stdout
From f9a42bb7454ef7eab9ff2aa024094ded2827323d Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:56:10 +0000
Subject: [PATCH 015/118] A fraction of more than 4300 digits renders: plural
category falls back to 'other' (review: values)
Babel's plural operands convert the fraction digits with int(), which Python
limits to 4300 digits; {{duration:}} and name-mode {{money:}} with a longer
fraction raised a raw ValueError. values._plural_category catches it and uses
'other' (the CLDR category of a non-integer in nearly every locale); name-mode
money puts Babel's long-name pattern together with 'other' when its own lookup
would fail. sys.set_int_max_str_digits is process-wide and is not touched.
---
src/legaldown_render/resolve/values.py | 52 ++++++++++++++++++++++----
tests/test_wp9_review_fixes.py | 21 +++++++++++
2 files changed, 66 insertions(+), 7 deletions(-)
diff --git a/src/legaldown_render/resolve/values.py b/src/legaldown_render/resolve/values.py
index 2e20bbc..ba94c13 100644
--- a/src/legaldown_render/resolve/values.py
+++ b/src/legaldown_render/resolve/values.py
@@ -20,7 +20,14 @@
from babel import Locale, UnknownLocaleError
from babel.dates import format_date
-from babel.numbers import format_currency, get_currency_precision, parse_pattern
+from babel.numbers import (
+ NumberPattern,
+ format_currency,
+ get_currency_name,
+ get_currency_precision,
+ get_currency_unit_pattern,
+ parse_pattern,
+)
from ..errors import InternalError
from ..style.model import Values
@@ -100,6 +107,22 @@ def _unit_patterns(locale: Locale, unit_id: str, length: str) -> dict[str, str]:
return {category: pattern for category, pattern in patterns.items() if category != "per"}
+def _plural_category(locale: Locale, value: Decimal) -> str:
+ """The CLDR plural category of *value* in *locale*.
+
+ CLDR's plural operands of a value are its integer and fraction digits read
+ as integers, and Python refuses to convert a string of more than 4300 digits
+ to an int (``ValueError``). Such a value is a non-integer or a very long
+ integer whose category cannot be computed under that limit, and ``other`` is
+ the category CLDR gives non-integers in nearly every locale, so ``other`` it
+ is. The limit is not raised: ``sys.set_int_max_str_digits`` is process-wide.
+ """
+ try:
+ return locale.plural_form(value)
+ except ValueError:
+ return "other"
+
+
def _duration_pattern(locale: Locale, unit_id: str, length: str, value: Decimal) -> tuple[str, bool]:
"""The CLDR pattern for a duration of *value*, and whether it is English
for a locale that is not.
@@ -117,14 +140,14 @@ def _duration_pattern(locale: Locale, unit_id: str, length: str, value: Decimal)
if language is not None and str(language) != str(locale):
candidates.append(language)
for candidate in candidates:
- category = candidate.plural_form(value)
+ category = _plural_category(candidate, value)
for each in lengths:
patterns = _unit_patterns(candidate, unit_id, each)
pattern = patterns.get(category) or patterns.get("other")
if pattern:
return pattern, False
english = Locale("en")
- category = english.plural_form(value)
+ category = _plural_category(english, value)
for each in lengths:
patterns = _unit_patterns(english, unit_id, each)
pattern = patterns.get(category) or patterns.get("other")
@@ -183,15 +206,30 @@ def _money(self, amount: str, currency: str) -> str:
pattern = parse_pattern(source)
digits = max(get_currency_precision(currency), len(amount.partition(".")[2]))
pattern.frac_prec = (digits, digits)
- text = format_currency(value, currency, format=pattern, locale=self.locale, currency_digits=False,
- format_type="name" if mode == "name" else "standard",
- decimal_quantization=False)
if mode == "name":
- return text
+ return self._money_name(value, currency, pattern)
+ text = format_currency(value, currency, format=pattern, locale=self.locale, currency_digits=False,
+ format_type="standard", decimal_quantization=False)
# CLDR spaces an alphabetic symbol or code from the digits, on
# whichever side it stands ("USD 10,000.00", "10 000,00 CZK").
return _LETTER_DIGIT.sub("\u00a0", text)
+ def _money_name(self, value: Decimal, currency: str, pattern: NumberPattern) -> str:
+ """*value* with the currency's name ("1,234.50 euros"), through Babel
+ unless the plural category of *value* cannot be computed (see
+ :func:`_plural_category`): Babel's own lookup of the unit pattern would
+ then fail, so the ``other`` pattern and name are put together here, the
+ way Babel does it (``_format_currency_long_name``)."""
+ try:
+ self.locale.plural_form(value)
+ except ValueError:
+ number = pattern.apply(value, self.locale, currency=currency, currency_digits=False,
+ decimal_quantization=False)
+ unit_pattern = get_currency_unit_pattern(currency, locale=self.locale)
+ return unit_pattern.format(number, get_currency_name(currency, count=value, locale=self.locale))
+ return format_currency(value, currency, format=pattern, locale=self.locale, currency_digits=False,
+ format_type="name", decimal_quantization=False)
+
def duration(self, value: str, unit: str) -> str:
"""A duration (already validated) such as "12 months" or "12 měsíců"."""
return self.format_duration(value, unit).text
diff --git a/tests/test_wp9_review_fixes.py b/tests/test_wp9_review_fixes.py
index b621028..0e1284a 100644
--- a/tests/test_wp9_review_fixes.py
+++ b/tests/test_wp9_review_fixes.py
@@ -96,3 +96,24 @@ def test_cycles_through_refs_still_terminate(output_format: str) -> None:
assert "{{" not in out
# Which term shows what inside a cycle follows the order they are first needed in.
assert "A b 1" in out and "C c" in out
+
+
+# -- 2: a fraction beyond Python's int/str digit limit ---------------------------
+
+LONG = "2" * 4400
+
+
+def test_a_duration_with_a_4400_digit_fraction_renders() -> None:
+ output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=D}}}}", format="text").output
+ assert f"1.{LONG} days" in output
+
+
+def test_a_money_amount_in_name_mode_with_a_4400_digit_fraction_renders() -> None:
+ output = render(FRONT + f"# A\n\n{{{{money: 1.{LONG}, currency=EUR}}}}", format="text",
+ overrides={"values.money": "name"}).output
+ assert f"1.{LONG} euros" in output
+
+
+def test_a_4400_digit_fraction_uses_the_other_category_in_czech() -> None:
+ output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=Y}}}}", format="text", locale="cs-CZ").output
+ assert output.rstrip().endswith(" let") # "other", not the "many" of "2,5 roku"
From 61adf8ca6bf69ea257c718c307a5203b2909cd10 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:55:48 +0000
Subject: [PATCH 016/118] Text writer: attachment separator mirrors the HTML
layout; blank signature labels (W1, W7 follow-up)
W1: with rule/page-break the text writer prints "---" before the
attachments heading (as it did originally) and between consecutive
attachments, not between the heading and the first attachment, so the
heading stays with what it heads. With none, no "---" at all. Updates the
TextWriter docstring, CONFORMANCE row and docs.
W7: the blank-label rule also covers the signature blocks (name, title, date,
place): a blanked label prints the value alone, and a row with neither label
nor value is omitted instead of a lone ":".
Goldens (features.default/continental/outline.txt): the "---" moves back
before the "Attachments" heading, relative to the previous commit; a "---"
now also separates the two attachments. Tests in tests/test_wp5_text.py
updated and extended for both changes.
---
CONFORMANCE.md | 2 +-
docs/architecture.md | 2 +-
docs/style-templates.md | 2 +-
src/legaldown_render/writers/text.py | 31 ++++++++++++++++-----------
tests/golden/features.continental.txt | 4 ++--
tests/golden/features.default.txt | 4 ++--
tests/golden/features.outline.txt | 4 ++--
tests/test_wp5_text.py | 27 ++++++++++++++++++++---
8 files changed, 52 insertions(+), 24 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index ae7a9fe..63175df 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -41,7 +41,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
| Links (§8.7) | ✅ Every link is a hyperlink in HTML and shows `text ` in plain text, whatever its scheme (`ftp:`, `sip:`, `urn:`, ...), except one whose URL is empty or uses `javascript:`, `vbscript:`, `file:`, or `data:`, which shows its text alone. A link with no text shows its URL; the text writer omits `` for an autolink |
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
-| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no separator, `rule` and `page-break` both print a `---` line before each attachment (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block and party details; HTML keeps an empty `
` so its grid layout and the `
` stay valid |
+| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty or only whitespace renders `[TBD: id]` |
| Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` |
diff --git a/docs/architecture.md b/docs/architecture.md
index 378944b..cad1c5d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -176,7 +176,7 @@ clamped to 1–5, as the validator numbers them.
| Writer | Notes |
|---|---|
-| `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, one quote stays one quote, and tables keep escaped cells and alignment. It takes the style's `attachments.separator` (default `page-break`; `rule` and `page-break` both print `---` before each attachment). 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, one quote stays one quote, and tables keep escaped cells and alignment. It takes the style's `attachments.separator` (default `page-break`; `rule` and `page-break` both print `---` before the attachments heading and between attachments). 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)) |
diff --git a/docs/style-templates.md b/docs/style-templates.md
index 9ce9074..d424d17 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -110,7 +110,7 @@ placeholders:
title_block: { parties: true, effective_date: true, version: false }
contents: { enabled: false, depth: 2, attachments: true } # table of contents after the title block; depth 1-5
# counts number levels ("2.1" is 2); attachments listed last
-attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none (plain text: `---` before each attachment unless none)
+attachments: { render: placeholder, separator: page-break } # placeholder | omit; page-break | rule | none (plain text: `---` around attachments unless none)
signatures: { enabled: true } # the document's include_signatures: false wins
template_view: { choice_separator: " / ", empty_choice: "" }
diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py
index 3e1a873..4ca0a81 100644
--- a/src/legaldown_render/writers/text.py
+++ b/src/legaldown_render/writers/text.py
@@ -99,9 +99,10 @@ class TextWriter:
"""Writes a resolved tree as plain text.
*attachment_separator* is the style's ``attachments.separator`` (§13.8):
- ``none`` prints nothing between attachments; ``rule`` and ``page-break``
- both print a ``---`` line before each attachment, as plain text has no
- pages to break. The default is the default style's value.
+ ``none`` prints no ``---`` at all; ``rule`` and ``page-break`` both print
+ one before the attachments heading and between consecutive attachments, as
+ plain text has no pages to break (the HTML layout: the heading stays with
+ the first attachment). The default is the default style's value.
"""
extension = "txt"
@@ -142,9 +143,11 @@ def write(self, tree: RenderTree) -> str:
chunks.append(f"[{section.condition_label}]")
chunks += [self.block(block) for block in section.blocks]
if tree.attachments:
+ if self.attachment_separator != "none":
+ chunks.append("---")
chunks.append(tree.attachments_label)
- for attachment in tree.attachments:
- if self.attachment_separator != "none":
+ for index, attachment in enumerate(tree.attachments):
+ if index and self.attachment_separator != "none":
chunks.append("---")
lines = [inline_text(attachment.title)]
if attachment.condition_label:
@@ -225,15 +228,19 @@ def cell(inlines: tuple[Inline, ...]) -> str:
def signature(self, signature: SignatureParty, labels: dict[str, str], colon: str) -> str:
lines = [inline_text(signature.side), inline_text(signature.name)]
+
+ def row(key: str, value: str = "", *, strip: str | None = " ") -> list[str]:
+ # A blanked label leaves the value alone; a row with neither is
+ # omitted. Date and place keep a no-break space of the colon (their
+ # value is to be written in); name and title lose all trailing space.
+ line = _labelled(labels[key], colon, value).rstrip(strip)
+ return [line] if line.strip() else []
+
# With no declared representative, one blank signing line.
for name, title in signature.signatories or (((), ()),):
- lines += [
- "",
- "_" * 30,
- f"{labels['name']}{colon}{inline_text(name)}".rstrip(),
- f"{labels['title']}{colon}{inline_text(title)}".rstrip(),
- ]
- lines += [f"{labels['date']}{colon}".rstrip(" "), f"{labels['place']}{colon}".rstrip(" ")]
+ lines += ["", "_" * 30, *row("name", inline_text(name), strip=None),
+ *row("title", inline_text(title), strip=None)]
+ lines += [*row("date"), *row("place")]
return "\n".join(lines)
diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt
index 38d3d91..d44aedb 100644
--- a/tests/golden/features.continental.txt
+++ b/tests/golden/features.continental.txt
@@ -65,10 +65,10 @@ This Services Agreement (this Agreement) is made between Acme and Beta Industrie
(2) Raw HTML is dropped. A safe link stays; an unsafe one does not.
-Attachments
-
---
+Attachments
+
Schedule A: Service Description
File: attachments/service-description.lgd
diff --git a/tests/golden/features.default.txt b/tests/golden/features.default.txt
index 2f88b4a..d7c8ab9 100644
--- a/tests/golden/features.default.txt
+++ b/tests/golden/features.default.txt
@@ -65,10 +65,10 @@ These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVA
Raw HTML is dropped. A safe link stays; an unsafe one does not.
-Attachments
-
---
+Attachments
+
Schedule A: Service Description
File: attachments/service-description.lgd
diff --git a/tests/golden/features.outline.txt b/tests/golden/features.outline.txt
index d28ca11..b8f4da2 100644
--- a/tests/golden/features.outline.txt
+++ b/tests/golden/features.outline.txt
@@ -65,10 +65,10 @@ These are deliberately wrong: [BROKEN REF: nowhere], [UNDEFINED: nothing], [INVA
Raw HTML is dropped. A safe link stays; an unsafe one does not.
-Attachments
-
---
+Attachments
+
Schedule A: Service Description
File: attachments/service-description.lgd
diff --git a/tests/test_wp5_text.py b/tests/test_wp5_text.py
index b6c37d9..6402e4a 100644
--- a/tests/test_wp5_text.py
+++ b/tests/test_wp5_text.py
@@ -45,10 +45,11 @@ def text(body: str, **overrides: object) -> str:
@pytest.mark.parametrize("separator", ["rule", "page-break"])
def test_a_separator_precedes_each_attachment(separator: str) -> None:
output = text("# A\n\nBody.\n", **{"attachments.separator": separator})
- assert "Attachments\n\n---\n\nSchedule A\nFile: a.lgd\n\n---\n\nSchedule B\nFile: b.pdf\n" in output
+ # The heading stays with the first attachment; a rule falls before it and between attachments.
+ assert "Body.\n\n---\n\nAttachments\n\nSchedule A\nFile: a.lgd\n\n---\n\nSchedule B\nFile: b.pdf\n" in output
-def test_none_prints_no_separator_between_or_before_attachments() -> None:
+def test_none_prints_no_separator_around_attachments() -> None:
output = text("# A\n\nBody.\n", **{"attachments.separator": "none"})
assert "Body.\n\nAttachments\n\nSchedule A\nFile: a.lgd\n\nSchedule B\nFile: b.pdf\n" in output
# The one `---` left is the one before the signatures.
@@ -57,7 +58,7 @@ def test_none_prints_no_separator_between_or_before_attachments() -> None:
def test_the_default_style_separates_attachments() -> None:
- assert text("# A\n\nBody.\n").count("---") >= 2
+ assert text("# A\n\nBody.\n").count("---") == 3 # heading, between attachments, signatures
def test_the_writer_needs_no_style_and_defaults_to_the_default_styles_separator() -> None:
@@ -148,3 +149,23 @@ def test_a_label_still_prefixes_its_value() -> None:
output = text("# A\n\nBody.\n")
assert "Registration No.: DE-1" in output
assert "Effective date: June 1, 2026" in output
+
+
+def test_a_blanked_signature_label_prints_the_value_alone() -> None:
+ body = "# A\n\nBody.\n"
+ output = text(body, **{"labels.signature_name": "", "labels.signature_title": ""})
+ assert "Name:" not in output and "Title:" not in output
+ assert "Date:\nPlace:" in output
+
+
+def test_a_signature_row_with_no_label_and_no_value_is_omitted() -> None:
+ output = text("# A\n\nBody.\n", **{"labels.signature_date": "", "labels.signature_place": ""})
+ assert output.endswith("______________________________\nName:\nTitle:\n")
+ assert "\n:" not in output
+
+
+def test_a_blanked_signature_label_with_a_representative_shows_the_name() -> None:
+ source = FRONT.replace("identification_number: DE-1", "representatives:\n - name: John Smith\n"
+ " title: CEO") + "# A\n\nBody.\n"
+ output = render(source, format="text", overrides={"labels.signature_name": "", "labels.signature_title": ""}).output
+ assert "______________________________\nJohn Smith\nCEO\nDate:\nPlace:\n" in output
From 3c92e56d59ea6b2e7c1219e7da8847761a6240a2 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:57:00 +0000
Subject: [PATCH 017/118] -o: only the extension of a format the renderer does
not write is a usage error (review: cli)
contract.v2, report.2025-10-27 and .out failed with exit 2 since the S6 fix. Now
only .docx .doc .odt .rtf .pdf .md .markdown .epub .tex (any case) need --format;
any other extension, or none, keeps the html default. -o and -f --help and the
README state the exact rule. tests/test_wp2_cli.py: the S6 parametrization grows
to the full list, and tests for the kept default, known extensions and help.
---
README.md | 7 +++++--
src/legaldown_render/cli.py | 18 +++++++++++++-----
tests/test_wp2_cli.py | 29 +++++++++++++++++++++++++++--
3 files changed, 45 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 1eeed6a..396d5cb 100644
--- a/README.md
+++ b/README.md
@@ -73,8 +73,11 @@ read (a document that is not UTF-8 included) or the output file cannot be writte
style, setting, or usage, and 70 for an internal error (a bug to report).
Documents are read as UTF-8, and standard output is written as UTF-8 whatever the console's encoding.
-`-o` takes its format from the file extension (`.html`, `.htm`, `.txt`, `.text`); a name with no
-extension is HTML, and any other extension (`.docx`, `.pdf`, `.md`) needs an explicit `--format`.
+Without `--format`, `-o` takes the format from the file extension: `.html` and `.htm` write HTML,
+`.txt` and `.text` plain text. The extension of a document format this renderer does not write
+(`.docx`, `.doc`, `.odt`, `.rtf`, `.pdf`, `.md`, `.markdown`, `.epub`, `.tex`, in any case) is a usage
+error (exit 2) unless `--format` says what to write. Any other name, with any other extension or
+none (`contract`, `contract.v2`, `report.2025-10-27`, `.out`), is just a name and writes HTML.
## Python
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index 8509c08..881b6ae 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -25,6 +25,12 @@
from .style import builtin_styles, dump_style, load_style, parse_override
from .writers import FORMATS, format_for_path
+#: Extensions of document formats this renderer does not write. An output file
+#: named so (``x.docx``) and no ``--format`` is a mistake to refuse: HTML under
+#: that name would mislead. Any other extension (``contract.v2``,
+#: ``report.2025-10-27``), or none, is just a name and keeps the HTML default.
+_UNWRITTEN_EXTENSIONS = frozenset({".docx", ".doc", ".odt", ".rtf", ".pdf", ".md", ".markdown", ".epub", ".tex"})
+
def _setting(text: str) -> tuple[str, Any]:
key, separator, value = text.partition("=")
@@ -48,8 +54,12 @@ def _parser() -> argparse.ArgumentParser:
"See every setting with --print-style.",
)
parser.add_argument("input", nargs="?", help="the .lgd file to render, or - for standard input")
- parser.add_argument("-o", "--output", help="output file (default: standard output); its extension sets the format")
- parser.add_argument("-f", "--format", choices=sorted(FORMATS), help="output format (default: from --output, else html)")
+ parser.add_argument("-o", "--output", help="output file (default: standard output). Without --format, "
+ ".html/.htm write HTML and .txt/.text plain text; any other name, with or without an "
+ "extension, writes HTML, except the extension of a format not written (.docx, .doc, "
+ ".odt, .rtf, .pdf, .md, .markdown, .epub, .tex), which needs --format")
+ parser.add_argument("-f", "--format", choices=sorted(FORMATS),
+ help="output format (default: html, or text for --output *.txt/*.text)")
parser.add_argument("-s", "--style", help="style template: a built-in name or a path to a .yaml file")
parser.add_argument("--set", dest="overrides", action="append", default=[], type=_setting, metavar="KEY=VALUE",
help="override one style setting; repeatable. Text settings take the value as "
@@ -123,9 +133,7 @@ def main(argv: list[str] | None = None) -> int:
if not output_format and args.output:
file_name = Path(args.output).name
output_format = format_for_path(file_name)
- if not output_format and "." in file_name.rstrip("."):
- # A name with an extension we do not write (x.docx, x.pdf, x.md): HTML under that
- # name would mislead. A name without one keeps the html default.
+ if not output_format and Path(file_name).suffix.lower() in _UNWRITTEN_EXTENSIONS:
parser.error(f"cannot tell the output format from '{args.output}'; use --format html or text")
output_format = output_format or "html"
name = "" if args.input == "-" else args.input
diff --git a/tests/test_wp2_cli.py b/tests/test_wp2_cli.py
index 949fcff..7c0c43d 100644
--- a/tests/test_wp2_cli.py
+++ b/tests/test_wp2_cli.py
@@ -149,9 +149,10 @@ def test_output_that_is_a_directory(tmp_path: Path, capsys: pytest.CaptureFixtur
assert "cannot write" in capsys.readouterr().err
-# --- S6: unknown extension -------------------------------------------------------------------
+# --- S6: an extension of a format not written -----------------------------------------------
-@pytest.mark.parametrize("name", ["x.docx", "x.pdf", "x.md", "x.DOCX"])
+@pytest.mark.parametrize("name", ["x.docx", "x.pdf", "x.md", "x.DOCX", "x.doc", "x.odt", "x.rtf", "x.markdown",
+ "x.epub", "x.tex", "x.v2.Pdf"])
def test_unknown_output_extension_without_format_is_a_usage_error(
tmp_path: Path, capsys: pytest.CaptureFixture[str], name: str) -> None:
target = tmp_path / name
@@ -177,6 +178,30 @@ def test_output_without_an_extension_keeps_the_html_default(tmp_path: Path) -> N
assert target.read_text(encoding="utf-8").startswith("")
+@pytest.mark.parametrize("name", ["contract.v2", "report.2025-10-27", ".out", "out.", "notes.final", "x.json"])
+def test_any_other_extension_keeps_the_html_default(tmp_path: Path, name: str) -> None:
+ target = tmp_path / name
+ assert main([FEATURES, "-q", "-o", str(target)]) == 0
+ assert target.read_text(encoding="utf-8").startswith("")
+
+
+def test_a_known_extension_still_sets_the_format(tmp_path: Path) -> None:
+ for name, start in (("a.TXT", "Services Agreement"), ("a.text", "Services Agreement"),
+ ("a.htm", ""), ("a.html", "")):
+ target = tmp_path / name
+ assert main([FEATURES, "-q", "-o", str(target)]) == 0
+ assert target.read_text(encoding="utf-8").startswith(start)
+
+
+def test_the_help_states_the_output_rule(capsys: pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit):
+ main(["--help"])
+ help_text = " ".join(capsys.readouterr().out.split())
+ assert "its extension sets the format" not in help_text
+ assert "any other name, with or without an extension, writes HTML" in help_text
+ assert ".docx, .doc, .odt, .rtf, .pdf, .md, .markdown, .epub, .tex" in help_text
+
+
# --- S2: error hierarchy ---------------------------------------------------------------------
def test_style_error_is_a_render_error_and_still_a_value_error() -> None:
From 73a845c601bf5c51533ca015f9057dbbb70a2c65 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:58:09 +0000
Subject: [PATCH 018/118] CLI: only the program reconfigures the streams;
output is UTF-8 with LF everywhere (review: cli)
main() reconfigured sys.stdout/sys.stderr on every call, so an in-process
main([...]) changed the caller's streams for good. It now does so only when
argv is None (the console script and python -m call it so). Standard output is
reconfigured with newline="\n" and -o files are written with newline="\n", so
no platform translates LF to CRLF. README states both. tests/test_wp2_cli.py:
comments on the exact-byte comparisons (LF on every platform). New tests in
tests/test_wp9_review_fixes.py. No golden changed.
---
README.md | 5 ++-
src/legaldown_render/cli.py | 21 ++++++---
tests/test_wp2_cli.py | 3 ++
tests/test_wp9_review_fixes.py | 79 ++++++++++++++++++++++++++++++++++
4 files changed, 101 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 396d5cb..d8197a1 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,10 @@ or a template that cannot be assembled with its answers) or the document or answ
read (a document that is not UTF-8 included) or the output file cannot be written, 2 for an invalid
style, setting, or usage, and 70 for an internal error (a bug to report).
-Documents are read as UTF-8, and standard output is written as UTF-8 whatever the console's encoding.
+Documents are read as UTF-8, and output is UTF-8 with LF line endings, on standard output and in
+`-o` files, whatever the console's encoding and the platform's newline. The command reconfigures
+the standard streams only when it runs as the program; calling `legaldown_render.cli.main([...])`
+from Python leaves `sys.stdout` and `sys.stderr` as they are.
Without `--format`, `-o` takes the format from the file extension: `.html` and `.htm` write HTML,
`.txt` and `.text` plain text. The extension of a document format this renderer does not write
(`.docx`, `.doc`, `.odt`, `.rtf`, `.pdf`, `.md`, `.markdown`, `.epub`, `.tex`, in any case) is a usage
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index 881b6ae..a7be21f 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -7,7 +7,8 @@
report).
Documents and output are always UTF-8 (the HTML says ````),
-never the console's or the locale's encoding.
+never the console's or the locale's encoding, and output has LF line endings on
+every platform.
"""
from __future__ import annotations
@@ -85,13 +86,15 @@ def _parser() -> argparse.ArgumentParser:
def _configure_streams() -> None:
"""Make the standard streams safe for any content, whatever the console's
encoding: the document and the style are UTF-8, so what is written to
- standard output is UTF-8 as well, and a diagnostic that quotes characters
+ standard output is UTF-8 as well, with LF line endings (no translation to
+ CRLF on Windows), and a diagnostic that quotes characters
the console cannot show escapes them (``\\u2192``) instead of failing with a
UnicodeEncodeError. Replaced streams without ``reconfigure`` (a test
- capture, a StringIO) are left alone."""
+ capture, a StringIO) are left alone. :func:`main` calls this only when it
+ runs as the program (see there)."""
if hasattr(sys.stdout, "reconfigure"):
with contextlib.suppress(ValueError, OSError):
- sys.stdout.reconfigure(encoding="utf-8")
+ sys.stdout.reconfigure(encoding="utf-8", newline="\n")
if hasattr(sys.stderr, "reconfigure"):
with contextlib.suppress(ValueError, OSError):
sys.stderr.reconfigure(errors="backslashreplace")
@@ -109,7 +112,13 @@ def _read_source(path: str) -> str:
def main(argv: list[str] | None = None) -> int:
- _configure_streams()
+ """Run the command: *argv* is the argument list, or None for the process's
+ own (``sys.argv``). Only then is ``main`` the program, as the console script
+ and ``python -m legaldown_render.cli`` call it, and only then are the
+ standard streams reconfigured: a caller that passes *argv* is in-process
+ code whose ``sys.stdout`` and ``sys.stderr`` are not ours to change."""
+ if argv is None:
+ _configure_streams()
parser = _parser()
args = parser.parse_args(argv)
overrides = dict(args.overrides)
@@ -181,7 +190,7 @@ def main(argv: list[str] | None = None) -> int:
_report(name, result.diagnostics, quiet=args.quiet)
if args.output:
try:
- Path(args.output).write_text(result.output, encoding="utf-8")
+ Path(args.output).write_text(result.output, encoding="utf-8", newline="\n")
except OSError as error:
print(f"legaldown-render: cannot write {args.output}: {error.strerror or error}", file=sys.stderr)
return 1
diff --git a/tests/test_wp2_cli.py b/tests/test_wp2_cli.py
index 7c0c43d..1d1e199 100644
--- a/tests/test_wp2_cli.py
+++ b/tests/test_wp2_cli.py
@@ -55,6 +55,8 @@ def test_stdin_to_stdout_is_utf8_whatever_the_console_encoding(fmt: str) -> None
expected = render(CZECH, format=fmt).output.encode("utf-8")
done = _run(["-", "-f", fmt, "-q"], CZECH.encode("utf-8"))
assert done.returncode == 0, done.stderr
+ # Exact bytes: the output is UTF-8 with LF line endings on every platform, so
+ # the same comparison holds on Windows (no CRLF translation of stdout).
assert done.stdout == expected
assert "Příloha".encode() in done.stdout
@@ -64,6 +66,7 @@ def test_file_to_stdout_is_utf8_whatever_the_console_encoding(tmp_path: Path) ->
path.write_bytes(CZECH.encode("utf-8"))
done = _run([str(path), "-f", "text", "-q"])
assert done.returncode == 0, done.stderr
+ # Exact bytes, as above: UTF-8 with LF line endings on every platform.
assert done.stdout == render(CZECH, format="text").output.encode("utf-8")
diff --git a/tests/test_wp9_review_fixes.py b/tests/test_wp9_review_fixes.py
index 0e1284a..3d9b5a3 100644
--- a/tests/test_wp9_review_fixes.py
+++ b/tests/test_wp9_review_fixes.py
@@ -117,3 +117,82 @@ def test_a_money_amount_in_name_mode_with_a_4400_digit_fraction_renders() -> Non
def test_a_4400_digit_fraction_uses_the_other_category_in_czech() -> None:
output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=Y}}}}", format="text", locale="cs-CZ").output
assert output.rstrip().endswith(" let") # "other", not the "many" of "2,5 roku"
+
+
+# -- 4: only the program reconfigures the streams -------------------------------
+
+
+def _wrapper(encoding: str, **options: object) -> io.TextIOWrapper:
+ return io.TextIOWrapper(io.BytesIO(), encoding=encoding, **options) # type: ignore[arg-type]
+
+
+def test_in_process_main_leaves_the_streams_alone(monkeypatch: pytest.MonkeyPatch) -> None:
+ out, err = _wrapper("cp1252", newline="\r\n"), _wrapper("cp1252")
+ monkeypatch.setattr(sys, "stdout", out)
+ monkeypatch.setattr(sys, "stderr", err)
+ assert main([FEATURES, "-f", "text", "-q"]) == 0
+ assert main(["--list-styles"]) == 0
+ assert (sys.stdout.encoding, sys.stdout.errors) == ("cp1252", "strict")
+ assert (sys.stderr.encoding, sys.stderr.errors) == ("cp1252", "strict")
+ out.flush()
+ # Not even the newline translation of the caller's stream changed.
+ assert b"\r\n" in out.buffer.getvalue() # type: ignore[attr-defined]
+
+
+def test_main_as_the_program_reconfigures_the_streams(monkeypatch: pytest.MonkeyPatch) -> None:
+ out, err = _wrapper("cp1252", newline="\r\n"), _wrapper("cp1252")
+ monkeypatch.setattr(sys, "stdout", out)
+ monkeypatch.setattr(sys, "stderr", err)
+ monkeypatch.setattr(sys, "argv", ["legaldown-render", "--list-styles"])
+ assert main() == 0
+ out.flush()
+ assert (out.encoding, err.errors) == ("utf-8", "backslashreplace")
+ data = out.buffer.getvalue() # type: ignore[attr-defined]
+ assert data and b"\r" not in data and data.endswith(b"\n")
+
+
+def test_the_console_script_calls_main_without_argv() -> None:
+ # The script entry point calls main() with no argument, so it is "the program".
+ pyproject = (SRC.parent / "pyproject.toml").read_text(encoding="utf-8")
+ assert 'legaldown-render = "legaldown_render.cli:main"' in pyproject
+
+
+# -- 5: LF line endings everywhere ------------------------------------------------
+
+
+def _run(args: list[str]) -> subprocess.CompletedProcess[bytes]:
+ env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], capture_output=True, env=env,
+ check=False)
+
+
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_standard_output_has_lf_line_endings(fmt: str) -> None:
+ done = _run([FEATURES, "-f", fmt, "-q"])
+ assert done.returncode == 0, done.stderr
+ assert b"\n" in done.stdout and b"\r" not in done.stdout
+
+
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_an_output_file_has_lf_line_endings(tmp_path: Path, fmt: str) -> None:
+ target = tmp_path / "out.bin"
+ assert main([FEATURES, "-q", "-f", fmt, "-o", str(target)]) == 0
+ data = target.read_bytes()
+ assert b"\n" in data and b"\r" not in data
+ assert data == render(Path(FEATURES).read_text(encoding="utf-8"), format=fmt).output.encode("utf-8")
+
+
+def test_an_output_file_is_written_without_newline_translation(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ # Windows' text mode would turn "\n" into CRLF unless the file is opened
+ # with newline="\n": that is what the CLI asks for.
+ calls: list[dict[str, object]] = []
+ real = Path.write_text
+
+ def spy(self: Path, data: str, *args: object, **kwargs: object) -> int:
+ calls.append(kwargs)
+ return real(self, data, *args, **kwargs) # type: ignore[arg-type]
+
+ monkeypatch.setattr(Path, "write_text", spy)
+ assert main([FEATURES, "-q", "-o", str(tmp_path / "out.txt")]) == 0
+ assert calls == [{"encoding": "utf-8", "newline": "\n"}]
From 50742867630ca435c3b0652b9ed6bfef653751e7 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:04:38 +0000
Subject: [PATCH 019/118] Docs and labels: neutral party-id labels, packaging,
documented limitations (S13, S14, S15, B1, B5, B7, B10, R7/B11, W9/S12)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
S13 labels (style/labels.py): the party identifier label is shown for §3.4
identification_number, which natural persons may have too. cs "IČO" ->
"Identifikační číslo", sk "IČO" -> "Identifikačné číslo", fr "Représentée par"
-> "Représenté(e) par"; pl "Reprezentacja" kept (no gendered form). Existing
test changed: tests/test_render.py French label assertion. No golden shows
these labels. New tests/test_wp8_labels.py.
S14: README and __init__ docstring examples open files with encoding="utf-8"
(and write with newline="\n"); the --locale example says it formats values and
README states labels follow the document language; README links are absolute
GitHub URLs for PyPI; CONFORMANCE.md added to the sdist (checked with
python -m build --sdist). docs: locale vs language wording in concepts.md and
style-templates.md.
S15: dropped the deprecated License classifier (license = "MIT" is SPDX);
.github/PUBLISHING.md recommends gating the publish workflow on the tests
(workflow file not changed).
DOC rows, each verified by running an example: CONFORMANCE.md Known
limitations for text after a comment (B1), directives in link/image
destinations (B5), the exact nesting-limit rule (B7), table cells beyond the
header (B10), parties block vs {{party:}} (R7/B11); architecture.md Security
rewritten for trusted style input (W9/S12), link-gate sentences de-duplicated.
---
.github/PUBLISHING.md | 7 +++++
CONFORMANCE.md | 28 +++++++++++++++++--
README.md | 33 ++++++++++++----------
docs/architecture.md | 32 +++++++++++++++------
docs/concepts.md | 4 +--
docs/style-templates.md | 19 ++++++++++---
pyproject.toml | 3 +-
src/legaldown_render/__init__.py | 6 ++--
src/legaldown_render/style/labels.py | 6 ++--
tests/test_render.py | 2 +-
tests/test_wp8_labels.py | 42 ++++++++++++++++++++++++++++
11 files changed, 142 insertions(+), 40 deletions(-)
create mode 100644 tests/test_wp8_labels.py
diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md
index 21ad195..e3fafa2 100644
--- a/.github/PUBLISHING.md
+++ b/.github/PUBLISHING.md
@@ -89,4 +89,11 @@ markdown-it-py) come from PyPI.
overrides the installed validator with `main`, so it tests main regardless of the range). The
tests fail on any `legaldown.LegaldownDeprecationWarning`, so a release never calls what the
validator's next minor version removes.
+- **Recommendation: gate the upload on the tests.** The `publish` workflow builds and uploads
+ without running the test suite, and `ci.yml` runs separately, so a release can reach PyPI from
+ a commit whose tests fail. Add the lint, test, and conformance jobs (or one called workflow)
+ as `needs:` of the `build` job in `publish.yml`, or require a passing CI run on the tagged
+ commit before the Release is published. This repository's workflows are not changed here.
+- The license is declared once, as the SPDX expression `license = "MIT"` in `pyproject.toml`
+ (PEP 639), so the deprecated `License :: OSI Approved :: MIT License` classifier is not listed.
- The package ships a `py.typed` marker, so type checkers use its annotations directly.
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index d0c3021..aa52e49 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -37,7 +37,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| 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 |
| A conditional unit's condition is shown (§15.8, MUST) | ✅ Also for a custom style: `labels.condition` must contain `{condition}`, or the style is refused |
| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ |
-| `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 |
+| `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 (see "Text after a comment", below) |
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
| Links (§8.7) | ✅ Every link is a hyperlink in HTML and shows `text ` in plain text, whatever its scheme (`ftp:`, `sip:`, `urn:`, ...), except one whose URL is empty or uses `javascript:`, `vbscript:`, `file:`, or `data:`, which shows its text alone. A link with no text shows its URL; the text writer omits `` for an autolink |
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
@@ -61,13 +61,35 @@ It is checked against the specification's own examples and fixtures corpus (`tes
The containment rule (`path-outside-root`) is not checked: no document root is configured.
- **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.
+ in which the enclosing lists and quotes plus one text's inline formatting (emphasis, links) nest
+ more than 100 levels, while building it and before resolving or writing it, so no document can
+ exhaust the stack. The count is per text: a defined term's own text (`"A *b*" {{def: x}}`) is
+ built separately, so the emphasis inside the term does not add to the emphasis around it. Each
+ is checked against the limit with the enclosing lists and quotes, and their sum can exceed 100.
- **Comment-only items and quotes.** A list item whose blocks are all comments or raw HTML is
left out (§8.6: the author removed it), and so is a list left with no items and a quote that
holds only comments. Numbers and designations are those of the items that remain. An item with
no content at all keeps its place (CommonMark), and so does an empty drafting note, whose label
is the template construct (§15.6).
+- **Text after a comment.** A comment that starts a line is a CommonMark HTML block, which ends at
+ the line holding `-->`. Anything after the `-->` on that line belongs to the block and is dropped
+ with it, with the validator's `raw-html` Warning: ` text` renders nothing (the lines
+ after it are ordinary paragraphs again). A line holding only the comment is dropped with no
+ Warning, and a comment inside a line of text (`before after`) is dropped alone,
+ keeping the text around it (§8.6).
+- **Directives in a link or image destination.** A destination is not text: `[x](#{{ref: a}})`,
+ `` and `` are not resolved. The `href` or `src`
+ holds the directive's source, percent-encoded (`#%7B%7Bref:%20a%7D%7D`). The validator still reads
+ the directive, so a broken `{{ref:}}` there is reported (`ref-broken`), and the visible text of
+ an autolink shows it resolved, unlike its target. Directives in link text and alt text are
+ resolved.
+- **Table cells.** A row with more cells than the header has loses the extra cells, as GFM
+ specifies, with no diagnostic from the validator or the renderer; a shorter row is padded with
+ empty cells.
+- **Parties and `{{party:}}`.** The title block lists each party as an identification block, by
+ `legal_name` (then `label`, then `name`), and the signature blocks use `legal_name` (§3.6). The
+ text uses `label`, falling back to `legal_name` (§3.6): "the Seller" in a sentence, "Acme
+ Corporation" in the title block.
- **`{{term:}}` text.** A `{{term:}}` shows the term as rendered at its first definition in the
body, with its Markdown, `{{ref:}}`s and placeholders resolved as at the definition
(`"A *b* c" {{def: abc}}` is "A b c", `"Fee per {{ref: h}}"` is "Fee per 1"), not the term's raw
diff --git a/README.md b/README.md
index d8197a1..ef9fdc4 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ Python 3.11 or newer. The dependencies are all pure Python: `legaldown-validator
```bash
legaldown-render contract.lgd -o contract.html # HTML page
legaldown-render contract.lgd -o contract.txt # plain text (format from extension)
-legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html
+legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html # Czech dates, money, durations
legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumeration.enabled=false
legaldown-render contract.lgd --set contents.enabled=true --set contents.depth=3 # table of contents
legaldown-render contract.lgd --strict # refuse if the document has errors
@@ -66,6 +66,9 @@ legaldown-render template.lgd --answers answers.yaml -o nda.html # fill in a tem
legaldown-render --print-style --style continental # every setting, as YAML
```
+`--locale` sets how dates, money, and durations are formatted. The words the renderer generates
+(such as "Represented by" or "Signatures") follow the document's `language` (§13.7), whatever the locale.
+
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 (strict mode,
or a template that cannot be assembled with its answers) or the document or answers file cannot be
@@ -87,10 +90,12 @@ none (`contract`, `contract.v2`, `report.2025-10-27`, `.out`), is just a name an
```python
from legaldown_render import render
-result = render(open("contract.lgd").read(), format="html", style="continental", locale="cs-CZ")
+with open("contract.lgd", encoding="utf-8") as source:
+ result = render(source.read(), format="html", style="continental", locale="cs-CZ")
for diagnostic in result.diagnostics:
print(diagnostic.level, diagnostic.rule, diagnostic.message)
-open("contract.html", "w").write(result.output)
+with open("contract.html", "w", encoding="utf-8", newline="\n") as output:
+ output.write(result.output)
```
`render()` also accepts a `RenderOptions`. Pass `answers={...}` to render a template filled in.
@@ -117,9 +122,9 @@ typography: { font_family: "Georgia, serif", justify: true }
```
**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).
+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](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/style-templates.md).
Built-in styles are `default` (1. / 1.1, with (a) / (i) list items), `continental` (numbered
articles, 2.1-style items, numbered paragraphs), and `outline` (I. / A. / 1. / a.).
@@ -128,12 +133,12 @@ articles, 2.1-style items, numbered paragraphs), and `outline` (I. / A. / 1. / a
| Document | What it covers |
|---|---|
-| [Concepts](docs/concepts.md) | What a renderer is responsible for, principles, and vocabulary |
-| [Architecture](docs/architecture.md) | The pipeline, the render tree, the package layout, and testing |
-| [Styles and preferences](docs/style-templates.md) | The style format, layering, and every setting |
-| [Conformance](CONFORMANCE.md) | What the Rendering level requires, and what is covered |
-| [Roadmap](docs/roadmap.md) | Milestones, and the changes needed upstream |
-| [Decision records](docs/decisions/) | Why the main choices were made |
+| [Concepts](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/concepts.md) | What a renderer is responsible for, principles, and vocabulary |
+| [Architecture](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/architecture.md) | The pipeline, the render tree, the package layout, and testing |
+| [Styles and preferences](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/style-templates.md) | The style format, layering, and every setting |
+| [Conformance](https://github.com/ForLegalAI/legaldown-render/blob/main/CONFORMANCE.md) | What the Rendering level requires, and what is covered |
+| [Roadmap](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/roadmap.md) | Milestones, and the changes needed upstream |
+| [Decision records](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/decisions/) | Why the main choices were made |
## Development
@@ -145,8 +150,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).
+Releases go to PyPI from a GitHub Release; see [PUBLISHING.md](https://github.com/ForLegalAI/legaldown-render/blob/main/.github/PUBLISHING.md).
## License
-[MIT](LICENSE)
+[MIT](https://github.com/ForLegalAI/legaldown-render/blob/main/LICENSE)
diff --git a/docs/architecture.md b/docs/architecture.md
index 835f4a1..492a95f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -102,7 +102,9 @@ replaces are punctuation, so that CommonMark's emphasis flanking rules see the s
in the source: `_text_{{ref: a}}` closes its emphasis. A defined term in a link title or alt text shows its term but is not the
definition's anchor.
-An HTML block in the model (`kind="html"`), a comment block included, renders nothing (§8.6).
+An HTML block in the model (`kind="html"`), a comment block included, renders nothing (§8.6). A
+comment that starts a line is such a block, and it ends at the line holding `-->`, so text after
+the `-->` on that line is dropped with it (CONFORMANCE.md, "Text after a comment").
Within a block's text, inline comments and tags are dropped; where one stood between two spaces,
one space stays, and before a line break none does. A list item whose blocks are all comments,
and a quote that holds only comments, are dropped (with a list left empty), after the whole list
@@ -154,7 +156,9 @@ Guessing at lost structure was tried, and it traded each gap for new bugs.
A `label` never hides a failure marker (§11.5). Frontmatter strings go through the same resolver,
so placeholders in frontmatter render as blanks (§3.10).
-The resolver also builds the **title block** (effective date, sides and parties), **attachment
+The resolver also builds the **title block** (effective date, sides and parties; a party is named
+there by `legal_name`, as an identification block, where `{{party:}}` in the text shows its
+`label`, §3.6), **attachment
placeholders** (§13.8; files are not read at this level), and **signature blocks** (§2.2, always
with `legal_name`, per §3.6).
@@ -248,11 +252,10 @@ line public (`Document.line_of`, U2).
`file`, or `data` (a deny-list, `urls.DENIED_SCHEMES`, judged as a browser reads the URL: tabs,
newlines, controls, and case are ignored). Every other scheme (`ftp:`, `sip:`, `urn:`, ...) is
linked. A link that is not made renders as its text alone, in HTML and in text alike. The
- builder keeps such links as nodes (it turns off markdown-it-py's own `validateLink`, which
- would leave them as literal Markdown), so the writers' check is the one that applies;
- that deny-list is the gate for every `href` and `src` the HTML writer emits (links, images,
- value links), and markdown-it-py's check is off so that the resolver can report §2.3 paths.
- A link with no text shows its URL; the text writer leaves out ` ` when the URL, less a
+ builder turns off markdown-it-py's own `validateLink` (which would leave such links as literal
+ Markdown) and keeps them as nodes, so that the resolver can report §2.3 paths and the writers'
+ deny-list is the one gate for every `href` and `src` the HTML writer emits (links, images,
+ value links). A link with no text shows its URL; the text writer leaves out ` ` when the URL, less a
`mailto:` or `tel:` prefix, is the text.
- An image path must be relative with no URI scheme (§2.3), so a remote resource is never fetched
or embedded. Resolution, not a writer, enforces it: an image whose path has a scheme (`https:`,
@@ -264,8 +267,19 @@ line public (`Document.line_of`, U2).
- At Rendering level, the only files read are the document, the style template, and the answers
file the CLI reads with `--answers` (the Python API takes answers as a mapping). Attachment
files and includes are never opened.
-- Style values that are placed into CSS are stripped of `;{}<>`. `html.extra_css` is trusted
- configuration, so a hosted service must not accept styles from untrusted users.
+- **A style template is trusted input.** Nothing in it is a security boundary:
+ - A value placed into the stylesheet (fonts, sizes, colours, margins, `page.size`) is stripped
+ of `;{}<>` only (`writers/html.py:_css`). That keeps it from ending its declaration or the
+ `` included.
+ - `extends` names any path the process can read, absolute or with `../`; only cycles are
+ rejected. (A style file is read with `yaml.safe_load`, and as UTF-8.)
+ A service that renders for several parties must choose the style itself, and never accept a
+ style file, `--set` values, or `extends` paths from a user it does not trust. Document source
+ has no such power: it is never emitted as HTML or CSS (§8.7).
## Testing
diff --git a/docs/concepts.md b/docs/concepts.md
index 1509192..e5d895a 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -75,8 +75,8 @@ errors, because a draft with visible markers is useful. Under `--strict` it refu
**4. Content and presentation stay separate.** Everything visual comes from the style template,
including numbering schemes, locale, and the labels used in the template view. The same source
-must render correctly with any compatible template (§13.7). Locale is a render-time setting and
-is never read from the document; `language` is only a hint (§10.1).
+must render correctly with any compatible template (§13.7). Locale is a render-time setting
+of the style: the document's `language` is only the hint used when the style sets none (§10.1).
**5. Output is deterministic.** Identical inputs produce byte-identical output: no timestamps,
no random ids, stable anchor names, stable ordering. This makes golden-file tests possible and
diff --git a/docs/style-templates.md b/docs/style-templates.md
index dc3f186..54177b7 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -25,8 +25,9 @@ Values are layered. Each layer overrides the one before it, **field by field**:
1. **Built-in defaults.** Every setting has one, defined in `legaldown_render/style/model.py`.
The `default` style is exactly these defaults.
2. **The `extends` chain.** A style names the style it builds on, which can be a built-in name or
- a path relative to the file. Chains are resolved base first, and cycles are rejected, however
- the files are spelled (`../dir/a.yaml`, a symbolic link).
+ a path relative to the file (any path the process can read, `../` and absolute paths
+ included). Chains are resolved base first, and cycles are rejected, however the files are
+ spelled (`../dir/a.yaml`, a symbolic link).
3. **The chosen style**: `--style NAME|PATH`, or `RenderOptions(style=...)`.
4. **Per-job overrides**: `--set numbering.scheme=mixed` or `overrides={"numbering.scheme": "mixed"}`,
plus `--locale`. On the command line, text settings take the value as written
@@ -136,7 +137,7 @@ headings: # per level 1–5
page: { size: A4, margin: 25mm } # print stylesheet now; the PDF writer later
html:
max_width: 46rem
- extra_css: "" # appended to the stylesheet; trusted configuration only
+ extra_css: "" # raw CSS, appended to the stylesheet as written (see "Trusted input")
```
### Level formats
@@ -185,9 +186,19 @@ legaldown-render: Invalid style 'default':
A `ref` is one piece of a designation, so it cannot show the whole `{path}`. A label left unset
takes the built-in one for the document language, which always passes.
+### Trusted input
+
+A style template, a `--set` value, and an `extends` path are the operator's, not the document
+author's. Values placed into the stylesheet (`typography`, `headings`, `page`, `html.max_width`)
+are stripped of `;{}<>` and are otherwise not checked as CSS, so a value such as `12pt (` is
+emitted as written and breaks the rest of the stylesheet. `html.extra_css` is raw CSS. Do not
+render with styles from users you do not trust
+([Security](architecture.md#security)).
+
### Built-in labels
-Labels follow the document's `language`. English (`en`), Czech (`cs`), German (`de`), French
+Labels follow the document's `language`, not `locale` (which formats dates, money, and durations).
+English (`en`), Czech (`cs`), German (`de`), French
(`fr`), Polish (`pl`), and Slovak (`sk`) are built in, and other languages fall back to English. A style sets any label explicitly to override it, which is also
how a firm adds a language.
diff --git a/pyproject.toml b/pyproject.toml
index 8ea3cbe..63a65a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,7 +19,6 @@ classifiers = [
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Legal Industry",
- "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
@@ -59,7 +58,7 @@ path = "src/legaldown_render/__init__.py"
packages = ["src/legaldown_render"]
[tool.hatch.build.targets.sdist]
-include = ["src/legaldown_render", "tests", "docs", "README.md", "LICENSE", "pyproject.toml"]
+include = ["src/legaldown_render", "tests", "docs", "README.md", "CONFORMANCE.md", "LICENSE", "pyproject.toml"]
[tool.ruff]
line-length = 120
diff --git a/src/legaldown_render/__init__.py b/src/legaldown_render/__init__.py
index 8845c22..366b307 100644
--- a/src/legaldown_render/__init__.py
+++ b/src/legaldown_render/__init__.py
@@ -6,10 +6,12 @@
from legaldown_render import render
- result = render(open("contract.lgd").read(), format="html", style="default")
+ with open("contract.lgd", encoding="utf-8") as source:
+ result = render(source.read(), format="html", style="default")
for diagnostic in result.diagnostics:
print(diagnostic.level, diagnostic.rule, diagnostic.message)
- open("contract.html", "w").write(result.output)
+ with open("contract.html", "w", encoding="utf-8", newline="\n") as output:
+ output.write(result.output)
Parsing and Core validation come from legaldown-validator; this package
claims the Rendering conformance level (§17.3). Exact coverage:
diff --git a/src/legaldown_render/style/labels.py b/src/legaldown_render/style/labels.py
index 19a432a..62b7f5e 100644
--- a/src/legaldown_render/style/labels.py
+++ b/src/legaldown_render/style/labels.py
@@ -29,7 +29,7 @@
"cs": Labels(
drafting_note="Poznámka pro zpracovatele",
condition="Pouze pokud: {condition}",
- identification_number="IČO",
+ identification_number="Identifikační číslo",
date_of_birth="Datum narození",
address="Adresa",
represented_by="Zastoupení",
@@ -71,7 +71,7 @@
identification_number="N° d'immatriculation",
date_of_birth="Date de naissance",
address="Adresse",
- represented_by="Représentée par",
+ represented_by="Représenté(e) par",
effective_date="Date d'effet",
version="Version",
attachments="Annexes",
@@ -106,7 +106,7 @@
"sk": Labels(
drafting_note="Poznámka pre spracovateľa",
condition="Iba ak: {condition}",
- identification_number="IČO",
+ identification_number="Identifikačné číslo",
date_of_birth="Dátum narodenia",
address="Adresa",
represented_by="Zastúpenie",
diff --git a/tests/test_render.py b/tests/test_render.py
index 37a0676..d432e3a 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -331,7 +331,7 @@ def test_representatives_are_labelled_and_french_labels_space_their_colons() ->
relabelled = render(source, format="text", overrides={"labels.represented_by": "Acting for"}).output
assert "Acting for: John Smith" in relabelled
french = render(source.replace("language: en", "language: fr"), format="text").output
- assert "Représentée par\xa0: John Smith" in french
+ assert "Représenté(e) par\xa0: John Smith" in french
assert "Nom\xa0: " in french and "\nLieu\xa0:\n" in french
assert "Date d'effet\xa0: " in french
diff --git a/tests/test_wp8_labels.py b/tests/test_wp8_labels.py
new file mode 100644
index 0000000..cb2a3fd
--- /dev/null
+++ b/tests/test_wp8_labels.py
@@ -0,0 +1,42 @@
+"""WP8 (S13): the party identifier label is neutral, since §3.4's
+``identification_number`` also covers a natural person's national ID."""
+from __future__ import annotations
+
+import pytest
+
+from legaldown_render import render
+from legaldown_render.style.labels import BUILTIN_LABELS
+
+DOCUMENT = """---
+legaldown: "0.2"
+title: T
+language: {language}
+sides:
+ - name: s1
+ parties:
+ - name: a
+ type: natural_person
+ legal_name: Jan Novak
+ identification_number: "800101/1234"
+ representatives:
+ - {{name: Eva, title: Jednatel}}
+---
+# A
+"""
+
+
+@pytest.mark.parametrize(("language", "identifier", "represented_by"), [
+ ("cs", "Identifikační číslo", "Zastoupení"),
+ ("sk", "Identifikačné číslo", "Zastúpenie"),
+ ("fr", "N° d'immatriculation", "Représenté(e) par"),
+ ("pl", "Nr rejestrowy", "Reprezentacja"),
+])
+def test_party_labels_in_each_language(language: str, identifier: str, represented_by: str) -> None:
+ colon = BUILTIN_LABELS[language].colon
+ output = render(DOCUMENT.format(language=language), format="text").output
+ assert f"{identifier}{colon}800101/1234" in output
+ assert f"{represented_by}{colon}Eva, Jednatel" in output
+
+
+def test_cs_and_sk_do_not_call_every_party_identifier_an_ico() -> None:
+ assert "IČO" not in {BUILTIN_LABELS["cs"].identification_number, BUILTIN_LABELS["sk"].identification_number}
From f5ad89f8c78ad133867ca1438f87f67bb2a49f52 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:08:44 +0000
Subject: [PATCH 020/118] Links: allow-list of schemes, refused link reported,
diagnostics escape controls (review wave 2)
- urls.LINK_SCHEMES (http, https, mailto, tel, ftp, ftps, sftp, sip, sips, sms,
urn, geo, news, plus scheme-less URLs) replaces the DENIED_SCHEMES deny-list:
protocol handlers (ms-msdt:, vscode:, ...) and pseudo-schemes (C:\x,
localhost:8080) render as their text. One predicate, is_link_target.
- resolver: a refused non-empty link is the Warning render-link-refused.
- urls.printable escapes C0/C1, DEL, Cf, U+2028/9 in quoted paths/URLs; used in
path-not-relative and the new warning (no ESC/BEL/newline forged on stderr).
- path-not-relative dedupe matches the quoted ''; message is generic.
- A link with no text shows the URL as written (unquoted), in both writers.
- tests/test_wp3_links.py: magnet: is no longer a link; 'a:b' is no longer
a safe href (allow-list).
---
src/legaldown_render/build.py | 2 +-
src/legaldown_render/resolve/resolver.py | 31 ++++-
src/legaldown_render/urls.py | 63 +++++++--
src/legaldown_render/writers/html.py | 11 +-
src/legaldown_render/writers/text.py | 4 +-
tests/test_wp10_review_fixes.py | 155 +++++++++++++++++++++++
tests/test_wp3_links.py | 8 +-
7 files changed, 243 insertions(+), 31 deletions(-)
create mode 100644 tests/test_wp10_review_fixes.py
diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py
index c9aa8e5..2b9ecfc 100644
--- a/src/legaldown_render/build.py
+++ b/src/legaldown_render/build.py
@@ -185,7 +185,7 @@ def __init__(self, document: Document, result: ValidationResult) -> None:
# prose. The builder keeps the node instead: the resolver reports an
# image path that is not relative (§2.3) and replaces the image with
# its alt text, and the writers show a refused link as its text
- # (`urls.DENIED_SCHEMES`), which is the safety markdown-it was for.
+ # (`urls.LINK_SCHEMES`), which is the safety markdown-it was for.
self.md.validateLink = lambda url: True
self.env: dict = {}
self.payloads: list[_Payload] = []
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 748e907..87fa466 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -17,7 +17,8 @@
failures shown here, so the resolver only adds the diagnostics that need a
style template or a renderer: ``ref-not-enumerated``, ``render-ref-ambiguous``,
and ``render-not-processed`` for constructs beyond the Rendering level (§17.5),
-and ``path-not-relative`` for an image whose path is not relative (§2.3), which
+``render-link-refused`` for a link whose URL scheme is not one links may use
+(§8.7), and ``path-not-relative`` for an image whose path is not relative (§2.3), which
the validator does not check yet.
"""
from __future__ import annotations
@@ -84,7 +85,7 @@
plain_text,
unlinked,
)
-from ..urls import is_empty_path, is_relative_path
+from ..urls import is_empty_path, is_link_target, is_relative_path, printable
from .numbering import RENUMBERED, extend, fill, format_counter, heading_levels
from .values import DURATION_UNITS, Formatter
@@ -532,7 +533,9 @@ def _resolve_inline(self, inline: Inline) -> Inline | tuple[Inline, ...] | None:
return Emphasis(self._resolve_inlines(children))
case Strong(children=children):
return Strong(self._resolve_inlines(children))
- case Link(children=children, title=title):
+ case Link(href=href, children=children, title=title):
+ if href.strip() and not is_link_target(href):
+ self._link_refused(href)
return replace(inline, children=self._resolve_inlines(children),
title=self._resolve_inlines(_attribute_text(title)))
case Image(src=src, children=children, title=title):
@@ -890,6 +893,18 @@ def _warn(self, rule: str, message: str) -> None:
if diagnostic not in self.diagnostics:
self.diagnostics.append(diagnostic)
+ def _link_refused(self, href: str) -> None:
+ """Report a link whose URL does not use a scheme links may use
+ (:data:`~legaldown_render.urls.LINK_SCHEMES`) as ``render-link-refused``:
+ the writers show its text and say nothing (ADR 0003), so the reader of
+ the document would not otherwise know a link was lost."""
+ shown = printable(unquote(href.strip()))
+ self._warn(
+ "render-link-refused",
+ f"Link to '{shown}' is not rendered as a link: only http, https, mailto, tel, ftp, ftps, sftp, sip, "
+ f"sips, sms, urn, geo, news and relative links are; its text is shown.",
+ )
+
def _not_relative(self, path: str) -> None:
"""Report an image *path* that is not relative as the Core Error
``path-not-relative`` (§2.3, §16.2), unless the validator has
@@ -898,13 +913,17 @@ def _not_relative(self, path: str) -> None:
percent-encoded the path ("C:\\x" reads "C:%5Cx"), so it is decoded
to name the path as written."""
path = unquote(path)
- if any(d.rule == "path-not-relative" and path in d.message for d in self.result.diagnostics):
+ # Assumption: the validator quotes the path in its message as '',
+ # as written. Matching the quoted form keeps a path that is a prefix of
+ # another ("/a" and "/ab") from being counted as already reported.
+ if any(d.rule == "path-not-relative" and f"'{path}'" in d.message for d in self.result.diagnostics):
return
+ path = printable(path)
diagnostic = Diagnostic(
rule="path-not-relative",
level="error",
- message=f"Image path '{path}' is not relative (§2.3): an image path is a relative path with no URI "
- f"scheme, and a remote resource is never fetched. Its alt text is shown instead.",
+ message=f"Image path '{path}' is not relative (§2.3): file references are relative paths with no "
+ f"URI scheme. Its alt text is shown instead.",
)
if diagnostic not in self.diagnostics:
self.diagnostics.append(diagnostic)
diff --git a/src/legaldown_render/urls.py b/src/legaldown_render/urls.py
index a76ef7c..6b89a8f 100644
--- a/src/legaldown_render/urls.py
+++ b/src/legaldown_render/urls.py
@@ -1,5 +1,6 @@
"""Reading URLs the way the renderer needs to: which schemes a link may use
-(§8.7), and whether an image path is a relative one (§2.3).
+(§8.7), whether an image path is a relative one (§2.3), and how to quote one in
+a message.
The checks are syntactic, like the validator's (§2.3). They are shared by the
resolver, which decides what an image is, and by both writers, which decide
@@ -8,16 +9,25 @@
from __future__ import annotations
import re
+import unicodedata
from urllib.parse import unquote
-#: Schemes whose links are never made: script (``javascript:``, ``vbscript:``),
-#: local files (``file:``), and ``data:``. This list is the gate: markdown-it's
-#: own ``validateLink`` is turned off in the builder, so that the resolver can
-#: see and report an image path that is not relative (§2.3), and the HTML
-#: writer applies this list to every ``href`` and ``src`` it emits (a link, an
-#: image, a value's link). Every other scheme (``ftp:``, ``sip:``, ``urn:``,
-#: ...) is a hyperlink, since §8.7 requires links to be rendered as hyperlinks.
-DENIED_SCHEMES = frozenset({"javascript", "vbscript", "file", "data"})
+#: The schemes a link may use: the web, mail and phone, file transfer, and a
+#: few that name a thing rather than start a program. Nothing else is a link:
+#: a protocol handler (``ms-msdt:``, ``search-ms:``, ``vscode:``, ``intent:``),
+#: a pseudo-scheme (``C:\x``, ``localhost:8080``), script, ``file:``, and
+#: ``data:`` render as their text, and the resolver reports it. §8.7 requires
+#: links to be hyperlinks, but a renderer reads documents it did not write
+#: (docs/concepts.md, principle 6), so it is an allow-list rather than a list
+#: of what is known to be dangerous. A URL with no scheme (a relative path, a
+#: ``#fragment``) is a link too. markdown-it's own ``validateLink`` is turned
+#: off in the builder, so that the resolver can see and report an image path
+#: that is not relative (§2.3); this list is the gate, and the HTML writer
+#: applies it to every ``href`` and ``src`` it emits (a link, an image, a
+#: value's link).
+LINK_SCHEMES = frozenset({
+ "http", "https", "mailto", "tel", "ftp", "ftps", "sftp", "sip", "sips", "sms", "urn", "geo", "news",
+})
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
# What the WHATWG URL parser ignores before it reads a scheme: tabs and
@@ -39,17 +49,44 @@ def url_scheme(url: str) -> str | None:
def is_safe_href(href: str) -> bool:
- """True unless *href* uses a scheme in :data:`DENIED_SCHEMES`, judged as a
+ """True if *href* has no scheme, or one in :data:`LINK_SCHEMES`, judged as a
browser parses the URL."""
- return url_scheme(href) not in DENIED_SCHEMES
+ scheme = url_scheme(href)
+ return scheme is None or scheme in LINK_SCHEMES
def is_link_target(href: str) -> bool:
- """True if a link to *href* is made: it names something, and is safe.
- An empty destination would be a link to the page itself."""
+ """True if a link to *href* is made: it names something, and its scheme is
+ one links may use. An empty destination would be a link to the page itself.
+ Both writers and the resolver's report use this one predicate."""
return bool(_as_read(href)) and is_safe_href(href)
+def displayed_url(href: str) -> str:
+ """*href* as the document wrote it, to show where a link has no text: the
+ percent-encoding markdown-it added is undone (``a%20b`` reads ``a b``),
+ unless that would show a control or format character."""
+ url = href.strip()
+ shown = unquote(url)
+ return url if any(unicodedata.category(c) in ("Cc", "Cf") for c in shown) else shown
+
+
+def printable(text: str) -> str:
+ """*text*, a path or URL from a document, made safe to quote in a message:
+ every control character (C0, C1, DEL), Unicode format character (bidi
+ controls, zero-width, ...), and line or paragraph separator is written as
+ ``\\xNN`` or ``\\uNNNN``, so that it cannot move the cursor, set a title,
+ or start a line of its own on a terminal."""
+ return "".join(_escaped(c) for c in text)
+
+
+def _escaped(c: str) -> str:
+ if unicodedata.category(c) not in ("Cc", "Cf") and c not in "\u2028\u2029":
+ return c
+ code = ord(c)
+ return f"\\x{code:02x}" if code < 0x100 else f"\\u{code:04x}" if code < 0x10000 else f"\\U{code:08x}"
+
+
def is_empty_path(path: str) -> bool:
"""True if *path* names nothing: empty, or only what a browser ignores. As
an image's source it would request the page itself."""
diff --git a/src/legaldown_render/writers/html.py b/src/legaldown_render/writers/html.py
index 01a440d..82ada1d 100644
--- a/src/legaldown_render/writers/html.py
+++ b/src/legaldown_render/writers/html.py
@@ -9,9 +9,10 @@
Safety (docs/architecture.md): every text node and attribute is escaped, raw
HTML from the source never reaches this writer (§8.7), and a link whose URL
-is empty or uses a script, ``file:``, or ``data:`` scheme
-(:data:`~legaldown_render.urls.DENIED_SCHEMES`, judged as a browser reads the
-URL) renders as its text alone. Every other scheme is a hyperlink (§8.7).
+is empty or uses a scheme outside the allow-list
+(:data:`~legaldown_render.urls.LINK_SCHEMES`, judged as a browser reads the
+URL: script, ``file:``, ``data:``, protocol handlers) renders as its text
+alone, and the resolver reports it. Every other link is a hyperlink (§8.7).
This check is the gate for every ``href`` and ``src`` the writer emits (a link,
an image, a value's link): the builder turns markdown-it's own check off.
An image path that is not relative (§2.3) never gets here: resolution has
@@ -56,7 +57,7 @@
has_visible_content,
plain_text,
)
-from ..urls import is_link_target, is_safe_href
+from ..urls import displayed_url, is_link_target, is_safe_href
__all__ = ["HtmlWriter", "is_safe_href", "stylesheet"]
@@ -314,7 +315,7 @@ def inline(self, inline: Inline) -> str:
return f"{_text(text)}"
case Link(href=href, children=children, title=title):
# A link with no text shows its URL (§8.7: it is still a hyperlink).
- shown = children if has_visible_content(children) else (Text(href.strip()),)
+ shown = children if has_visible_content(children) else (Text(displayed_url(href)),)
if not is_link_target(href):
return self.inlines(children)
title_attr = f' title="{_attr(plain_text(title))}"' if title else ""
diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py
index 4ca0a81..aa6f328 100644
--- a/src/legaldown_render/writers/text.py
+++ b/src/legaldown_render/writers/text.py
@@ -41,7 +41,7 @@
Text,
Value,
)
-from ..urls import is_link_target
+from ..urls import displayed_url, is_link_target
_INDENT = " "
_ADDRESS_SCHEMES = ("mailto:", "tel:")
@@ -54,7 +54,7 @@ def _link_text(href: str, text: str) -> str:
text alone, and one with no text shows its URL."""
if not is_link_target(href):
return text
- url = href.strip()
+ url = displayed_url(href)
if not text.strip():
return url
address = url
diff --git a/tests/test_wp10_review_fixes.py b/tests/test_wp10_review_fixes.py
new file mode 100644
index 0000000..e0aca2c
--- /dev/null
+++ b/tests/test_wp10_review_fixes.py
@@ -0,0 +1,155 @@
+"""Review fixes after wave 2 (WP10): links are an allow-list and a refused one is
+reported, control characters never reach a diagnostic, style templates are
+validated only where a setting is used, and small wording fixes."""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from legaldown_render import render
+from legaldown_render.urls import displayed_url, is_link_target, printable
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+FRONT = "---\ntitle: T\n---\n\n"
+
+
+def html(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="html", **settings).output
+
+
+def text(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="text", **settings).output
+
+
+# -- 1: links are an allow-list ---------------------------------------------------
+
+HANDLERS = [
+ "ms-msdt:/id PCWDiagnostic", "ms-word:ofe|u|https://x/a.docx", "ms-officecmd:{}", "search-ms:query=x",
+ "ms-appinstaller:?source=https://x/a.appx", "vscode://file/x", "intent://x#Intent;end", "blob:https://x/1",
+ "about:blank", "jar:https://x/a.jar!/", "view-source:https://x", "mhtml:file://x", "ms-its:x.chm::/a.htm",
+ "C:\\x", "C:/x", "localhost:8080", "magnet:?xt=urn:btih:x", "javascript:alert(1)", "file:///etc/passwd",
+ "data:text/html,x", " MS-MSDT:x",
+]
+ALLOWED = [
+ "http://e/x", "https://e/x", "mailto:a@e", "tel:+1", "ftp://e/x", "ftps://e/x", "sftp://e/x", "sip:a@e",
+ "sips:a@e", "sms:+1", "urn:isbn:1", "geo:1,2", "news:a.b", "a/b.pdf", "../a.pdf", "#top", "?q=1",
+]
+
+
+@pytest.mark.parametrize("href", HANDLERS)
+def test_a_protocol_handler_or_pseudo_scheme_is_not_a_link(href: str) -> None:
+ assert not is_link_target(href)
+ body = f"# A\n\nSee [click](<{href}>) now.\n"
+ result = render(FRONT + body, format="html")
+ assert "See click now." in result.output
+ assert "")[1]
+ assert "See click now." in text(body)
+ assert "<" not in text(body).split("See click now.")[0].split("\n")[-1]
+
+
+def test_the_scheme_is_read_as_a_browser_reads_it() -> None:
+ # markdown-it percent-encodes a tab in a URL it parses ("ms%09-msdt:x" is a
+ # relative path to a browser); the check still holds for a URL it is given
+ # directly, as a browser would read it.
+ for href in ("ms\t-msdt:x", "jav\nascript:x", "\x01javascript:x", "\u0000 vscode://x"):
+ assert not is_link_target(href), repr(href)
+
+
+@pytest.mark.parametrize("href", ALLOWED)
+def test_an_allowed_scheme_or_a_relative_url_is_a_link(href: str) -> None:
+ assert is_link_target(href)
+ assert f'click' in html(f"# A\n\nSee [click](<{href}>).\n")
+
+
+def test_a_refused_link_is_reported_once_as_a_warning() -> None:
+ body = "# A\n\n[a](ms-msdt:x) and [b](ms-msdt:x) and [c](vscode://x) and [d](http://ok) and [e]().\n"
+ result = render(FRONT + body, format="html")
+ refused = [d for d in result.diagnostics if d.rule == "render-link-refused"]
+ assert [d.level for d in refused] == ["warning", "warning"]
+ assert "Link to 'ms-msdt:x' is not rendered as a link" in refused[0].message
+ assert "its text is shown" in refused[0].message
+ assert "vscode://x" in refused[1].message
+ assert result.ok # a Warning does not refuse, even under --strict's Errors
+ assert render(FRONT + body, format="text").diagnostics == result.diagnostics
+
+
+def test_an_empty_or_allowed_link_is_not_reported() -> None:
+ result = render(FRONT + "# A\n\n[a]() [b](<>) [c](https://e) [d](#x) [e](rel/path)\n")
+ assert not [d for d in result.diagnostics if d.rule == "render-link-refused"]
+
+
+# -- 3, 4, 5: what a diagnostic shows of a document ------------------------------
+
+
+def test_printable_escapes_controls_and_format_characters() -> None:
+ assert printable("a\x1b[2J\x07\n\x7f\x85") == "a\\x1b[2J\\x07\\x0a\\x7f\\x85"
+ assert printable("\u202e\u200b\ufeff\u2028\u2029") == "\\u202e\\u200b\\ufeff\\u2028\\u2029"
+ assert printable("ž é €") == "ž é €"
+
+
+EVIL = "https://e/%1b[2J%1b]0;pwned%07%0aerror: [x] forged%e2%80%ae"
+
+
+def test_a_control_character_in_an_image_path_is_escaped_in_the_message() -> None:
+ result = render(FRONT + f"# A\n\n\n")
+ messages = [d.message for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert messages
+ for message in messages:
+ assert "\x1b" not in message and "\x07" not in message and "\n" not in message
+ assert any("\\x1b[2J\\x1b]0;pwned\\x07\\x0aerror: [x] forged\\u202e" in m for m in messages)
+
+
+def test_a_control_character_in_a_link_is_escaped_in_the_message() -> None:
+ result = render(FRONT + f"# A\n\n[x]()\n")
+ message = next(d.message for d in result.diagnostics if d.rule == "render-link-refused")
+ assert "\x1b" not in message and "\x07" not in message and "\n" not in message and "\u202e" not in message
+ assert "\\x1b" in message
+
+
+def test_the_cli_writes_no_control_character_from_a_document_to_standard_error(tmp_path: Path) -> None:
+ source = tmp_path / "evil.lgd"
+ source.write_text(FRONT + f"# A\n\n [y]()\n", encoding="utf-8")
+ env = dict(os.environ, PYTHONPATH=str(SRC))
+ done = subprocess.run([sys.executable, "-m", "legaldown_render.cli", str(source), "-f", "text"],
+ capture_output=True, env=env, check=False)
+ err = done.stderr.decode("utf-8")
+ assert "path-not-relative" in err and "render-link-refused" in err
+ assert "\x1b" not in err and "\x07" not in err and "\u202e" not in err
+ assert "\r" not in err
+ assert not any(line.startswith("error: [x]") for line in err.splitlines())
+
+
+def test_a_path_that_starts_another_is_not_covered_by_the_validators_report() -> None:
+ # Whatever the validator reports for one path, a different path whose text
+ # it contains is still the renderer's to report.
+ result = render(FRONT + "# A\n\n \n")
+ messages = [d.message for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert any("'/x.png2'" in m for m in messages)
+ assert any("'/x.png'" in m for m in messages)
+
+
+@pytest.mark.parametrize("path", ["javascript:alert(1)", "data:text/html,x", "https://e/p.png"])
+def test_the_not_relative_message_is_generic(path: str) -> None:
+ result = render(FRONT + f"# A\n\n\n")
+ message = next(d.message for d in result.diagnostics if d.rule == "path-not-relative")
+ assert "never fetched" not in message
+ assert "file references are relative paths with no URI scheme. Its alt text is shown instead." in message
+
+
+# -- 8: a link with no text shows the URL as written ----------------------------
+
+
+def test_displayed_url_undoes_the_encoding_unless_it_shows_a_control() -> None:
+ assert displayed_url(" https://e/a%20b ") == "https://e/a b"
+ assert displayed_url("https://e/a%0ab") == "https://e/a%0ab"
+ assert displayed_url("https://e/%E2%80%AE") == "https://e/%E2%80%AE"
+
+
+def test_a_link_with_no_text_shows_the_written_url() -> None:
+ body = "# A\n\nSee []().\n"
+ assert 'href="https://e/a%20b/%C3%A9">https://e/a b/é' in html(body)
+ assert "See https://e/a b/é." in text(body)
diff --git a/tests/test_wp3_links.py b/tests/test_wp3_links.py
index e745292..c212fbe 100644
--- a/tests/test_wp3_links.py
+++ b/tests/test_wp3_links.py
@@ -127,9 +127,9 @@ def validate(document, **kwargs): # type: ignore[no-untyped-def]
@pytest.mark.parametrize("href", [
"ftp://example.com/f", "sip:alice@example.com", "urn:isbn:0451450523", "https://example.com",
- "mailto:a@example.com", "tel:+420123456789", "magnet:?xt=urn:btih:x", "attachments/a.pdf", "../a.pdf", "#top",
+ "mailto:a@example.com", "tel:+420123456789", "attachments/a.pdf", "../a.pdf", "#top",
])
-def test_every_link_but_the_dangerous_ones_is_a_hyperlink(href: str) -> None:
+def test_every_link_of_an_allowed_scheme_is_a_hyperlink(href: str) -> None:
output = html(f"# A\n\nSee [the link](<{href}>).\n")
assert f'the link' in output
@@ -152,12 +152,12 @@ def test_a_refused_link_is_its_text_in_both_writers(href: str) -> None:
assert not is_safe_href(href)
-def test_the_deny_list_is_judged_as_a_browser_reads_the_url() -> None:
+def test_the_allow_list_is_judged_as_a_browser_reads_the_url() -> None:
for href in ("java\tscript:alert(1)", "jav\nascript:x", "\x01javascript:x", " JAVASCRIPT:x", "data:text/html,x",
"VBScript:x", "FILE:///x"):
assert not is_safe_href(href), repr(href)
for href in ("https://example.com", "#section", "attachments/a.pdf", "mailto:a@example.com", "ftp://x/y",
- "sip:a@b", "urn:x:y", "a:b", "x/data:y", "javascript"):
+ "sip:a@b", "urn:x:y", "x/data:y", "javascript", "HTTPS://X", "\thttps://x"):
assert is_safe_href(href), href
From b19901d9c5431d85bdf1a78b0e9525c203f3ca9e Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:10:18 +0000
Subject: [PATCH 021/118] Style: validate templates only where a setting is
used (review wave 2)
- template_settings: a ref is required to contain {n} only where it is read
(numbering unless scheme none; enumeration when enabled or ordered:
enumerate; paragraphs when numbered). Unknown fields are still flagged.
- Plain labels (labels.* but condition and attachment_file) substitute
nothing, so braces in them are text and they are no longer checked.
- tests/test_wp6_style.py: labels.drafting_note/colon with fields are valid;
paragraph format cases set paragraphs.numbered.
---
src/legaldown_render/style/loader.py | 4 ++-
src/legaldown_render/style/model.py | 40 ++++++++++++++++------
tests/test_wp10_review_fixes.py | 51 +++++++++++++++++++++++++++-
tests/test_wp6_style.py | 7 ++--
4 files changed, 86 insertions(+), 16 deletions(-)
diff --git a/src/legaldown_render/style/loader.py b/src/legaldown_render/style/loader.py
index d53f58a..7e472da 100644
--- a/src/legaldown_render/style/loader.py
+++ b/src/legaldown_render/style/loader.py
@@ -312,7 +312,9 @@ def _template_problems(style: Style) -> list[str]:
hide the condition, which §15.8 requires to be shown). An empty template
is exempt from the required fields where the resolver falls back to the
bare field for it (``labels.condition``): it already shows the condition.
- Where there is no such fallback, an empty template lacks its fields."""
+ Where there is no such fallback, an empty template lacks its fields.
+ :func:`~.model.template_settings` lists only the settings that substitute
+ fields, and drops the required fields of one the style does not use."""
problems = []
for key, template, fields in template_settings(style):
found = set(_FIELD_RE.findall(template))
diff --git a/src/legaldown_render/style/model.py b/src/legaldown_render/style/model.py
index 8032faf..971834c 100644
--- a/src/legaldown_render/style/model.py
+++ b/src/legaldown_render/style/model.py
@@ -16,7 +16,7 @@
from __future__ import annotations
from collections.abc import Iterator
-from dataclasses import dataclass, field, fields
+from dataclasses import dataclass, field, fields, replace
from typing import Literal
Counter = Literal["decimal", "lower-alpha", "upper-alpha", "lower-roman", "upper-roman"]
@@ -332,19 +332,39 @@ class TemplateFields:
}
+def _unused(fields: TemplateFields) -> TemplateFields:
+ """*fields* for a setting the style does not use: a typo is still a typo,
+ but nothing is missing from a setting nothing reads."""
+ return replace(fields, required=frozenset(), why="")
+
+
def template_settings(style: Style) -> Iterator[tuple[str, str, TemplateFields]]:
- """Every text template setting of *style* that has been set, as
- ``(key path, template, fields)``. A label left unset (``None``) takes a
- built-in one, which is correct by construction."""
- for name, kind_label, kind_ref in (("numbering", HEADING_LABEL, HEADING_REF),
- ("enumeration", ITEM_LABEL, ITEM_REF)):
+ """Every text template setting of *style* that has been set and substitutes
+ fields, as ``(key path, template, fields)``. A label left unset (``None``)
+ takes a built-in one, which is correct by construction. A plain label
+ (``labels.*`` but ``condition`` and ``attachment_file``) substitutes
+ nothing, so braces in it are text and it is not listed.
+
+ A ``ref`` that is not in use does not have to tell its units apart: the
+ heading levels' under ``numbering.scheme: none`` (a reference shows the
+ heading text, §13.3), the item levels' when lists are not enumerated
+ (``enumeration.enabled: false`` and ``ordered: renumber``), the
+ paragraph's unless ``paragraphs.numbered``. Their fields are still checked
+ for typos."""
+ numbered = style.numbering.scheme != "none"
+ enumerated = style.enumeration.enabled or style.enumeration.ordered == "enumerate"
+ for name, kind_label, kind_ref, in_use in (
+ ("numbering", HEADING_LABEL, HEADING_REF, numbered),
+ ("enumeration", ITEM_LABEL, ITEM_REF, enumerated),
+ ):
for index, level in enumerate(getattr(style, name).levels):
yield f"{name}.levels[{index}].label", level.label, kind_label
- yield f"{name}.levels[{index}].ref", level.ref, kind_ref
+ yield f"{name}.levels[{index}].ref", level.ref, kind_ref if in_use else _unused(kind_ref)
yield "paragraphs.format.label", style.paragraphs.format.label, ITEM_LABEL
- yield "paragraphs.format.ref", style.paragraphs.format.ref, ITEM_REF
+ yield ("paragraphs.format.ref", style.paragraphs.format.ref,
+ ITEM_REF if style.paragraphs.numbered else _unused(ITEM_REF))
yield "references.format", style.references.format, REFERENCE_FORMAT
for item in fields(Labels):
template = getattr(style.labels, item.name)
- if template is not None:
- yield f"labels.{item.name}", template, LABEL_FIELDS.get(item.name, TemplateFields())
+ if template is not None and item.name in LABEL_FIELDS:
+ yield f"labels.{item.name}", template, LABEL_FIELDS[item.name]
diff --git a/tests/test_wp10_review_fixes.py b/tests/test_wp10_review_fixes.py
index e0aca2c..324144d 100644
--- a/tests/test_wp10_review_fixes.py
+++ b/tests/test_wp10_review_fixes.py
@@ -10,7 +10,8 @@
import pytest
-from legaldown_render import render
+from legaldown_render import StyleError, render
+from legaldown_render.style import load_style
from legaldown_render.urls import displayed_url, is_link_target, printable
SRC = Path(__file__).resolve().parents[1] / "src"
@@ -153,3 +154,51 @@ def test_a_link_with_no_text_shows_the_written_url() -> None:
body = "# A\n\nSee []().\n"
assert 'href="https://e/a%20b/%C3%A9">https://e/a b/é' in html(body)
assert "See https://e/a b/é." in text(body)
+
+
+# -- 6: a style is validated where its settings are used ------------------------
+
+def _problems(overrides: dict[str, object]) -> list[str]:
+ with pytest.raises(StyleError) as caught:
+ load_style("default", overrides=overrides)
+ return caught.value.problems
+
+
+NO_N = [{"label": "x", "ref": "x"}]
+
+
+@pytest.mark.parametrize(("overrides", "key"), [
+ ({"enumeration.levels": NO_N}, "enumeration.levels[0].ref"),
+ ({"enumeration.levels": NO_N, "enumeration.enabled": False, "enumeration.ordered": "enumerate"},
+ "enumeration.levels[0].ref"),
+ ({"numbering.levels": NO_N}, "numbering.levels[0].ref"),
+ ({"paragraphs.numbered": True, "paragraphs.format": {"label": "x", "ref": "x"}}, "paragraphs.format.ref"),
+])
+def test_a_ref_that_is_in_use_must_have_its_counter(overrides: dict[str, object], key: str) -> None:
+ assert any(p.startswith(key) and "{n}" in p for p in _problems(overrides))
+
+
+@pytest.mark.parametrize("overrides", [
+ {"enumeration.levels": NO_N, "enumeration.enabled": False},
+ {"numbering.levels": NO_N, "numbering.scheme": "none"},
+ {"paragraphs.format": {"label": "x", "ref": "x"}},
+ {"paragraphs.format": {"label": "x", "ref": ""}},
+])
+def test_a_ref_that_is_not_in_use_may_leave_out_its_counter(overrides: dict[str, object]) -> None:
+ load_style("default", overrides=overrides)
+
+
+def test_a_typo_is_flagged_even_where_the_setting_is_not_used() -> None:
+ problems = _problems({"numbering.scheme": "none", "numbering.levels": [{"label": "{n}", "ref": "{m}"}]})
+ assert any(p.startswith("numbering.levels[0].ref") and "{m}" in p and "unknown field" in p for p in problems)
+
+
+def test_a_plain_label_may_contain_braced_words() -> None:
+ style = load_style("default", overrides={"labels.drafting_note": "Note {see below}", "labels.colon": "{x}:"})
+ assert style.labels.drafting_note == "Note {see below}"
+ assert style.labels.colon == "{x}:"
+
+
+def test_the_labels_that_substitute_still_check_their_fields() -> None:
+ assert _problems({"labels.condition": "{condtion}"})
+ assert _problems({"labels.attachment_file": "{fil}"})
diff --git a/tests/test_wp6_style.py b/tests/test_wp6_style.py
index f0cdfd5..c033687 100644
--- a/tests/test_wp6_style.py
+++ b/tests/test_wp6_style.py
@@ -215,8 +215,6 @@ def test_a_typo_in_a_field_is_named() -> None:
@pytest.mark.parametrize(("setting", "value"), [
("labels.condition", "Only if {condition} {n}"),
("labels.attachment_file", "{condition}"),
- ("labels.drafting_note", "{note}"),
- ("labels.colon", "{n}"),
("references.format", "{n}{designation}"),
("numbering.levels", [{"label": "{n}", "ref": "{path}"}]),
("numbering.levels", [{"label": "{section}", "ref": "{n}"}]),
@@ -227,7 +225,8 @@ def test_a_typo_in_a_field_is_named() -> None:
("paragraphs.format", {"label": "{n}", "ref": "{section}"}),
])
def test_invalid_templates_are_refused(setting: str, value: object) -> None:
- problems = _problems({setting: value})
+ # A paragraph's ref is read only where paragraphs are numbered.
+ problems = _problems({setting: value, "paragraphs.numbered": True})
assert any(p.startswith(setting) for p in problems), problems
@@ -270,7 +269,7 @@ def test_an_empty_template_without_a_fallback_still_lacks_its_field() -> None:
for setting in ("references.format", "numbering.levels", "paragraphs.format"):
value: object = {"label": "{n}", "ref": ""} if setting == "paragraphs.format" else (
[{"ref": ""}] if setting == "numbering.levels" else "")
- assert any(p.startswith(setting) for p in _problems({setting: value}))
+ assert any(p.startswith(setting) for p in _problems({setting: value, "paragraphs.numbered": True}))
def test_unset_labels_are_not_checked() -> None:
From 4e11aba35db8b3b5cffa6c1def78d6c417d6bc24 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:10:43 +0000
Subject: [PATCH 022/118] Placeholders: a blank of whitespace or format
characters is no visual blank (review wave 2)
---
CONFORMANCE.md | 2 +-
docs/style-templates.md | 2 +-
src/legaldown_render/resolve/resolver.py | 8 ++++++--
tests/test_wp10_review_fixes.py | 16 ++++++++++++++++
4 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index aa52e49..66aa38c 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -43,7 +43,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
-| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty or only whitespace renders `[TBD: id]` |
+| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty, or only whitespace or invisible format characters (U+200B, U+FEFF), renders `[TBD: id]` |
| 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 |
diff --git a/docs/style-templates.md b/docs/style-templates.md
index 54177b7..cb1f28b 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -105,7 +105,7 @@ values:
duration: long # long (12 months) | short | narrow
placeholders:
- blank: "[_____]" # empty or whitespace only: renders [TBD: id] (§13.5 rule 6)
+ blank: "[_____]" # empty, or only whitespace/invisible characters: renders [TBD: id] (§13.5 rule 6)
show_prompt: false # show the question's prompt next to the blank (§15.8)
title_block: { parties: true, effective_date: true, version: false }
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 87fa466..189e414 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -23,6 +23,7 @@
"""
from __future__ import annotations
+import unicodedata
from collections.abc import Callable
from dataclasses import dataclass, replace
from typing import Any, NamedTuple
@@ -752,9 +753,12 @@ def _placeholder(self, directive: Directive, placeholder_id: str) -> Inline:
prompt = ""
if self.style.placeholders.show_prompt and isinstance(declared, dict):
prompt = str(declared.get("prompt") or "")
- # §13.5 rule 6: a style whose blank is empty offers no visual blank.
+ # §13.5 rule 6: a style whose blank is empty offers no visual blank,
+ # and neither does one of whitespace or format characters only
+ # (U+200B zero-width space, U+FEFF, bidi controls): it shows nothing.
blank = self.style.placeholders.blank
- return Blank(placeholder_id, blank if blank.strip() else f"[TBD: {placeholder_id}]", prompt)
+ shows = any(not c.isspace() and unicodedata.category(c) != "Cf" for c in blank)
+ return Blank(placeholder_id, blank if shows else f"[TBD: {placeholder_id}]", prompt)
def _attach(self, directive: Directive, attachment_id: str) -> Inline:
title = self.result.index.attachment_lookup.get(attachment_id)
diff --git a/tests/test_wp10_review_fixes.py b/tests/test_wp10_review_fixes.py
index 324144d..2c6ca00 100644
--- a/tests/test_wp10_review_fixes.py
+++ b/tests/test_wp10_review_fixes.py
@@ -202,3 +202,19 @@ def test_a_plain_label_may_contain_braced_words() -> None:
def test_the_labels_that_substitute_still_check_their_fields() -> None:
assert _problems({"labels.condition": "{condtion}"})
assert _problems({"labels.attachment_file": "{fil}"})
+
+
+# -- 7: a blank of invisible characters is no visual blank ------------------------
+
+
+@pytest.mark.parametrize("blank", ["", "", " ", " ", ""])
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_a_blank_of_invisible_characters_falls_back_to_tbd(blank: str, fmt: str) -> None:
+ source = FRONT.replace("title: T", "title: T\nplaceholders:\n - id: fee\n type: text") + "# A\n\nFee {{placeholder: fee}}.\n"
+ result = render(source, format=fmt, overrides={"placeholders.blank": blank})
+ assert "[TBD: fee]" in result.output
+
+
+def test_a_blank_with_a_visible_character_is_kept() -> None:
+ source = FRONT.replace("title: T", "title: T\nplaceholders:\n - id: fee\n type: text") + "# A\n\nFee {{placeholder: fee}}.\n"
+ assert "Fee __." in render(source, format="text", overrides={"placeholders.blank": "__"}).output
From 12879c5c0cc19f86ac878a95472ea0bff6508269 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:11:34 +0000
Subject: [PATCH 023/118] =?UTF-8?q?Labels:=20neutral=20identifier=20labels?=
=?UTF-8?q?,=20since=20identification=5Fnumber=20covers=20natural=20person?=
=?UTF-8?q?s=20(=C2=A73.4)=20(review=20wave=202)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
en Identification No., de Identifikationsnummer, fr Numéro d'identification,
pl Numer identyfikacyjny. Goldens features.* change only that line;
test_wp5_text and test_wp8_labels follow (de added).
---
src/legaldown_render/style/labels.py | 8 ++++----
tests/golden/features.continental.txt | 2 +-
tests/golden/features.default.html | 2 +-
tests/golden/features.default.txt | 2 +-
tests/golden/features.outline.txt | 2 +-
tests/test_wp5_text.py | 2 +-
tests/test_wp8_labels.py | 4 ++--
7 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/legaldown_render/style/labels.py b/src/legaldown_render/style/labels.py
index 62b7f5e..015d5c3 100644
--- a/src/legaldown_render/style/labels.py
+++ b/src/legaldown_render/style/labels.py
@@ -10,7 +10,7 @@
"en": Labels(
drafting_note="Drafting note",
condition="Only if: {condition}",
- identification_number="Registration No.",
+ identification_number="Identification No.",
date_of_birth="Date of birth",
address="Address",
represented_by="Represented by",
@@ -48,7 +48,7 @@
"de": Labels(
drafting_note="Bearbeitungshinweis",
condition="Nur wenn: {condition}",
- identification_number="Registernummer",
+ identification_number="Identifikationsnummer",
date_of_birth="Geburtsdatum",
address="Anschrift",
represented_by="Vertreten durch",
@@ -68,7 +68,7 @@
"fr": Labels(
drafting_note="Note de rédaction",
condition="Uniquement si\u00a0: {condition}",
- identification_number="N° d'immatriculation",
+ identification_number="Numéro d'identification",
date_of_birth="Date de naissance",
address="Adresse",
represented_by="Représenté(e) par",
@@ -87,7 +87,7 @@
"pl": Labels(
drafting_note="Uwaga redakcyjna",
condition="Tylko jeżeli: {condition}",
- identification_number="Nr rejestrowy",
+ identification_number="Numer identyfikacyjny",
date_of_birth="Data urodzenia",
address="Adres",
represented_by="Reprezentacja",
diff --git a/tests/golden/features.continental.txt b/tests/golden/features.continental.txt
index d44aedb..3454d11 100644
--- a/tests/golden/features.continental.txt
+++ b/tests/golden/features.continental.txt
@@ -6,7 +6,7 @@ Effective date: June 1, 2026
Provider
Acme Corporation
- Registration No.: DE-12345678
+ Identification No.: DE-12345678
Address: 123 Main Street, Dover, DE 19901
Represented by: John Smith, Chief Executive Officer
diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html
index a7a45a5..09c78d3 100644
--- a/tests/golden/features.default.html
+++ b/tests/golden/features.default.html
@@ -89,7 +89,7 @@
Services Agreement
Acme Corporation
-
Registration No.
DE-12345678
+
Identification No.
DE-12345678
Address
123 Main Street, Dover, DE 19901
Represented by
John Smith, Chief Executive Officer
diff --git a/tests/golden/features.default.txt b/tests/golden/features.default.txt
index d7c8ab9..5ea8aa9 100644
--- a/tests/golden/features.default.txt
+++ b/tests/golden/features.default.txt
@@ -6,7 +6,7 @@ Effective date: June 1, 2026
Provider
Acme Corporation
- Registration No.: DE-12345678
+ Identification No.: DE-12345678
Address: 123 Main Street, Dover, DE 19901
Represented by: John Smith, Chief Executive Officer
diff --git a/tests/golden/features.outline.txt b/tests/golden/features.outline.txt
index b8f4da2..09536b2 100644
--- a/tests/golden/features.outline.txt
+++ b/tests/golden/features.outline.txt
@@ -6,7 +6,7 @@ Effective date: June 1, 2026
Provider
Acme Corporation
- Registration No.: DE-12345678
+ Identification No.: DE-12345678
Address: 123 Main Street, Dover, DE 19901
Represented by: John Smith, Chief Executive Officer
diff --git a/tests/test_wp5_text.py b/tests/test_wp5_text.py
index 6402e4a..a896a6d 100644
--- a/tests/test_wp5_text.py
+++ b/tests/test_wp5_text.py
@@ -147,7 +147,7 @@ def test_a_blanked_title_block_label_drops_its_prefix() -> None:
def test_a_label_still_prefixes_its_value() -> None:
output = text("# A\n\nBody.\n")
- assert "Registration No.: DE-1" in output
+ assert "Identification No.: DE-1" in output
assert "Effective date: June 1, 2026" in output
diff --git a/tests/test_wp8_labels.py b/tests/test_wp8_labels.py
index cb2a3fd..5cfbce2 100644
--- a/tests/test_wp8_labels.py
+++ b/tests/test_wp8_labels.py
@@ -28,8 +28,8 @@
@pytest.mark.parametrize(("language", "identifier", "represented_by"), [
("cs", "Identifikační číslo", "Zastoupení"),
("sk", "Identifikačné číslo", "Zastúpenie"),
- ("fr", "N° d'immatriculation", "Représenté(e) par"),
- ("pl", "Nr rejestrowy", "Reprezentacja"),
+ ("fr", "Numéro d'identification", "Représenté(e) par"),
+ ("pl", "Numer identyfikacyjny", "Reprezentacja"),
])
def test_party_labels_in_each_language(language: str, identifier: str, represented_by: str) -> None:
colon = BUILTIN_LABELS[language].colon
From cc4d7b7d4613a3d937fde2c5fca41f6a3e26a202 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:12:39 +0000
Subject: [PATCH 024/118] Docs: link allow-list, render-link-refused, escaped
diagnostics, style validation scope, overrides note (review wave 2)
---
CONFORMANCE.md | 5 +++--
README.md | 3 ++-
docs/architecture.md | 30 +++++++++++++++++++++---------
docs/concepts.md | 6 ++++--
docs/style-templates.md | 20 +++++++++++++-------
5 files changed, 43 insertions(+), 21 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 66aa38c..3f95325 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -39,7 +39,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ |
| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ 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 (see "Text after a comment", below) |
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
-| Links (§8.7) | ✅ Every link is a hyperlink in HTML and shows `text ` in plain text, whatever its scheme (`ftp:`, `sip:`, `urn:`, ...), except one whose URL is empty or uses `javascript:`, `vbscript:`, `file:`, or `data:`, which shows its text alone. A link with no text shows its URL; the text writer omits `` for an autolink |
+| Links (§8.7) | ✅ A link is a hyperlink in HTML and shows `text ` in plain text when its URL is relative, a fragment, or has one of the schemes `http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sftp`, `sip`, `sips`, `sms`, `urn`, `geo`, `news` (an allow-list, judged as a browser reads the URL). Any other scheme (`javascript:`, `file:`, `data:`, protocol handlers such as `ms-msdt:`, `vscode:`, `search-ms:`, and pseudo-schemes such as `C:\x` or `localhost:8080`), or an empty URL, is not a link: its text is shown alone, and the Warning `render-link-refused` is reported for a non-empty URL. A link with no text shows its URL as written; the text writer omits `` for an autolink |
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
@@ -53,7 +53,8 @@ It is checked against the specification's own examples and fixtures corpus (`tes
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
+ renderer's own (`render-ref-ambiguous`, `ref-not-enumerated`, `render-not-processed`,
+ `render-link-refused`) do not
yet ([validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)).
- **`path-not-relative` for images.** The renderer reports the Core rule `path-not-relative`
(§2.3, §16.2) for an image path, because the validator (0.4) does not check image paths; it
diff --git a/README.md b/README.md
index ef9fdc4..3e72d7a 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,8 @@ Problems in the document never raise. Only an invalid style (`StyleError`), an u
format (`OptionsError`), an unreadable document (`DocumentError`), strict mode, or a template that
cannot be assembled with its answers (`RenderRefused`) do. All of them are `RenderError`s, and
`StyleError` and `OptionsError` are also `ValueError`s. They can be pickled, so they cross process
-boundaries (`multiprocessing`, `concurrent.futures`) intact.
+boundaries (`multiprocessing`, `concurrent.futures`) intact. `overrides=` takes dotted settings and
+may not contain `version` or `extends`, so `overrides=style_to_dict(style)` must drop those two keys.
## Styles and preferences
diff --git a/docs/architecture.md b/docs/architecture.md
index 492a95f..4799b8c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -239,7 +239,7 @@ turns into an error.
Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate:
`ref-not-enumerated` and `path-not-relative` (an Error for an image path that is not relative, §2.3,
reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
-`render-not-processed`, `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), and `render-ref-ambiguous`. The validator's
+`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), 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: they are about a directive within a block, and the validator makes only a block's
line public (`Document.line_of`, U2).
@@ -248,15 +248,27 @@ line public (`Document.line_of`, U2).
- Raw HTML in the source is never emitted (§8.7).
- Every text node and attribute is escaped.
-- A link is a hyperlink (§8.7) unless its URL is empty or its scheme is `javascript`, `vbscript`,
- `file`, or `data` (a deny-list, `urls.DENIED_SCHEMES`, judged as a browser reads the URL: tabs,
- newlines, controls, and case are ignored). Every other scheme (`ftp:`, `sip:`, `urn:`, ...) is
- linked. A link that is not made renders as its text alone, in HTML and in text alike. The
- builder turns off markdown-it-py's own `validateLink` (which would leave such links as literal
- Markdown) and keeps them as nodes, so that the resolver can report §2.3 paths and the writers'
- deny-list is the one gate for every `href` and `src` the HTML writer emits (links, images,
- value links). A link with no text shows its URL; the text writer leaves out ` ` when the URL, less a
+- A link is a hyperlink (§8.7) only if its URL is relative (no scheme, a fragment) or its scheme
+ is one of `urls.LINK_SCHEMES`: `http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sftp`, `sip`,
+ `sips`, `sms`, `urn`, `geo`, `news`. It is an allow-list, because a renderer reads documents it
+ did not write (principle 6 in concepts.md): protocol handlers (`ms-msdt:`, `ms-word:`,
+ `search-ms:`, `vscode:`, `intent:`, `blob:`, `jar:`, ...) and pseudo-schemes (`C:\x`,
+ `localhost:8080`) are not links, nor are `javascript:`, `file:`, `data:`. The scheme is read as
+ a browser reads the URL (tabs, newlines, controls, and case are ignored). One predicate,
+ `urls.is_link_target`, serves both writers and the resolver. A link that is not made renders as
+ its text alone, in HTML and in text alike, and the resolver reports a non-empty one as the
+ Warning `render-link-refused` (the writers emit no diagnostics, ADR 0003); an empty URL is
+ text with no diagnostic. The builder turns off markdown-it-py's own `validateLink` (which would
+ leave such links as literal Markdown) and keeps them as nodes, so that the resolver can report
+ §2.3 paths and the allow-list is the one gate for every `href` and `src` the HTML writer emits
+ (links, images, value links). A link with no text shows its URL as written (percent-encoding
+ undone; the `href` stays encoded); the text writer leaves out ` ` when the URL, less a
`mailto:` or `tel:` prefix, is the text.
+- A diagnostic never carries a raw control character from the document: a path or URL quoted in a
+ message (`path-not-relative`, `render-link-refused`) goes through `urls.printable`, which writes
+ every C0/C1 control, DEL, Unicode format character (bidi controls, zero-width), and U+2028/9 as
+ `\xNN` or `\uNNNN`, so a crafted URL cannot move the cursor, set a terminal title, or forge a
+ line on stderr.
- An image path must be relative with no URI scheme (§2.3), so a remote resource is never fetched
or embedded. Resolution, not a writer, enforces it: an image whose path has a scheme (`https:`,
`file:`, `data:`, a Windows drive such as `C:\x`) or starts with `/` or `\` (`//host`) is
diff --git a/docs/concepts.md b/docs/concepts.md
index e5d895a..2d66b40 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -83,8 +83,10 @@ no random ids, stable anchor names, stable ordering. This makes golden-file test
lets rendered output be diffed in Git just like the source.
**6. It is safe with documents you did not write.** Raw HTML in the source is never emitted
-(§8.7). All text is escaped for the target format. A link whose URL is empty or uses a script,
-`file:`, or `data:` scheme is shown as its text, and an image whose path is not relative is shown as
+(§8.7). All text is escaped for the target format. A link is made only for a relative URL or
+one of a short list of schemes (`http`, `https`, `mailto`, `tel`, `ftp`, ...); any other
+(`javascript:`, `file:`, `data:`, protocol handlers such as `ms-msdt:`) is shown as its text and
+reported, and an image whose path is not relative is shown as
its alt text and reported (§2.3). At Rendering level the renderer reads no files other than the document, the chosen
style template, and the answers file the CLI is given with `--answers`.
diff --git a/docs/style-templates.md b/docs/style-templates.md
index cb1f28b..780cda3 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -161,21 +161,27 @@ are set off by a space ("Termination (a)", §13.3).
### Text templates and their fields
-A text setting that holds `{field}` names is checked when the style is loaded. A field is a name
-in braces that is an identifier (`{n}`, `{designation}`); any other braces (`{}`, `{1}`, `{a b}`)
-are literal text. An identifier the setting does not accept is an error naming the setting (a
-typo would otherwise be shown literally), and so is a missing required field.
+A text setting that substitutes `{field}` names is checked when the style is loaded. A field is a
+name in braces that is an identifier (`{n}`, `{designation}`); any other braces (`{}`, `{1}`,
+`{a b}`) are literal text. An identifier the setting does not accept is an error naming the
+setting (a typo would otherwise be shown literally), and so is a missing required field, but only
+where the setting is in use: the `ref` of `numbering.levels` is required to hold `{n}` unless
+`numbering.scheme` is `none` (a reference then shows the heading text); the `ref` of
+`enumeration.levels` only when lists are enumerated (`enumeration.enabled: true`, or `ordered:
+enumerate`); `paragraphs.format.ref` only when `paragraphs.numbered` is true. A typo in a field is
+flagged either way. Plain labels substitute nothing, so they are not checked: `{word}` in one is
+just text.
| Setting | Accepted fields | Required |
|---|---|---|
| `numbering.levels[].label` | `{n}`, `{path}` | none |
-| `numbering.levels[].ref` | `{n}` | `{n}` |
+| `numbering.levels[].ref` | `{n}` | `{n}`, unless `numbering.scheme: none` |
| `enumeration.levels[].label`, `paragraphs.format.label` | `{n}`, `{path}`, `{section}` | none |
-| `enumeration.levels[].ref`, `paragraphs.format.ref` | `{n}`, `{section}` | `{n}` |
+| `enumeration.levels[].ref`, `paragraphs.format.ref` | `{n}`, `{section}` | `{n}`, when lists are enumerated / paragraphs are numbered |
| `references.format` | `{designation}` | `{designation}`, or every reference reads the same |
| `labels.condition` | `{condition}` | `{condition}`, as §15.8 requires the condition to be shown; an empty value is allowed and shows the bare condition |
| `labels.attachment_file` | `{file}` | none |
-| every other label | none | none |
+| every other label | not checked: braces are text | none |
```
$ legaldown-render contract.lgd --set references.format=nope
From 6652ab6afb7f61ca560bea66413d67312cc90b42 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:19:08 +0000
Subject: [PATCH 025/118] Wave 3 review fixes: plural of huge fractions, text
list items, -o extension, term cache in cycles
1. Plural fallback for huge fractions (values.py _plural_category, Formatter._money_name):
when Babel's plural operands fail (4300-digit limit), the category is computed from
the value with its fraction cut to the first 30 digits (integer part exact, v != 0);
only if that fails is it "other". The old comment ("other is the non-integer category
in nearly every locale") was false for cs, sk, lt (many). Name-mode money does the same.
2. Text writer (TextWriter.list): an item whose first block is not a paragraph (table,
code block, quote, as for a nested list) gets its marker on its own line and the block
indented under the item text, instead of being flattened onto the label line. Paragraph-first
items and all goldens are unchanged.
3. -o extension rule (cli.py, writers/__init__.py): new file_extension() strips trailing dots
and spaces and takes the text after the last dot even in a dotfile name, so ".pdf", ".md",
"x.pdf." and "x.docx " are refused without -f; format_for_path uses it too (".txt",
"x.txt." write text). No extension / unknown extensions still write HTML; -f still wins.
4. Term text caching (resolver.py _term_text): a text worked out while another term's text is
open depends on which term was open, so only the outermost text is cached.
Existing tests changed because they pinned the old behaviour:
- tests/test_wp9_review_fixes.py: a 4400-digit fraction in Czech years is now "roku" (many), not " let".
- tests/test_render.py test_an_item_opening_with_a_drafting_note_or_code: the label now stands alone
and the quote/code is indented under the item text (was "(b) code" and "(a) > [Drafting note]").
Docs: README (-o rule), CONFORMANCE.md (term cycles, text list layout), docs/architecture.md (plural).
---
CONFORMANCE.md | 6 +-
README.md | 6 +-
docs/architecture.md | 2 +-
src/legaldown_render/cli.py | 15 +-
src/legaldown_render/resolve/resolver.py | 10 +-
src/legaldown_render/resolve/values.py | 58 ++++++--
src/legaldown_render/writers/__init__.py | 16 ++-
src/legaldown_render/writers/text.py | 15 +-
tests/test_render.py | 6 +-
tests/test_wp11_review_fixes.py | 166 +++++++++++++++++++++++
tests/test_wp9_review_fixes.py | 6 +-
11 files changed, 266 insertions(+), 40 deletions(-)
create mode 100644 tests/test_wp11_review_fixes.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 3f95325..fc158d7 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -41,7 +41,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
| Links (§8.7) | ✅ A link is a hyperlink in HTML and shows `text ` in plain text when its URL is relative, a fragment, or has one of the schemes `http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sftp`, `sip`, `sips`, `sms`, `urn`, `geo`, `news` (an allow-list, judged as a browser reads the URL). Any other scheme (`javascript:`, `file:`, `data:`, protocol handlers such as `ms-msdt:`, `vscode:`, `search-ms:`, and pseudo-schemes such as `C:\x` or `localhost:8080`), or an empty URL, is not a link: its text is shown alone, and the Warning `render-link-refused` is reported for a non-empty URL. A link with no text shows its URL as written; the text writer omits `` for an autolink |
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
-| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
+| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label; an item that opens with a nested list, table, code block or quote has its label on a line of its own, so that block stays valid. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty, or only whitespace or invisible format characters (U+200B, U+FEFF), renders `[TBD: id]` |
| Question prompts next to blanks (§15.8, MAY) | ✅ `placeholders.show_prompt` |
@@ -97,7 +97,9 @@ It is checked against the specification's own examples and fixtures corpus (`tes
source; `label=` overrides it. A definition not in the body (a template's other alternative)
falls back to the validator's term text.
A term that refers to itself, directly or through other terms (`"A {{term: a}}" {{def: a}}`), is
- rendered without looping: the inner reference shows its `label=`, else the definition id. The
+ rendered without looping: the inner reference shows its `label=`, else the definition id. A term text
+ worked out while another term's text is open depends on which one is open, so only a text worked out
+ at the outermost level is kept and reused. The
validator (0.4.0) does not report these cycles; the specification's `definition-circular`
Warning (§16) is not emitted yet.
- **Ambiguous references.** Each list starts again at its first number, as contracts are usually
diff --git a/README.md b/README.md
index 3e72d7a..3fa4b17 100644
--- a/README.md
+++ b/README.md
@@ -82,8 +82,10 @@ from Python leaves `sys.stdout` and `sys.stderr` as they are.
Without `--format`, `-o` takes the format from the file extension: `.html` and `.htm` write HTML,
`.txt` and `.text` plain text. The extension of a document format this renderer does not write
(`.docx`, `.doc`, `.odt`, `.rtf`, `.pdf`, `.md`, `.markdown`, `.epub`, `.tex`, in any case) is a usage
-error (exit 2) unless `--format` says what to write. Any other name, with any other extension or
-none (`contract`, `contract.v2`, `report.2025-10-27`, `.out`), is just a name and writes HTML.
+error (exit 2) unless `--format` says what to write. The extension is the text after the last dot,
+also in a name that starts with one (`.pdf`), and trailing dots and spaces are ignored (`x.pdf.`,
+`"x.docx "`). Any other name, with any other extension or none (`contract`, `contract.v2`,
+`report.2025-10-27`, `.out`), is just a name and writes HTML.
## Python
diff --git a/docs/architecture.md b/docs/architecture.md
index 4799b8c..4dd47df 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -144,7 +144,7 @@ Guessing at lost structure was tried, and it traded each gap for new bugs.
| `{{ref:}}` | `CrossRef(designation, anchor)` (§13.3). A ref to an unnumbered item or paragraph falls back to its section's designation, with `ref-not-enumerated` | `[BROKEN REF: id]` |
| `{{term:}}` | `TermRef(label or the definition's rendered term text, def anchor)` (§7.3, §13.4); the text is the first definition's term resolved as at the definition, worked out on first use by `Resolver._term_text` (`_survey` only records which definition is first) | `[UNDEFINED: id]` |
| `"Term" {{def:}}` | `DefinedTerm` without quotation marks, anchored at `def:` (§7.2) | n/a |
-| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005): amounts and durations keep every written digit, and a duration without a pattern in the locale falls back to another length, the language alone, then English with `render-locale-fallback` | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` |
+| `{{date:}}` `{{money:}}` `{{duration:}}` | `Value`, formatted for the locale by Babel (ADR 0005): amounts and durations keep every written digit (a fraction beyond Python's 4300-digit limit takes its plural form from its first 30 digits), and a duration without a pattern in the locale falls back to another length, the language alone, then English with `render-locale-fallback` | `[INVALID DATE: …]`, `[INVALID AMOUNT: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION: …]`, `[INVALID DURATION UNIT: …]` |
| `{{party:}}` `{{side:}}` | `Value(label or display name)` (§3.6) | `[INVALID PARTY: …]`, `[UNKNOWN PARTY: …]`, and the side forms |
| `{{field:}}` | `Value(raw value)` | `[INVALID FIELD: …]` |
| `{{placeholder:}}` | `Blank`, with the question's prompt if the style asks for it | `[INVALID PLACEHOLDER: …]`, `[UNKNOWN CURRENCY: …]`, `[INVALID DURATION UNIT: …]` |
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index a7be21f..e0cdea8 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -24,13 +24,14 @@
from .api import RenderOptions, render
from .errors import DocumentError, InternalError, RenderRefused, StyleError
from .style import builtin_styles, dump_style, load_style, parse_override
-from .writers import FORMATS, format_for_path
+from .writers import FORMATS, file_extension, format_for_path
-#: Extensions of document formats this renderer does not write. An output file
-#: named so (``x.docx``) and no ``--format`` is a mistake to refuse: HTML under
-#: that name would mislead. Any other extension (``contract.v2``,
-#: ``report.2025-10-27``), or none, is just a name and keeps the HTML default.
-_UNWRITTEN_EXTENSIONS = frozenset({".docx", ".doc", ".odt", ".rtf", ".pdf", ".md", ".markdown", ".epub", ".tex"})
+#: Extensions (:func:`file_extension`, so no dot) of document formats this
+#: renderer does not write. An output file named so (``x.docx``, ``.pdf``,
+#: ``x.pdf.``) and no ``--format`` is a mistake to refuse: HTML under that name
+#: would mislead. Any other extension (``contract.v2``, ``report.2025-10-27``),
+#: or none, is just a name and keeps the HTML default.
+_UNWRITTEN_EXTENSIONS = frozenset({"docx", "doc", "odt", "rtf", "pdf", "md", "markdown", "epub", "tex"})
def _setting(text: str) -> tuple[str, Any]:
@@ -142,7 +143,7 @@ def main(argv: list[str] | None = None) -> int:
if not output_format and args.output:
file_name = Path(args.output).name
output_format = format_for_path(file_name)
- if not output_format and Path(file_name).suffix.lower() in _UNWRITTEN_EXTENSIONS:
+ if not output_format and file_extension(file_name) in _UNWRITTEN_EXTENSIONS:
parser.error(f"cannot tell the output format from '{args.output}'; use --format html or text")
output_format = output_format or "html"
name = "" if args.input == "-" else args.input
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 189e414..83af663 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -642,7 +642,11 @@ def _term_text(self, definition_id: str) -> str:
definition is not in the body.
Worked out the first time it is needed and kept, which is once every
- target is registered and the placeholder types are known. Before that
+ target is registered and the placeholder types are known. Only a text
+ worked out at the outermost level is kept: one worked out while another
+ term's text is being worked out has that term open, so a reference back
+ to it shows its label or id there, and the same term resolved alone
+ would read differently (a cycle). Before that
(a heading read as text by the ``none`` scheme, §13.3) a text-only pass
stands in, which leaves references out and is not kept. Resolving the
term gives out no anchor: a definition inside a term keeps its place in
@@ -655,6 +659,7 @@ def _term_text(self, definition_id: str) -> str:
if definition_id in self.term_texts:
return self.term_texts[definition_id]
self._terms_open.append(definition_id)
+ outermost = len(self._terms_open) == 1
try:
if not self._targets_known:
return self._title_text(source.children)
@@ -665,7 +670,8 @@ def _term_text(self, definition_id: str) -> str:
self.used_anchors = anchors
finally:
self._terms_open.pop()
- self.term_texts[definition_id] = text
+ if outermost:
+ self.term_texts[definition_id] = text
return text
def _plain_open(self, definition_id: str, term: str) -> str:
diff --git a/src/legaldown_render/resolve/values.py b/src/legaldown_render/resolve/values.py
index ba94c13..52be250 100644
--- a/src/legaldown_render/resolve/values.py
+++ b/src/legaldown_render/resolve/values.py
@@ -107,20 +107,44 @@ def _unit_patterns(locale: Locale, unit_id: str, length: str) -> dict[str, str]:
return {category: pattern for category, pattern in patterns.items() if category != "per"}
+_PLURAL_FRACTION_DIGITS = 30
+"""Fraction digits kept when the plural category of a value with a huge
+fraction is computed from a shortened copy (see :func:`_plural_category`)."""
+
+
+def _truncated_fraction(value: Decimal) -> Decimal:
+ """*value* with its fraction cut to its first :data:`_PLURAL_FRACTION_DIGITS`
+ digits (cut, not rounded), so the integer part stays exact and a value that
+ had a fraction still has one (CLDR's operand ``v`` stays non-zero). The
+ digits are rearranged, not computed with, so no precision limit applies."""
+ sign, digits, exponent = value.as_tuple()
+ if not isinstance(exponent, int) or exponent >= -_PLURAL_FRACTION_DIGITS:
+ return value
+ keep = len(digits) + exponent + _PLURAL_FRACTION_DIGITS
+ digits = digits[:keep] if keep > 0 else (0,)
+ return Decimal((sign, digits, -_PLURAL_FRACTION_DIGITS))
+
+
def _plural_category(locale: Locale, value: Decimal) -> str:
"""The CLDR plural category of *value* in *locale*.
CLDR's plural operands of a value are its integer and fraction digits read
as integers, and Python refuses to convert a string of more than 4300 digits
- to an int (``ValueError``). Such a value is a non-integer or a very long
- integer whose category cannot be computed under that limit, and ``other`` is
- the category CLDR gives non-integers in nearly every locale, so ``other`` it
- is. The limit is not raised: ``sys.set_int_max_str_digits`` is process-wide.
+ to an int (``ValueError``). For a value whose fraction is that long the
+ category is computed from the same value with its fraction cut to its first
+ 30 digits: that keeps the integer part and the fact that there is a
+ fraction, which is what the category depends on in practice ("1,333..." is
+ ``many`` in Czech, Slovak and Lithuanian, not ``other``). A value whose
+ integer part is itself too long has no category that can be computed, and
+ ``other`` it is. The limit is not raised: ``sys.set_int_max_str_digits`` is
+ process-wide.
"""
- try:
- return locale.plural_form(value)
- except ValueError:
- return "other"
+ for candidate in (value, _truncated_fraction(value)):
+ try:
+ return locale.plural_form(candidate)
+ except ValueError:
+ continue
+ return "other"
def _duration_pattern(locale: Locale, unit_id: str, length: str, value: Decimal) -> tuple[str, bool]:
@@ -216,17 +240,23 @@ def _money(self, amount: str, currency: str) -> str:
def _money_name(self, value: Decimal, currency: str, pattern: NumberPattern) -> str:
"""*value* with the currency's name ("1,234.50 euros"), through Babel
- unless the plural category of *value* cannot be computed (see
- :func:`_plural_category`): Babel's own lookup of the unit pattern would
- then fail, so the ``other`` pattern and name are put together here, the
- way Babel does it (``_format_currency_long_name``)."""
+ unless the plural category of *value* cannot be computed from its own
+ digits (see :func:`_plural_category`): Babel's own lookup of the unit
+ pattern would then fail, so the pattern and name are put together here,
+ the way Babel does it (``_format_currency_long_name``), for the
+ category of the value with its fraction shortened (``other`` when even
+ that fails)."""
try:
self.locale.plural_form(value)
except ValueError:
number = pattern.apply(value, self.locale, currency=currency, currency_digits=False,
decimal_quantization=False)
- unit_pattern = get_currency_unit_pattern(currency, locale=self.locale)
- return unit_pattern.format(number, get_currency_name(currency, count=value, locale=self.locale))
+ shortened = _truncated_fraction(value)
+ try:
+ unit_pattern = get_currency_unit_pattern(currency, count=shortened, locale=self.locale)
+ except ValueError:
+ unit_pattern = get_currency_unit_pattern(currency, locale=self.locale)
+ return unit_pattern.format(number, get_currency_name(currency, count=shortened, locale=self.locale))
return format_currency(value, currency, format=pattern, locale=self.locale, currency_digits=False,
format_type="name", decimal_quantization=False)
diff --git a/src/legaldown_render/writers/__init__.py b/src/legaldown_render/writers/__init__.py
index a3d25b0..cf77336 100644
--- a/src/legaldown_render/writers/__init__.py
+++ b/src/legaldown_render/writers/__init__.py
@@ -12,10 +12,22 @@
}
+def file_extension(name: str) -> str:
+ """The lower-cased extension of a file name, without the dot, or ``""``.
+
+ It is the text after the last dot, also in a name that starts with one
+ (``.pdf`` is a PDF name here, not a name without extension), and trailing
+ dots and spaces are not part of the name (Windows drops them, so
+ ``x.pdf.`` and ``x.docx `` name the same files as ``x.pdf`` and ``x.docx``).
+ """
+ name = name.rstrip(". ")
+ return name.rsplit(".", 1)[-1].lower() if "." in name else ""
+
+
def format_for_path(path: str) -> str | None:
"""The output format a file name implies, or None."""
- extension = path.rsplit(".", 1)[-1].lower() if "." in path else ""
+ extension = file_extension(path)
return next((name for name, extensions in FORMATS.items() if extension in extensions), None)
-__all__ = ["FORMATS", "HtmlWriter", "TextWriter", "format_for_path", "stylesheet"]
+__all__ = ["FORMATS", "HtmlWriter", "TextWriter", "file_extension", "format_for_path", "stylesheet"]
diff --git a/src/legaldown_render/writers/text.py b/src/legaldown_render/writers/text.py
index aa6f328..449ba0a 100644
--- a/src/legaldown_render/writers/text.py
+++ b/src/legaldown_render/writers/text.py
@@ -188,7 +188,9 @@ def list(self, block: List, indent: str) -> list[str]:
starts one column after the item's own label (or bullet): a condition
label (§15.8) does not move it. Blocks of an item are set apart by a
blank line, except that a nested list stays tight under the paragraph
- or list it follows."""
+ or list it follows. An item whose first block is not a paragraph (a
+ nested list, table, code block or quote) has its label on a line of its
+ own, so the block starts at a line start and stays what it is."""
lines: list[str] = []
for item in block.items:
label = item.label if item.label else "-"
@@ -198,13 +200,14 @@ def list(self, block: List, indent: str) -> list[str]:
for child in item.blocks:
if previous is not None and not (isinstance(child, List) and isinstance(previous, Paragraph | List)):
lines.append("")
+ if previous is None and not isinstance(child, Paragraph):
+ # The item opens with a block that must start a line
+ # (a nested list, a table, code, a quote): its own label
+ # still comes first, on a line of its own.
+ lines.append(indent + marker)
if isinstance(child, List):
- if previous is None:
- # The item opens with a nested list: its own label
- # still comes first, on a line of its own.
- lines.append(indent + marker)
lines += self.list(child, content)
- elif previous is None:
+ elif previous is None and isinstance(child, Paragraph):
lines.append(_prefix(f"{marker} {self.block(child)}", indent, hanging=" " * (len(label) + 1)))
else:
lines.append(_prefix(self.block(child), content))
diff --git a/tests/test_render.py b/tests/test_render.py
index d432e3a..93c507d 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -1113,8 +1113,10 @@ def test_a_marker_copy_inside_a_comment_is_not_the_marker() -> None:
def test_an_item_opening_with_a_drafting_note_or_code() -> None:
output = text("# A\n\n- > [!DRAFTING]\n > note here\n- ```\n code\n ```\n")
- assert "(a) > [Drafting note]\n > note here" in output
- assert "(b) code" in output
+ # The label stands alone and the block is indented under the item text (WP11): flattened
+ # onto the label line, a quote is no quote and code is mis-indented.
+ assert "(a)\n > [Drafting note]\n > note here" in output
+ assert "(b)\n code" in output
assert "```" not in output
diff --git a/tests/test_wp11_review_fixes.py b/tests/test_wp11_review_fixes.py
new file mode 100644
index 0000000..e402ca5
--- /dev/null
+++ b/tests/test_wp11_review_fixes.py
@@ -0,0 +1,166 @@
+"""Review fixes after wave 3 (WP11): the plural of a huge fraction is computed
+from its first digits, a list item opening with a table, code or quote keeps
+that block intact in plain text, the ``-o`` extension rule has no holes, and a
+term text worked out inside a cycle is not kept."""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from babel import Locale
+
+from legaldown_render import render
+from legaldown_render.resolve.values import Formatter
+from legaldown_render.style.model import Values
+from legaldown_render.writers import file_extension, format_for_path
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+FRONT = "---\ntitle: T\n---\n\n"
+FEATURES = str(Path(__file__).resolve().parent / "documents" / "features.lgd")
+
+
+def text(body: str, **settings: object) -> str:
+ return render(FRONT + body, format="text", **settings).output
+
+
+# -- 1: the plural category of a huge fraction -----------------------------------
+
+
+def _formatter(locale: str, **values: str) -> Formatter:
+ return Formatter(Locale.parse(locale), Values(**values))
+
+
+@pytest.mark.parametrize("unit", ["D", "Y", "W"])
+@pytest.mark.parametrize("locale", ["cs", "sk", "lt", "pl", "ru", "en"])
+def test_a_huge_fraction_has_the_unit_form_of_its_first_digits(locale: str, unit: str) -> None:
+ formatter = _formatter(locale)
+ huge = formatter.format_duration("1." + "3" * 4400, unit).text
+ short = formatter.format_duration("1." + "3" * 40, unit).text
+ assert huge.replace("3" * 4400, "") == short.replace("3" * 40, "")
+
+
+def test_a_huge_fraction_is_many_in_czech() -> None:
+ formatter = _formatter("cs")
+ assert formatter.format_duration("1." + "3" * 4400, "D").text.endswith(" dne")
+ assert formatter.format_duration("1." + "3" * 40, "D").text.endswith(" dne")
+
+
+def test_a_huge_fraction_of_zeros_ending_in_a_digit_keeps_its_fraction() -> None:
+ # Cut to 30 digits it is "1.000...0": still a fraction (v != 0), so "many" in Czech.
+ value = "1." + "0" * 4399 + "1"
+ assert _formatter("cs").format_duration(value, "D").text.rsplit(" ", 1)[-1] == "dne"
+
+
+def test_a_name_mode_money_amount_with_a_huge_fraction_uses_the_form_of_its_first_digits() -> None:
+ formatter = _formatter("cs", money="name")
+ huge = formatter.money("1." + "3" * 4400, "EUR")
+ short = formatter.money("1." + "3" * 40, "EUR")
+ assert huge.replace("3" * 4400, "") == short.replace("3" * 40, "")
+ assert huge.endswith("eura") # "many", not the "other" form "eur"
+
+
+def test_an_integer_part_beyond_the_limit_falls_back_to_other() -> None:
+ # No category can be computed (not even from the first digits): "other" ("dnů" in Czech).
+ other = _formatter("cs").format_duration("1.5", "D").text.rsplit(" ", 1)[-1]
+ assert other == "dne"
+ assert _formatter("cs").format_duration("1" * 4400, "D").text.endswith(" dnů")
+
+
+# -- 2: an item that opens with a table, code or a quote -------------------------
+
+
+def test_an_item_that_opens_with_a_table_keeps_a_valid_table() -> None:
+ out = text("# A\n\n1. | x | y |\n |---|---|\n | 1 | 2 |\n")
+ assert "\n1.\n | x | y |\n |---|---|\n | 1 | 2 |\n" in out
+ assert "1. |" not in out
+
+
+def test_an_item_that_opens_with_code_indents_it_under_the_item_text() -> None:
+ out = text("# A\n\n1. ```\n code\n more\n ```\n2. next\n")
+ assert "\n1.\n code\n more\n2. next\n" in out
+
+
+def test_an_item_that_opens_with_a_quote_puts_the_quote_on_its_own_lines() -> None:
+ out = text("# A\n\n1. > one\n >\n > two\n2. next\n")
+ assert "\n1.\n > one\n >\n > two\n2. next\n" in out
+ assert "1. >" not in out
+
+
+def test_blocks_after_a_non_paragraph_first_block_are_set_apart() -> None:
+ out = text("# A\n\n1. | x |\n |---|\n | 1 |\n\n then text\n")
+ assert "\n1.\n | x |\n |---|\n | 1 |\n\n then text\n" in out
+
+
+def test_an_item_that_opens_with_a_paragraph_is_unchanged() -> None:
+ out = text("# A\n\n1. first\n > quote\n2. second\n | x |\n |---|\n | 1 |\n")
+ assert "\n1. first\n\n > quote\n2. second\n\n | x |\n |---|\n | 1 |\n" in out
+
+
+# -- 3: the extension rule of -o --------------------------------------------------
+
+
+@pytest.mark.parametrize(("name", "extension"), [
+ ("x.pdf", "pdf"), (".pdf", "pdf"), (".md", "md"), ("x.pdf.", "pdf"), ("x.docx ", "docx"),
+ ("x.pdf. .", "pdf"), ("X.PDF", "pdf"), ("a.b.txt", "txt"), ("contract", ""), ("", ""), (".", ""),
+ ("..", ""), ("x.", ""),
+])
+def test_the_extension_of_a_file_name(name: str, extension: str) -> None:
+ assert file_extension(name) == extension
+
+
+@pytest.mark.parametrize(("name", "expected"), [
+ (".txt", "text"), ("x.txt.", "text"), ("x.html ", "html"), (".htm", "html"), ("x.unknown", None),
+])
+def test_the_format_a_file_name_implies(name: str, expected: str | None) -> None:
+ assert format_for_path(name) == expected
+
+
+def _run(*arguments: str, cwd: Path) -> subprocess.CompletedProcess[str]:
+ env = {**os.environ, "PYTHONPATH": str(SRC)}
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *arguments],
+ capture_output=True, text=True, cwd=cwd, env=env, check=False)
+
+
+@pytest.mark.parametrize("name", [".pdf", ".md", "x.pdf.", "x.docx ", "x.docx. .", "dir.pdf", "x.MD"])
+def test_an_unwritten_extension_is_refused_however_it_is_spelled(tmp_path: Path, name: str) -> None:
+ result = _run(FEATURES, "-o", name, "-q", cwd=tmp_path)
+ assert result.returncode == 2, result.stderr
+ assert "cannot tell the output format" in result.stderr
+ assert list(tmp_path.iterdir()) == []
+
+
+@pytest.mark.parametrize("name", ["x.pdf.", ".md", "x.docx "])
+def test_format_overrides_an_unwritten_extension(tmp_path: Path, name: str) -> None:
+ result = _run(FEATURES, "-o", name, "-f", "text", "-q", cwd=tmp_path)
+ assert result.returncode == 0, result.stderr
+ assert not (tmp_path / name).read_text(encoding="utf-8").lstrip().startswith("<")
+
+
+@pytest.mark.parametrize("name", ["contract", "contract.v2", "report.2025-10-27", ".out", "x.", "x.."])
+def test_a_name_without_a_known_extension_still_writes_html(tmp_path: Path, name: str) -> None:
+ result = _run(FEATURES, "-o", name, "-q", cwd=tmp_path)
+ assert result.returncode == 0, result.stderr
+ assert " None:
+ result = _run(FEATURES, "-o", name, "-q", cwd=tmp_path)
+ assert result.returncode == 0, result.stderr
+ assert marker not in (tmp_path / name).read_text(encoding="utf-8").lower()
+
+
+# -- 4: a term text worked out inside a cycle is not kept ------------------------
+
+CYCLE = ('# A\n\nUsed first: {{term: ca}} and {{term: cb}}.\n\n'
+ '"Alpha {{term: cb}}" {{def: ca}}\n\n"Beta {{term: ca}}" {{def: cb}}\n')
+
+
+def test_a_term_reference_reads_as_its_definition_in_a_cycle() -> None:
+ lines = text(CYCLE).splitlines()
+ used = next(line for line in lines if line.startswith("Used first:"))
+ site = next(line for line in lines if line.startswith("Beta "))
+ assert used.endswith(f"and {site}.")
diff --git a/tests/test_wp9_review_fixes.py b/tests/test_wp9_review_fixes.py
index 3d9b5a3..1a01418 100644
--- a/tests/test_wp9_review_fixes.py
+++ b/tests/test_wp9_review_fixes.py
@@ -114,9 +114,11 @@ def test_a_money_amount_in_name_mode_with_a_4400_digit_fraction_renders() -> Non
assert f"1.{LONG} euros" in output
-def test_a_4400_digit_fraction_uses_the_other_category_in_czech() -> None:
+def test_a_4400_digit_fraction_uses_the_many_category_in_czech() -> None:
+ # Wave 3 (WP11): the category comes from the first 30 fraction digits, so it is the
+ # "many" of "2,5 roku", not "other" (" let").
output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=Y}}}}", format="text", locale="cs-CZ").output
- assert output.rstrip().endswith(" let") # "other", not the "many" of "2,5 roku"
+ assert output.rstrip().endswith(" roku")
# -- 4: only the program reconfigures the streams -------------------------------
From a89a0b12dc2531f004f63cd0026bd1dd3e43327e Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:32:05 +0000
Subject: [PATCH 026/118] Term texts: memoize per term in every pass, so
branching chains are linear (review wave 3)
The previous commit kept a term text only when worked out at the outermost level, which made
a chain of terms that each use the next one twice exponential (a DoS from a short document);
the text-only pass before the targets are known and the validator-text fallback were never
memoized at all (exponential in every version).
Resolver._while_open now keeps texts for the three passes (term_texts, term_texts_early,
term_texts_plain). A text worked out with no cycle cut at a term opened before it is the term's
own text and is reused everywhere (so a reference in a cycle still reads as the definition
site: the earlier cycle test passes). A text cut short at an enclosing open term records the
terms it named and the ones it was cut at, and is reused only where exactly those named terms
are open (at most 32 per term). Chains without a cycle are worked out once per term; a cycle
costs one more per way round.
Tests (tests/test_wp11_review_fixes.py): branching chain under the decimal and none schemes,
used in body text and in a heading, with and without a cycle back to t0, each under 2 s.
The chain is n=20 (n=9 with a cycle), not 40: the text of t0 is itself 2**n words (4**n with
a cycle), so longer chains are bound by output size, not by the term cache.
---
CONFORMANCE.md | 5 +-
src/legaldown_render/resolve/resolver.py | 102 +++++++++++++++++------
tests/test_wp11_review_fixes.py | 31 +++++++
3 files changed, 112 insertions(+), 26 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index fc158d7..af6a33c 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -98,8 +98,9 @@ It is checked against the specification's own examples and fixtures corpus (`tes
falls back to the validator's term text.
A term that refers to itself, directly or through other terms (`"A {{term: a}}" {{def: a}}`), is
rendered without looping: the inner reference shows its `label=`, else the definition id. A term text
- worked out while another term's text is open depends on which one is open, so only a text worked out
- at the outermost level is kept and reused. The
+ worked out inside a cycle depends on which terms are open, so a text with
+ a cut at a term open outside it is reused only where the same of its terms are open; every other text
+ is worked out once per term, also in a heading read before the targets are known (§13.3). The
validator (0.4.0) does not report these cycles; the specification's `definition-circular`
Warning (§16) is not emitted yet.
- **Ambiguous references.** Each list starts again at its first number, as contracts are usually
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 83af663..524d9d4 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -114,6 +114,20 @@
}
+#: How many texts (one per different set of open terms around it) are kept for
+#: one term, so that a pathological tangle of cycles cannot fill memory.
+_TERM_TEXTS_PER_TERM = 32
+
+
+class _TermText(NamedTuple):
+ """A term's text, the terms it named, and those it was cut short at
+ because an enclosing term had them open (see ``Resolver._while_open``)."""
+
+ text: str
+ seen: frozenset[str]
+ cut: frozenset[str]
+
+
@dataclass(frozen=True, slots=True)
class _Target:
"""What a ``{{ref:}}`` to an anchor resolves to."""
@@ -157,11 +171,20 @@ def __init__(
# and -> the plain text of its term as rendered there, worked out the
# first time a {{term:}} needs it (see _term_text).
self.term_sources: dict[str, DefinitionSource] = {}
- self.term_texts: dict[str, str] = {}
+ self.term_texts: dict[str, list[_TermText]] = {}
+ # The same for the text-only pass before the targets are known, and
+ # for the validator's term text where a definition is not in the body.
+ self.term_texts_early: dict[str, list[_TermText]] = {}
+ self.term_texts_plain: dict[str, list[_TermText]] = {}
# The definitions whose term text is being worked out, so that a
# {{term:}} inside a term, directly or through other terms, cannot
# send the computation round in a circle.
self._terms_open: list[str] = []
+ # Since the text now being worked out began (see _while_open): every
+ # term a {{term:}} named, and those of them it was cut short at
+ # because they were open.
+ self._seen: set[str] = set()
+ self._cut: set[str] = set()
# False until every target is registered and the placeholder types are
# known (run() sets it just before the inlines are resolved).
self._targets_known = False
@@ -625,7 +648,10 @@ def _term(self, directive: Directive, definition_id: str) -> Inline:
# A term that refers to itself, directly or through others: the
# reference shows its label, else the id, and goes no further.
text = label or definition_id
+ self._seen.add(definition_id)
+ self._cut.add(definition_id)
else:
+ self._seen.add(definition_id)
# The term as rendered at its definition; the validator's own
# text (an empty term reads as its id, a definition not in the
# body) is the fallback.
@@ -642,46 +668,74 @@ def _term_text(self, definition_id: str) -> str:
definition is not in the body.
Worked out the first time it is needed and kept, which is once every
- target is registered and the placeholder types are known. Only a text
- worked out at the outermost level is kept: one worked out while another
- term's text is being worked out has that term open, so a reference back
- to it shows its label or id there, and the same term resolved alone
- would read differently (a cycle). Before that
+ target is registered and the placeholder types are known. Before that
(a heading read as text by the ``none`` scheme, §13.3) a text-only pass
- stands in, which leaves references out and is not kept. Resolving the
- term gives out no anchor: a definition inside a term keeps its place in
- document order. The definition is open meanwhile, so a ``{{term:}}``
- inside it, directly or through others, shows its label or id and goes
- no further."""
+ stands in, which leaves references out; it is kept apart from the
+ text of the later pass. Resolving the term gives out no anchor: a
+ definition inside a term keeps its place in document order. The
+ definition is open meanwhile, so a ``{{term:}}`` inside it, directly or
+ through others, shows its label or id and goes no further (see
+ :meth:`_while_open`)."""
source = self.term_sources.get(definition_id)
if source is None:
return ""
- if definition_id in self.term_texts:
- return self.term_texts[definition_id]
- self._terms_open.append(definition_id)
- outermost = len(self._terms_open) == 1
- try:
- if not self._targets_known:
+ known = self._targets_known
+
+ def work() -> str:
+ if not known:
return self._title_text(source.children)
anchors = set(self.used_anchors)
try:
- text = plain_text(self._resolve_inlines(source.children))
+ return plain_text(self._resolve_inlines(source.children))
finally:
self.used_anchors = anchors
- finally:
- self._terms_open.pop()
- if outermost:
- self.term_texts[definition_id] = text
- return text
+
+ return self._while_open(self.term_texts if known else self.term_texts_early, definition_id, work)
def _plain_open(self, definition_id: str, term: str) -> str:
"""The validator's *term* text of a definition as plain text, with
the definition marked open while its directives resolve."""
+ key = f"{definition_id}\0{int(self._targets_known)}"
+ return self._while_open(self.term_texts_plain, definition_id, lambda: self._plain_value(term), key=key)
+
+ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, work: Callable[[], str],
+ *, key: str | None = None) -> str:
+ """The text *work* gives with *definition_id* open, kept in *cache*
+ (under *key*, default the id), so a chain of terms that each use the
+ next one twice is worked out once per term, not once per use.
+
+ What a text reads depends on which terms are open around it only
+ where a reference was cut short at an open term (it shows that term's
+ label or id). A text with no cut at a term opened *before* this one is
+ the term's own text, as the reader sees it at its definition, and is
+ reused wherever the term is needed. A text with such a cut (a cycle
+ back through an enclosing term) records the terms it named and the
+ ones it was cut short at, and is reused only where exactly those of
+ the terms it named are open. A cut at this term itself, or at one
+ opened inside it, is its own business and does not count. Without a
+ cycle every term is worked out once; with one, once for each way round
+ it is reached.
+ """
+ key = definition_id if key is None else key
+ around = set(self._terms_open)
+ for kept in cache.get(key, ()):
+ if not kept.cut or kept.seen & around == kept.cut:
+ self._seen |= kept.seen
+ self._cut |= kept.cut
+ return kept.text
self._terms_open.append(definition_id)
+ outer = self._seen, self._cut
+ self._seen, self._cut = set(), set()
try:
- return self._plain_value(term)
+ text = work()
+ seen, cut = frozenset(self._seen), frozenset(self._cut & around)
+ if len(cache.setdefault(key, [])) < _TERM_TEXTS_PER_TERM:
+ cache[key].append(_TermText(text, seen, cut))
finally:
self._terms_open.pop()
+ self._seen = outer[0] | self._seen
+ self._cut = outer[1] | (self._cut & around)
+ return text
def _date(self, directive: Directive, value: str) -> Inline:
if not is_valid_iso_date(value):
diff --git a/tests/test_wp11_review_fixes.py b/tests/test_wp11_review_fixes.py
index e402ca5..ab17940 100644
--- a/tests/test_wp11_review_fixes.py
+++ b/tests/test_wp11_review_fixes.py
@@ -7,6 +7,7 @@
import os
import subprocess
import sys
+import time
from pathlib import Path
import pytest
@@ -164,3 +165,33 @@ def test_a_term_reference_reads_as_its_definition_in_a_cycle() -> None:
used = next(line for line in lines if line.startswith("Used first:"))
site = next(line for line in lines if line.startswith("Beta "))
assert used.endswith(f"and {site}.")
+
+
+def _chain(n: int, *, cycle: bool, heading: bool = False) -> str:
+ """Terms t0..tn, each reading the next one twice; the last one reads t0 when *cycle*."""
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)]
+ defs.append(f'"T{n}{" {{term: t0}}" if cycle else ""}" {{{{def: t{n}}}}}')
+ title = "About {{term: t0}}" if heading else "A"
+ return f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+@pytest.mark.parametrize("heading", [False, True])
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("cycle", [False, True])
+def test_a_branching_chain_of_terms_is_worked_out_once_per_term(cycle: bool, scheme: str, heading: bool) -> None:
+ # Each term reads the next twice: worked out afresh at each use, 20 terms is 2**20 resolutions.
+ # The text of t0 is itself 2**n words long (4**n with a cycle, which reads the chain round
+ # twice), so the chains cannot be longer. Under the none scheme a heading is read as text
+ # before the targets are known (§13.3).
+ started = time.perf_counter()
+ out = text(_chain(9 if cycle else 20, cycle=cycle, heading=heading), overrides={"numbering.scheme": scheme})
+ assert time.perf_counter() - started < 2
+ assert "{{" not in out
+
+
+def test_a_chain_without_a_cycle_reads_as_each_term_does() -> None:
+ lines = text(_chain(3, cycle=False)).splitlines()
+ used = next(line for line in lines if line.startswith("Used: "))
+ # t1 as used inside t0 is the same text as at t1's own definition.
+ site = next(line for line in lines if line.startswith("T1 "))
+ assert used == f"Used: T0 {site} {site}"
From fdbca3da21b8d8f5bbf3b448cbf277a7dee45930 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:34:16 +0000
Subject: [PATCH 027/118] Cap a term text at 1,000 characters,
render-term-too-long (review wave 3)
A chain of terms that each read the next twice asks for a text of 2**n words (4**n through a
cycle), so a short document could exhaust memory however well the texts were cached.
_term_text now checks the length as each term text is built (both the text-only pass before the
targets are known and the later pass); a longer one becomes its definition id, is cached like any
text, and the Warning render-term-too-long names the id once. A label= still wins. The
definition site renders nested {{term:}} through the same path, so nothing is built past the
source plus the nested references times the cap.
Tests (tests/test_wp11_review_fixes.py): the n=40 branching chain and the n=14 cyclic chain
(OOM-killed before) render in under 2 s, small, with the Warning, under both numbering schemes;
over the cap shows the id with one Warning; label wins; exactly 1,000 characters is kept; a
normal nested term is unaffected.
Docs: CONFORMANCE.md (the cap), docs/architecture.md (the rule id).
---
CONFORMANCE.md | 6 ++-
docs/architecture.md | 2 +-
src/legaldown_render/resolve/resolver.py | 37 +++++++++++++----
tests/test_wp11_review_fixes.py | 53 ++++++++++++++++++++++++
4 files changed, 88 insertions(+), 10 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index af6a33c..c98d2a8 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -100,7 +100,11 @@ It is checked against the specification's own examples and fixtures corpus (`tes
rendered without looping: the inner reference shows its `label=`, else the definition id. A term text
worked out inside a cycle depends on which terms are open, so a text with
a cut at a term open outside it is reused only where the same of its terms are open; every other text
- is worked out once per term, also in a heading read before the targets are known (§13.3). The
+ is worked out once per term, also in a heading read before the targets are known (§13.3). A term text is capped at 1,000 characters
+ of plain text, checked as it is built (no real defined term comes near it; a chain of terms that each
+ read the next twice would otherwise ask for a text of 2^n words): a longer one is shown by its
+ definition id in `{{term:}}` (a `label=` still wins), and the Warning `render-term-too-long` names the
+ id, once. The
validator (0.4.0) does not report these cycles; the specification's `definition-circular`
Warning (§16) is not emitted yet.
- **Ambiguous references.** Each list starts again at its first number, as contracts are usually
diff --git a/docs/architecture.md b/docs/architecture.md
index 4dd47df..d20372a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -239,7 +239,7 @@ turns into an error.
Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate:
`ref-not-enumerated` and `path-not-relative` (an Error for an image path that is not relative, §2.3,
reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
-`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), and `render-ref-ambiguous`. The validator's
+`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
diagnostics carry their line (§16.9), and the CLI prints it as `file:line:`. The renderer's own
have none yet: they are about a directive within a block, and the validator makes only a block's
line public (`Document.line_of`, U2).
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 524d9d4..90312bc 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -18,7 +18,8 @@
style template or a renderer: ``ref-not-enumerated``, ``render-ref-ambiguous``,
and ``render-not-processed`` for constructs beyond the Rendering level (§17.5),
``render-link-refused`` for a link whose URL scheme is not one links may use
-(§8.7), and ``path-not-relative`` for an image whose path is not relative (§2.3), which
+(§8.7), ``render-term-too-long`` for a term whose text is longer than a
+``{{term:}}`` may show, and ``path-not-relative`` for an image whose path is not relative (§2.3), which
the validator does not check yet.
"""
from __future__ import annotations
@@ -114,6 +115,15 @@
}
+#: The longest plain text a ``{{term:}}`` may show, in characters. No real
+#: defined term comes near it; one longer is a term that reads other terms
+#: that read other terms (each reference repeating the text of the next, so a
+#: short document can ask for a text of 2**n words), and its reference shows
+#: the definition id instead (``render-term-too-long``). Checked as each term
+#: text is built, so no text grows past the source plus the nested references
+#: times this.
+_MAX_TERM_TEXT = 1000
+
#: How many texts (one per different set of open terms around it) are kept for
#: one term, so that a pathological tangle of cycles cannot fill memory.
_TERM_TEXTS_PER_TERM = 32
@@ -675,7 +685,9 @@ def _term_text(self, definition_id: str) -> str:
definition inside a term keeps its place in document order. The
definition is open meanwhile, so a ``{{term:}}`` inside it, directly or
through others, shows its label or id and goes no further (see
- :meth:`_while_open`)."""
+ :meth:`_while_open`). A text longer than :data:`_MAX_TERM_TEXT` is the
+ definition id instead, and the Warning ``render-term-too-long`` says
+ so, once per id."""
source = self.term_sources.get(definition_id)
if source is None:
return ""
@@ -683,12 +695,21 @@ def _term_text(self, definition_id: str) -> str:
def work() -> str:
if not known:
- return self._title_text(source.children)
- anchors = set(self.used_anchors)
- try:
- return plain_text(self._resolve_inlines(source.children))
- finally:
- self.used_anchors = anchors
+ text = self._title_text(source.children)
+ else:
+ anchors = set(self.used_anchors)
+ try:
+ text = plain_text(self._resolve_inlines(source.children))
+ finally:
+ self.used_anchors = anchors
+ if len(text) > _MAX_TERM_TEXT:
+ self._warn(
+ "render-term-too-long",
+ f"The term of '{printable(definition_id)}' is longer than {_MAX_TERM_TEXT} characters once its "
+ f"references are resolved; '{{{{term: {printable(definition_id)}}}}}' shows the definition id.",
+ )
+ return definition_id
+ return text
return self._while_open(self.term_texts if known else self.term_texts_early, definition_id, work)
diff --git a/tests/test_wp11_review_fixes.py b/tests/test_wp11_review_fixes.py
index ab17940..bd2df16 100644
--- a/tests/test_wp11_review_fixes.py
+++ b/tests/test_wp11_review_fixes.py
@@ -195,3 +195,56 @@ def test_a_chain_without_a_cycle_reads_as_each_term_does() -> None:
# t1 as used inside t0 is the same text as at t1's own definition.
site = next(line for line in lines if line.startswith("T1 "))
assert used == f"Used: T0 {site} {site}"
+
+
+# -- 4b: a term text is capped ----------------------------------------------------
+
+
+def _warnings(body: str, **settings: object) -> list[str]:
+ return [d.message for d in render(FRONT + body, format="text", **settings).diagnostics
+ if d.rule == "render-term-too-long"]
+
+
+@pytest.mark.parametrize(("n", "cycle"), [(40, False), (14, True)])
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+def test_a_branching_chain_of_terms_is_capped_and_reported(n: int, cycle: bool, scheme: str) -> None:
+ started = time.perf_counter()
+ result = render(FRONT + _chain(n, cycle=cycle, heading=True), format="text",
+ overrides={"numbering.scheme": scheme})
+ assert time.perf_counter() - started < 2
+ assert len(result.output) < 100_000
+ messages = [d.message for d in result.diagnostics if d.rule == "render-term-too-long"]
+ assert messages
+ assert len(messages) == len(set(messages)) # one per id
+ assert all("longer than 1000 characters" in message and "shows the definition id" in message
+ for message in messages)
+ assert "{{" not in result.output
+
+
+def test_a_term_over_the_cap_shows_its_id_and_one_warning_names_it() -> None:
+ long_term = "word " * 250
+ body = f'# A\n\n"{long_term}" {{{{def: big}}}}\n\nSee {{{{term: big}}}} and {{{{term: big}}}}.\n'
+ out = text(body)
+ assert "See big and big." in out
+ assert _warnings(body) == [
+ "The term of 'big' is longer than 1000 characters once its references are resolved; "
+ "'{{term: big}}' shows the definition id."]
+
+
+def test_a_label_still_wins_over_the_cap() -> None:
+ body = '# A\n\n"' + "word " * 250 + '" {{def: big}}\n\nSee {{term: big, label=Big}}.\n'
+ assert "See Big." in text(body)
+
+
+def test_a_term_at_the_cap_is_shown_whole() -> None:
+ term = "x" * 1000
+ body = f'# A\n\n"{term}" {{{{def: edge}}}}\n\nSee {{{{term: edge}}}}.\n'
+ assert f"See {term}." in text(body)
+ assert _warnings(body) == []
+
+
+def test_a_normal_nested_term_is_unaffected() -> None:
+ body = ('# A\n\n"Company" {{def: company}}\n\n"Affiliate of the {{term: company}}" {{def: affiliate}}\n\n'
+ 'See {{term: affiliate}}.\n')
+ assert "See Affiliate of the Company." in text(body)
+ assert _warnings(body) == []
From bceecee42ba926343cd39f71de7da2231d1546d8 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:53:35 +0000
Subject: [PATCH 028/118] Final review fixes: term budget and depth limit,
exact cache reuse, printable CLI diagnostics, docs
1. render-term-limit: k terms that each name all k took time exponential in k (k=10 over two
minutes) whatever the cache. _while_open now counts real term-text computations per render
(limit 2,000) and the terms open inside one another (limit 32); past either, a {{term:}}
shows its label or definition id, nothing is kept, and one Warning is reported.
Also fixes a RecursionError for 150+ chained terms.
2. A cached term text is reused only where the terms it named are open exactly as when it was
worked out (the "no cut" shortcut reused a text although a term it named was open). The result
equals the uncached open-set recursion (differential fuzz test).
Changed test: test_wp11_review_fixes.test_a_term_reference_reads_as_its_definition_in_a_cycle
asserted that a reference reads as the definition site, which only held through the old cache
reusing a text across contexts; a reference is the term's text with that term open (the site
renders with none open), and it no longer depends on the order of use. Replaced by an order
independence test.
3. cli: the file name and every diagnostic message (validator's and renderer's), and the
DocumentError and RenderRefused messages, go through urls.printable, so control characters
from the document never reach stderr.
4. Docs: control-character claim narrowed to the CLI's stderr; allow-list wording; style_to_dict
import path; CONFORMANCE Links row marked partial (documented deviation); term-cycle wording
matches the real bound; render-term-limit documented.
---
CONFORMANCE.md | 11 +-
README.md | 2 +-
docs/architecture.md | 17 +--
src/legaldown_render/cli.py | 11 +-
src/legaldown_render/resolve/resolver.py | 69 ++++++++---
tests/test_wp11_review_fixes.py | 13 +-
tests/test_wp12_final_fixes.py | 150 +++++++++++++++++++++++
7 files changed, 234 insertions(+), 39 deletions(-)
create mode 100644 tests/test_wp12_final_fixes.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index c98d2a8..76a2046 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -39,7 +39,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| `ref-not-enumerated` Warning (§6.3, §16.3) | ✅ |
| `raw-html` Warning; raw HTML never emitted (§8.7) | ✅ 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 (see "Text after a comment", below) |
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
-| Links (§8.7) | ✅ A link is a hyperlink in HTML and shows `text ` in plain text when its URL is relative, a fragment, or has one of the schemes `http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sftp`, `sip`, `sips`, `sms`, `urn`, `geo`, `news` (an allow-list, judged as a browser reads the URL). Any other scheme (`javascript:`, `file:`, `data:`, protocol handlers such as `ms-msdt:`, `vscode:`, `search-ms:`, and pseudo-schemes such as `C:\x` or `localhost:8080`), or an empty URL, is not a link: its text is shown alone, and the Warning `render-link-refused` is reported for a non-empty URL. A link with no text shows its URL as written; the text writer omits `` for an autolink |
+| Links (§8.7) | ⚠️ Partial, a documented deviation for safety with untrusted documents: links with schemes outside the allow-list render as text with a `render-link-refused` Warning. A link is a hyperlink in HTML and shows `text ` in plain text when its URL is relative, a fragment, or has one of the schemes `http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sftp`, `sip`, `sips`, `sms`, `urn`, `geo`, `news` (an allow-list, judged as a browser reads the URL). Any other scheme (`javascript:`, `file:`, `data:`, protocol handlers such as `ms-msdt:`, `vscode:`, `search-ms:`, and pseudo-schemes such as `C:\x` or `localhost:8080`), or an empty URL, is not a link: its text is shown alone, and the Warning `render-link-refused` is reported for a non-empty URL. A link with no text shows its URL as written; the text writer omits `` for an autolink |
| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label; an item that opens with a nested list, table, code block or quote has its label on a line of its own, so that block stays valid. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
@@ -98,9 +98,12 @@ It is checked against the specification's own examples and fixtures corpus (`tes
falls back to the validator's term text.
A term that refers to itself, directly or through other terms (`"A {{term: a}}" {{def: a}}`), is
rendered without looping: the inner reference shows its `label=`, else the definition id. A term text
- worked out inside a cycle depends on which terms are open, so a text with
- a cut at a term open outside it is reused only where the same of its terms are open; every other text
- is worked out once per term, also in a heading read before the targets are known (§13.3). A term text is capped at 1,000 characters
+ worked out inside a cycle depends on which terms are open, so it is kept with the open terms it
+ depends on and reused only where those are open; a text with no cycle is worked out once per term,
+ also in a heading read before the targets are known (§13.3). Terms that name each other can still
+ ask for more different texts than any cache holds, so the work is bounded: past 2,000 texts worked
+ out in one document, or 32 terms open inside one another, a `{{term:}}` shows its `label=` or
+ definition id and the Warning `render-term-limit` is reported, once. A term text is capped at 1,000 characters
of plain text, checked as it is built (no real defined term comes near it; a chain of terms that each
read the next twice would otherwise ask for a text of 2^n words): a longer one is shown by its
definition id in `{{term:}}` (a `label=` still wins), and the Warning `render-term-too-long` names the
diff --git a/README.md b/README.md
index 3fa4b17..d4e9262 100644
--- a/README.md
+++ b/README.md
@@ -106,7 +106,7 @@ format (`OptionsError`), an unreadable document (`DocumentError`), strict mode,
cannot be assembled with its answers (`RenderRefused`) do. All of them are `RenderError`s, and
`StyleError` and `OptionsError` are also `ValueError`s. They can be pickled, so they cross process
boundaries (`multiprocessing`, `concurrent.futures`) intact. `overrides=` takes dotted settings and
-may not contain `version` or `extends`, so `overrides=style_to_dict(style)` must drop those two keys.
+may not contain `version` or `extends`, so `overrides=legaldown_render.style.style_to_dict(style)` must drop those two keys.
## Styles and preferences
diff --git a/docs/architecture.md b/docs/architecture.md
index d20372a..f7dfc39 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -239,7 +239,7 @@ turns into an error.
Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate:
`ref-not-enumerated` and `path-not-relative` (an Error for an image path that is not relative, §2.3,
reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
-`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
+`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, `render-term-limit` (a Warning when defined terms refer to each other too deeply or too often: past 32 nested or 2,000 computed term texts a `{{term:}}` shows its id), and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
diagnostics carry their line (§16.9), and the CLI prints it as `file:line:`. The renderer's own
have none yet: they are about a directive within a block, and the validator makes only a block's
line public (`Document.line_of`, U2).
@@ -264,11 +264,14 @@ line public (`Document.line_of`, U2).
(links, images, value links). A link with no text shows its URL as written (percent-encoding
undone; the `href` stays encoded); the text writer leaves out ` ` when the URL, less a
`mailto:` or `tel:` prefix, is the text.
-- A diagnostic never carries a raw control character from the document: a path or URL quoted in a
- message (`path-not-relative`, `render-link-refused`) goes through `urls.printable`, which writes
- every C0/C1 control, DEL, Unicode format character (bidi controls, zero-width), and U+2028/9 as
- `\xNN` or `\uNNNN`, so a crafted URL cannot move the cursor, set a terminal title, or forge a
- line on stderr.
+- What the CLI prints on stderr never carries a raw control character from the document: `cli._report`
+ passes the file name and every diagnostic message (the renderer's and the validator's, which quote
+ ids, paths and terms as written) through `urls.printable`, which writes every C0/C1 control, DEL,
+ Unicode format character (bidi controls, zero-width), and U+2028/9 as `\xNN` or `\uNNNN`, so a
+ crafted document cannot move the cursor, set a terminal title, or forge a line. The messages the
+ API returns are not all escaped: only a path or URL quoted by the renderer's own rules
+ (`path-not-relative`, `render-link-refused`) is, at its source; a caller that shows other
+ messages must do the same.
- An image path must be relative with no URI scheme (§2.3), so a remote resource is never fetched
or embedded. Resolution, not a writer, enforces it: an image whose path has a scheme (`https:`,
`file:`, `data:`, a Windows drive such as `C:\x`) or starts with `/` or `\` (`//host`) is
@@ -299,7 +302,7 @@ line public (`Document.line_of`, U2).
|---|---|
| `tests/test_golden.py` | The test documents under each built-in style, compared byte for byte with `tests/golden/`. Regenerate with `pytest --update-golden` and review the diff |
| `tests/test_render.py` | Numbering schemes, designations, fallbacks, every failure marker, template view, labels by language, HTML safety, unique anchors, determinism, strict mode, locales |
-| `tests/test_wp3_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme deny-list, empty URLs and empty link text |
+| `tests/test_wp3_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme allow-list, empty URLs and empty link text |
| `tests/test_style.py`, `tests/test_values.py` | Style layering and validation; counters and locale formatting |
| `tests/test_cli.py` | The command |
| `tests/conformance/` | Every example and fixture in the specification repository renders, every internal link has a target, and each invalid fixture renders its rule's failure marker. Needs `$LEGALDOWN_SPEC_DIR` or a sibling `../LegalDown` checkout |
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index e0cdea8..b97b438 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -24,6 +24,7 @@
from .api import RenderOptions, render
from .errors import DocumentError, InternalError, RenderRefused, StyleError
from .style import builtin_styles, dump_style, load_style, parse_override
+from .urls import printable
from .writers import FORMATS, file_extension, format_for_path
#: Extensions (:func:`file_extension`, so no dot) of document formats this
@@ -179,10 +180,10 @@ def main(argv: list[str] | None = None) -> int:
return 2
except RenderRefused as error:
_report(name, error.diagnostics, quiet=args.quiet)
- print(f"legaldown-render: {error}", file=sys.stderr)
+ print(f"legaldown-render: {printable(str(error))}", file=sys.stderr)
return 1
except DocumentError as error:
- print(f"{name}: error: {error}", file=sys.stderr)
+ print(f"{printable(name)}: error: {printable(str(error))}", file=sys.stderr)
return 1
except InternalError as error:
print(f"legaldown-render: internal error: {error}", file=sys.stderr)
@@ -205,8 +206,10 @@ def _report(name: str, diagnostics: list, *, quiet: bool) -> None:
return
for diagnostic in diagnostics:
# 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)
+ # The message quotes the document (an id, a path, a term), and a terminal
+ # obeys the control characters in it, so both parts are made printable.
+ where = f"{printable(name)}:{diagnostic.line}" if diagnostic.line else printable(name)
+ print(f"{where}: {diagnostic.level}: [{diagnostic.rule}] {printable(diagnostic.message)}", file=sys.stderr)
if __name__ == "__main__":
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 90312bc..4d626b6 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -19,7 +19,8 @@
and ``render-not-processed`` for constructs beyond the Rendering level (§17.5),
``render-link-refused`` for a link whose URL scheme is not one links may use
(§8.7), ``render-term-too-long`` for a term whose text is longer than a
-``{{term:}}`` may show, and ``path-not-relative`` for an image whose path is not relative (§2.3), which
+``{{term:}}`` may show, ``render-term-limit`` for terms that refer to each other too deeply or too often,
+and ``path-not-relative`` for an image whose path is not relative (§2.3), which
the validator does not check yet.
"""
from __future__ import annotations
@@ -124,6 +125,16 @@
#: times this.
_MAX_TERM_TEXT = 1000
+#: The most term texts worked out in one document (each real computation, not
+#: a text taken from the cache), and the most terms open inside one another.
+#: Past either, a ``{{term:}}`` shows its label or definition id and the
+#: Warning ``render-term-limit`` is reported. A document of terms that name
+#: each other (k terms each naming all k) would otherwise take time
+#: exponential in k, and a long chain of terms would exhaust the stack. No
+#: real document comes near them.
+_MAX_TERM_RUNS = 2000
+_MAX_TERM_DEPTH = 32
+
#: How many texts (one per different set of open terms around it) are kept for
#: one term, so that a pathological tangle of cycles cannot fill memory.
_TERM_TEXTS_PER_TERM = 32
@@ -195,6 +206,7 @@ def __init__(
# because they were open.
self._seen: set[str] = set()
self._cut: set[str] = set()
+ self._term_runs = 0
# False until every target is registered and the placeholder types are
# known (run() sets it just before the inlines are resolved).
self._targets_known = False
@@ -665,12 +677,16 @@ def _term(self, directive: Directive, definition_id: str) -> Inline:
# The term as rendered at its definition; the validator's own
# text (an empty term reads as its id, a definition not in the
# body) is the fallback.
- text = label or self._term_text(definition_id) or self._plain_open(definition_id, term)
+ text = label or self._term_text(definition_id)
+ if text == "":
+ text = self._plain_open(definition_id, term)
+ if text is None:
+ text = definition_id # past the limits (see _while_open)
# Settled once everything is resolved (see _settle_term_target).
target = DEFINITION_ANCHOR_PREFIX + definition_id
return TermRef(text, target, self.style.definitions.term_style)
- def _term_text(self, definition_id: str) -> str:
+ def _term_text(self, definition_id: str) -> str | None:
"""The plain text of the term at the first definition of *definition_id*
in the body, as the reader sees it there: its Markdown, references,
placeholders and other terms resolved exactly as at the definition
@@ -687,7 +703,8 @@ def _term_text(self, definition_id: str) -> str:
through others, shows its label or id and goes no further (see
:meth:`_while_open`). A text longer than :data:`_MAX_TERM_TEXT` is the
definition id instead, and the Warning ``render-term-too-long`` says
- so, once per id."""
+ so, once per id. ``None`` is a term past the limits of
+ :meth:`_while_open`."""
source = self.term_sources.get(definition_id)
if source is None:
return ""
@@ -713,37 +730,53 @@ def work() -> str:
return self._while_open(self.term_texts if known else self.term_texts_early, definition_id, work)
- def _plain_open(self, definition_id: str, term: str) -> str:
+ def _plain_open(self, definition_id: str, term: str) -> str | None:
"""The validator's *term* text of a definition as plain text, with
the definition marked open while its directives resolve."""
key = f"{definition_id}\0{int(self._targets_known)}"
return self._while_open(self.term_texts_plain, definition_id, lambda: self._plain_value(term), key=key)
def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, work: Callable[[], str],
- *, key: str | None = None) -> str:
+ *, key: str | None = None) -> str | None:
"""The text *work* gives with *definition_id* open, kept in *cache*
(under *key*, default the id), so a chain of terms that each use the
next one twice is worked out once per term, not once per use.
- What a text reads depends on which terms are open around it only
- where a reference was cut short at an open term (it shows that term's
- label or id). A text with no cut at a term opened *before* this one is
- the term's own text, as the reader sees it at its definition, and is
- reused wherever the term is needed. A text with such a cut (a cycle
- back through an enclosing term) records the terms it named and the
- ones it was cut short at, and is reused only where exactly those of
- the terms it named are open. A cut at this term itself, or at one
- opened inside it, is its own business and does not count. Without a
- cycle every term is worked out once; with one, once for each way round
- it is reached.
+ What a text reads depends on which terms are open around it: a
+ reference to an open term is cut short and shows its label or id. A
+ kept text records the terms it named (``seen``) and those it was cut
+ short at that were open *before* this one (``cut``: a cycle back
+ through an enclosing term). It is reused only where exactly those of
+ the terms it named are open, so it reads as it would if worked out
+ afresh there. A cut at this term itself, or at one opened inside it, is
+ its own business and does not count. Without a cycle every term is
+ worked out once.
+
+ A cycle can still ask for a great many different texts (k terms that
+ each name all k have more ways round than any cache holds), so the work
+ is limited: past :data:`_MAX_TERM_DEPTH` terms open at once, or
+ :data:`_MAX_TERM_RUNS` texts worked out in the document, the result is
+ ``None`` (the reference shows its label or id) and the Warning
+ ``render-term-limit`` says so, once.
"""
key = definition_id if key is None else key
around = set(self._terms_open)
for kept in cache.get(key, ()):
- if not kept.cut or kept.seen & around == kept.cut:
+ if kept.seen & around == kept.cut:
self._seen |= kept.seen
self._cut |= kept.cut
return kept.text
+ if len(self._terms_open) >= _MAX_TERM_DEPTH or self._term_runs >= _MAX_TERM_RUNS:
+ # Terms that refer to each other this deeply or this often are
+ # not resolved further: like a cut, the reference shows its label
+ # or id, and nothing is kept.
+ self._warn(
+ "render-term-limit",
+ "Defined terms refer to each other too deeply or too often to resolve fully; "
+ "some {{term:}} references show their definition id.",
+ )
+ return None
+ self._term_runs += 1
self._terms_open.append(definition_id)
outer = self._seen, self._cut
self._seen, self._cut = set(), set()
diff --git a/tests/test_wp11_review_fixes.py b/tests/test_wp11_review_fixes.py
index bd2df16..9cf45af 100644
--- a/tests/test_wp11_review_fixes.py
+++ b/tests/test_wp11_review_fixes.py
@@ -160,11 +160,14 @@ def test_a_text_name_with_a_dot_quirk_writes_text(tmp_path: Path, name: str, mar
'"Alpha {{term: cb}}" {{def: ca}}\n\n"Beta {{term: ca}}" {{def: cb}}\n')
-def test_a_term_reference_reads_as_its_definition_in_a_cycle() -> None:
- lines = text(CYCLE).splitlines()
- used = next(line for line in lines if line.startswith("Used first:"))
- site = next(line for line in lines if line.startswith("Beta "))
- assert used.endswith(f"and {site}.")
+def test_a_term_reference_in_a_cycle_does_not_depend_on_the_order_of_use() -> None:
+ # WP12: a term shown by {{term:}} is its text with that term open (a reference back to it
+ # shows its id), whichever term is used first. (The definition site renders with no term
+ # open, so it can read longer: "Beta Alpha Beta ca" there, "Beta Alpha cb" in a reference.)
+ ca_first = text(CYCLE)
+ cb_first = text(CYCLE.replace("{{term: ca}} and {{term: cb}}", "{{term: cb}} and {{term: ca}}"))
+ assert "Used first: Alpha Beta ca and Beta Alpha cb." in ca_first
+ assert "Used first: Beta Alpha cb and Alpha Beta ca." in cb_first
def _chain(n: int, *, cycle: bool, heading: bool = False) -> str:
diff --git a/tests/test_wp12_final_fixes.py b/tests/test_wp12_final_fixes.py
new file mode 100644
index 0000000..704c927
--- /dev/null
+++ b/tests/test_wp12_final_fixes.py
@@ -0,0 +1,150 @@
+"""Final review fixes (WP12): terms that name each other are resolved within a
+budget (``render-term-limit``), a cached term text is reused only where it would
+read the same, long chains of terms do not exhaust the stack, and nothing from a
+document reaches stderr as a control character."""
+from __future__ import annotations
+
+import os
+import random
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+from legaldown_render import render
+from legaldown_render.resolve.resolver import Resolver
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+FRONT = "---\ntitle: T\n---\n\n"
+LIMIT = ("Defined terms refer to each other too deeply or too often to resolve fully; "
+ "some {{term:}} references show their definition id.")
+
+
+def messages(result, rule: str) -> list[str]: # noqa: ANN001
+ return [d.message for d in result.diagnostics if d.rule == rule]
+
+
+# -- 1: terms that all name each other ---------------------------------------------
+
+
+def _tangle(k: int) -> str:
+ defs = [f'"T{i} {" ".join(f"{{{{term: t{j}}}}}" for j in range(k))}" {{{{def: t{i}}}}}' for i in range(k)]
+ return "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+@pytest.mark.parametrize("k", [3, 9, 12, 20, 40])
+def test_k_terms_that_each_name_all_k_render_quickly(k: int) -> None:
+ started = time.perf_counter()
+ result = render(FRONT + _tangle(k), format="text")
+ assert time.perf_counter() - started < 2
+ assert "{{" not in result.output
+ if k >= 9:
+ assert messages(result, "render-term-limit") == [LIMIT]
+
+
+def test_a_small_cycle_is_resolved_in_full_without_the_warning() -> None:
+ assert messages(render(FRONT + _tangle(3), format="text"), "render-term-limit") == []
+
+
+# -- 3: a long chain of terms ------------------------------------------------------
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("heading", [False, True])
+def test_a_chain_of_500_terms_does_not_exhaust_the_stack(scheme: str, heading: bool) -> None:
+ n = 500
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
+ title = "About {{term: t0}}" if heading else "A"
+ body = f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
+ started = time.perf_counter()
+ result = render(FRONT + body, format="text", overrides={"numbering.scheme": scheme})
+ assert time.perf_counter() - started < 2
+ assert messages(result, "render-term-limit") == [LIMIT]
+ used = next(line for line in result.output.splitlines() if line.startswith("Used: "))
+ assert used.startswith("Used: T0 T1 T2 ")
+ assert "{{" not in result.output
+
+
+def test_a_chain_inside_the_depth_limit_is_resolved_in_full() -> None:
+ n = 20
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
+ result = render(FRONT + "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n", format="text")
+ assert f"Used: {' '.join(f'T{i}' for i in range(n))} End" in result.output
+ assert messages(result, "render-term-limit") == []
+
+
+# -- 2: a cached text is reused only where it would read the same ------------------
+
+
+def _oracle(self: Resolver, cache: object, definition_id: str, work, *, key: str | None = None) -> str: # noqa: ANN001
+ """The term text with the open terms alone deciding, nothing kept."""
+ self._terms_open.append(definition_id)
+ try:
+ return work()
+ finally:
+ self._terms_open.pop()
+
+
+def _graph(rng: random.Random) -> str:
+ ids = [f"t{i}" for i in range(rng.randint(2, 6))]
+ defs = []
+ for identifier in ids:
+ parts = [identifier.upper()]
+ for _ in range(rng.randint(0, 3)):
+ target = rng.choice(ids)
+ parts.append(f"{{{{term: {target}, label=L{target}}}}}" if rng.random() < 0.15
+ else f"{{{{term: {target}}}}}")
+ defs.append(f'"{" ".join(parts)}" {{{{def: {identifier}}}}}')
+ rng.shuffle(defs)
+ heading = rng.choice(["A", f"About {{{{term: {rng.choice(ids)}}}}}"])
+ use = " ".join(f"{{{{term: {rng.choice(ids)}}}}}" for _ in range(rng.randint(1, 4)))
+ return f"# {heading}\n\nUsed: {use}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def test_cached_term_texts_equal_the_texts_worked_out_afresh(monkeypatch: pytest.MonkeyPatch) -> None:
+ rng = random.Random(20261027)
+ cached_original = Resolver._while_open
+ for _ in range(150):
+ source = FRONT + _graph(rng)
+ for scheme in ("decimal", "none"):
+ overrides = {"numbering.scheme": scheme}
+ monkeypatch.setattr(Resolver, "_while_open", cached_original)
+ cached = render(source, format="text", overrides=overrides)
+ monkeypatch.setattr(Resolver, "_while_open", _oracle)
+ fresh = render(source, format="text", overrides=overrides)
+ assert cached.output == fresh.output, source
+ assert [(d.rule, d.message) for d in cached.diagnostics] == \
+ [(d.rule, d.message) for d in fresh.diagnostics], source
+
+
+def test_a_text_that_named_a_term_is_not_reused_where_that_term_is_open() -> None:
+ # ca's text names cb. Worked out with nothing open it is "Alpha Beta ca"; with cb open it is
+ # "Alpha cb", and that is what a reference inside cb must read.
+ body = ('# A\n\nFirst {{term: ca}}. Then {{term: cb}}.\n\n"Alpha {{term: cb}}" {{def: ca}}\n\n'
+ '"Beta {{term: ca}}" {{def: cb}}\n')
+ out = render(FRONT + body, format="text").output
+ assert "First Alpha Beta ca. Then Beta Alpha cb." in out
+
+
+# -- 4: control characters never reach stderr ----------------------------------------
+
+
+def _run(source: str, tmp_path: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]:
+ document = tmp_path / "d.lgd"
+ document.write_text(source, encoding="utf-8")
+ env = {**os.environ, "PYTHONPATH": str(SRC)}
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", str(document), "-f", "text", *arguments],
+ capture_output=True, cwd=tmp_path, env=env, check=False)
+
+
+def test_no_control_character_from_the_document_reaches_stderr(tmp_path: Path) -> None:
+ body = ("# A {#a}\n\n{{include: a\x1b]0;pwn\x07.lgd}}\n\n{{ref: x\x1b[2Jy}}\n\n{{term: q\x1b[31m}}\n\n"
+ "See {{ref: a}}.\n")
+ result = _run(FRONT + body, tmp_path)
+ errors = result.stderr.decode("utf-8")
+ assert errors.count("\n") >= 3 # the three findings, one line each
+ assert not any(ord(c) < 0x20 and c != "\n" or 0x7F <= ord(c) < 0xA0 for c in errors)
+ assert "\\x1b" in errors # written out, not dropped
+ assert "pwn" in errors
From 2091550beb97ffc20d7c02acba82e6c64cf0c0fb Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 19:09:45 +0000
Subject: [PATCH 029/118] Term budget charges only repeat work; CLI escapes
every echoed command-line value
1. The budget no longer charges acyclic terms: a term's first text is free (bounded by the number
of terms per pass); only a repeat computation (the cache already holds a text of the term, for
other open terms) counts. 3000 independent terms, with and without headings, under both
schemes, show no render-term-limit and every real text.
2. The budget is now a work budget: the inline nodes resolved inside repeat computations (nested
ones included) are counted, limit _MAX_TERM_WORK = 200,000, so the extra work cycles can cause
is a small multiple of the document, whatever the number of references in a term. The depth
limit (32) stays. Past either: label or id, nothing kept, one render-term-limit Warning.
3. cli: every error line goes through _safe (urls.printable per line, keeping the line breaks of
multi-line messages), and argparse errors through a parser whose error() does the same, so an
input, answers, output or style name, a locale or a --set key echoed there is escaped.
Docs: CONFORMANCE.md (work budget), docs/architecture.md (limit, stderr).
---
CONFORMANCE.md | 8 +-
docs/architecture.md | 6 +-
src/legaldown_render/cli.py | 50 ++++---
src/legaldown_render/resolve/resolver.py | 44 +++---
tests/test_wp13_budget.py | 162 +++++++++++++++++++++++
5 files changed, 235 insertions(+), 35 deletions(-)
create mode 100644 tests/test_wp13_budget.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 76a2046..cc22c1b 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -101,9 +101,11 @@ It is checked against the specification's own examples and fixtures corpus (`tes
worked out inside a cycle depends on which terms are open, so it is kept with the open terms it
depends on and reused only where those are open; a text with no cycle is worked out once per term,
also in a heading read before the targets are known (§13.3). Terms that name each other can still
- ask for more different texts than any cache holds, so the work is bounded: past 2,000 texts worked
- out in one document, or 32 terms open inside one another, a `{{term:}}` shows its `label=` or
- definition id and the Warning `render-term-limit` is reported, once. A term text is capped at 1,000 characters
+ ask for more different texts than any cache holds, so the extra work is bounded: a term's first text
+ is free, and past 200,000 inline nodes resolved inside later texts of the same term (nested ones
+ included), or 32 terms open inside one another, a `{{term:}}` shows its `label=` or definition id
+ and the Warning `render-term-limit` is reported, once. Any number of terms that do not name each
+ other resolve in full and never reach the limit. A term text is capped at 1,000 characters
of plain text, checked as it is built (no real defined term comes near it; a chain of terms that each
read the next twice would otherwise ask for a text of 2^n words): a longer one is shown by its
definition id in `{{term:}}` (a `label=` still wins), and the Warning `render-term-too-long` names the
diff --git a/docs/architecture.md b/docs/architecture.md
index f7dfc39..f73c800 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -239,7 +239,7 @@ turns into an error.
Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can evaluate:
`ref-not-enumerated` and `path-not-relative` (an Error for an image path that is not relative, §2.3,
reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
-`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, `render-term-limit` (a Warning when defined terms refer to each other too deeply or too often: past 32 nested or 2,000 computed term texts a `{{term:}}` shows its id), and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
+`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, `render-term-limit` (a Warning when defined terms refer to each other too deeply or too often: past 32 nested terms, or 200,000 inline nodes resolved inside repeat computations of a term text (only cycles cause them), a `{{term:}}` shows its id), and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
diagnostics carry their line (§16.9), and the CLI prints it as `file:line:`. The renderer's own
have none yet: they are about a directive within a block, and the validator makes only a block's
line public (`Document.line_of`, U2).
@@ -264,7 +264,9 @@ line public (`Document.line_of`, U2).
(links, images, value links). A link with no text shows its URL as written (percent-encoding
undone; the `href` stays encoded); the text writer leaves out ` ` when the URL, less a
`mailto:` or `tel:` prefix, is the text.
-- What the CLI prints on stderr never carries a raw control character from the document: `cli._report`
+- What the CLI prints on stderr never carries a raw control character from the document or the command
+ line: every error line and every argparse error goes through `cli._safe` (`urls.printable` per line), so
+ an input, answers, output or style name, a locale or a `--set` key echoed in one is escaped, and `cli._report`
passes the file name and every diagnostic message (the renderer's and the validator's, which quote
ids, paths and terms as written) through `urls.printable`, which writes every C0/C1 control, DEL,
Unicode format character (bidi controls, zero-width), and U+2028/9 as `\xNN` or `\uNNNN`, so a
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index b97b438..993e704 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -16,7 +16,7 @@
import contextlib
import sys
from pathlib import Path
-from typing import Any
+from typing import Any, NoReturn
from legaldown import AnswersError, load_answers
@@ -35,20 +35,40 @@
_UNWRITTEN_EXTENSIONS = frozenset({"docx", "doc", "odt", "rtf", "pdf", "md", "markdown", "epub", "tex"})
+def _safe(text: str) -> str:
+ """*text* for stderr, with every control character written out (see
+ :func:`~legaldown_render.urls.printable`) except the line breaks that
+ structure a multi-line message. Command-line values (file names, a style
+ name, a locale, a setting key) and the library's messages that quote them
+ reach a terminal only through this."""
+ return "\n".join(printable(line) for line in text.split("\n"))
+
+
+def _complain(message: str) -> None:
+ print(_safe(message), file=sys.stderr)
+
+
+class _Parser(argparse.ArgumentParser):
+ def error(self, message: str) -> NoReturn:
+ # argparse echoes the offending value (an invalid choice, an
+ # unrecognized argument): written out, not obeyed by the terminal.
+ super().error(_safe(message))
+
+
def _setting(text: str) -> tuple[str, Any]:
key, separator, value = text.partition("=")
if not separator or not key.strip():
- raise argparse.ArgumentTypeError(f"expected KEY=VALUE, got '{text}'")
+ raise argparse.ArgumentTypeError(f"expected KEY=VALUE, got '{printable(text)}'")
# Typed by the setting: text stays as written, anything else is YAML.
try:
return key.strip(), parse_override(key.strip(), value)
except StyleError as error:
# argparse shows only ArgumentTypeError (and TypeError/ValueError, with a generic message).
- raise argparse.ArgumentTypeError(str(error)) from None
+ raise argparse.ArgumentTypeError(_safe(str(error))) from None
def _parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(
+ parser = _Parser(
prog="legaldown-render",
description="Render a LegalDown document to HTML or plain text.",
epilog="Style settings: --style picks a template (built-in: "
@@ -134,7 +154,7 @@ def main(argv: list[str] | None = None) -> int:
try:
print(dump_style(load_style(args.style, overrides=overrides)), end="")
except StyleError as error:
- print(f"legaldown-render: {error}", file=sys.stderr)
+ _complain(f"legaldown-render: {error}")
return 2
return 0
if not args.input:
@@ -145,17 +165,17 @@ def main(argv: list[str] | None = None) -> int:
file_name = Path(args.output).name
output_format = format_for_path(file_name)
if not output_format and file_extension(file_name) in _UNWRITTEN_EXTENSIONS:
- parser.error(f"cannot tell the output format from '{args.output}'; use --format html or text")
+ parser.error(f"cannot tell the output format from '{printable(args.output)}'; use --format html or text")
output_format = output_format or "html"
name = "" if args.input == "-" else args.input
try:
source = _read_source(args.input)
except OSError as error:
- print(f"legaldown-render: cannot read {name}: {error.strerror or error}", file=sys.stderr)
+ _complain(f"legaldown-render: cannot read {name}: {error.strerror or error}")
return 1
except UnicodeDecodeError as error:
- print(f"legaldown-render: cannot read {name}: not valid UTF-8 "
- f"(byte 0x{error.object[error.start]:02x} at offset {error.start})", file=sys.stderr)
+ _complain(f"legaldown-render: cannot read {name}: not valid UTF-8 "
+ f"(byte 0x{error.object[error.start]:02x} at offset {error.start})")
return 1
answers = None
@@ -165,10 +185,10 @@ def main(argv: list[str] | None = None) -> int:
try:
answers = load_answers(args.answers)
except OSError as error:
- print(f"legaldown-render: cannot read {Path(args.answers)}: {error}", file=sys.stderr)
+ _complain(f"legaldown-render: cannot read {Path(args.answers)}: {error}")
return 1
except AnswersError as error:
- print(f"legaldown-render: {error}", file=sys.stderr)
+ _complain(f"legaldown-render: {error}")
return 1
options = RenderOptions(format=output_format, style=args.style, overrides=overrides,
@@ -176,17 +196,17 @@ def main(argv: list[str] | None = None) -> int:
try:
result = render(source, options)
except StyleError as error:
- print(f"legaldown-render: {error}", file=sys.stderr)
+ _complain(f"legaldown-render: {error}")
return 2
except RenderRefused as error:
_report(name, error.diagnostics, quiet=args.quiet)
- print(f"legaldown-render: {printable(str(error))}", file=sys.stderr)
+ _complain(f"legaldown-render: {printable(str(error))}")
return 1
except DocumentError as error:
print(f"{printable(name)}: error: {printable(str(error))}", file=sys.stderr)
return 1
except InternalError as error:
- print(f"legaldown-render: internal error: {error}", file=sys.stderr)
+ _complain(f"legaldown-render: internal error: {error}")
return 70
_report(name, result.diagnostics, quiet=args.quiet)
@@ -194,7 +214,7 @@ def main(argv: list[str] | None = None) -> int:
try:
Path(args.output).write_text(result.output, encoding="utf-8", newline="\n")
except OSError as error:
- print(f"legaldown-render: cannot write {args.output}: {error.strerror or error}", file=sys.stderr)
+ _complain(f"legaldown-render: cannot write {args.output}: {error.strerror or error}")
return 1
else:
sys.stdout.write(result.output)
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 4d626b6..6954803 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -125,14 +125,17 @@
#: times this.
_MAX_TERM_TEXT = 1000
-#: The most term texts worked out in one document (each real computation, not
-#: a text taken from the cache), and the most terms open inside one another.
-#: Past either, a ``{{term:}}`` shows its label or definition id and the
-#: Warning ``render-term-limit`` is reported. A document of terms that name
-#: each other (k terms each naming all k) would otherwise take time
-#: exponential in k, and a long chain of terms would exhaust the stack. No
-#: real document comes near them.
-_MAX_TERM_RUNS = 2000
+#: The most inline nodes resolved, in one document, inside a term text that is
+#: worked out again (a second or later text of a term, for another set of open
+#: terms: only terms that name each other cause these), nested ones included;
+#: and the most terms open inside one another. Past either, a ``{{term:}}``
+#: shows its label or definition id and the Warning ``render-term-limit`` is
+#: reported. A term's first text costs nothing (it is bounded by the number of
+#: terms), so this bounds the extra work cycles can cause to a small multiple of
+#: the document, where k terms each naming all k would otherwise take time
+#: exponential in k, and a long chain of terms would exhaust the stack. No real
+#: document comes near them.
+_MAX_TERM_WORK = 200_000
_MAX_TERM_DEPTH = 32
#: How many texts (one per different set of open terms around it) are kept for
@@ -206,7 +209,9 @@ def __init__(
# because they were open.
self._seen: set[str] = set()
self._cut: set[str] = set()
- self._term_runs = 0
+ # Inline nodes resolved inside repeat computations (see _while_open).
+ self._term_work = 0
+ self._repeating = 0
# False until every target is registered and the placeholder types are
# known (run() sets it just before the inlines are resolved).
self._targets_known = False
@@ -401,6 +406,8 @@ def _title_text(self, title: tuple[Inline, ...]) -> str:
def text(inlines: tuple[Inline, ...]) -> str:
parts: list[str] = []
for inline in inlines:
+ if self._repeating:
+ self._term_work += 1
match inline:
case DirectiveSource(directive=directive):
if directive.name != "ref":
@@ -567,6 +574,8 @@ def _resolve_inlines(self, inlines: tuple[Inline, ...]) -> tuple[Inline, ...]:
return tuple(out)
def _resolve_inline(self, inline: Inline) -> Inline | tuple[Inline, ...] | None:
+ if self._repeating:
+ self._term_work += 1
match inline:
case DirectiveSource(directive=directive):
return self._directive(directive)
@@ -754,10 +763,13 @@ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, wor
A cycle can still ask for a great many different texts (k terms that
each name all k have more ways round than any cache holds), so the work
- is limited: past :data:`_MAX_TERM_DEPTH` terms open at once, or
- :data:`_MAX_TERM_RUNS` texts worked out in the document, the result is
- ``None`` (the reference shows its label or id) and the Warning
- ``render-term-limit`` says so, once.
+ is limited. A text worked out when the cache already holds one for
+ this term (for other open terms) is a repeat, and the inline nodes
+ resolved inside a repeat, nested ones included, are counted; first
+ texts are free. Past :data:`_MAX_TERM_WORK` such nodes, or with
+ :data:`_MAX_TERM_DEPTH` terms open at once, the result is ``None`` (the
+ reference shows its label or id), and the Warning ``render-term-limit``
+ says so, once.
"""
key = definition_id if key is None else key
around = set(self._terms_open)
@@ -766,7 +778,8 @@ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, wor
self._seen |= kept.seen
self._cut |= kept.cut
return kept.text
- if len(self._terms_open) >= _MAX_TERM_DEPTH or self._term_runs >= _MAX_TERM_RUNS:
+ repeat = bool(cache.get(key))
+ if len(self._terms_open) >= _MAX_TERM_DEPTH or (repeat and self._term_work >= _MAX_TERM_WORK):
# Terms that refer to each other this deeply or this often are
# not resolved further: like a cut, the reference shows its label
# or id, and nothing is kept.
@@ -776,7 +789,7 @@ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, wor
"some {{term:}} references show their definition id.",
)
return None
- self._term_runs += 1
+ self._repeating += repeat
self._terms_open.append(definition_id)
outer = self._seen, self._cut
self._seen, self._cut = set(), set()
@@ -786,6 +799,7 @@ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, wor
if len(cache.setdefault(key, [])) < _TERM_TEXTS_PER_TERM:
cache[key].append(_TermText(text, seen, cut))
finally:
+ self._repeating -= repeat
self._terms_open.pop()
self._seen = outer[0] | self._seen
self._cut = outer[1] | (self._cut & around)
diff --git a/tests/test_wp13_budget.py b/tests/test_wp13_budget.py
new file mode 100644
index 0000000..f9e9aed
--- /dev/null
+++ b/tests/test_wp13_budget.py
@@ -0,0 +1,162 @@
+"""Review fixes (WP13): the term budget charges only repeat work, and every
+command-line value echoed to stderr is escaped."""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+from legaldown_render import render
+
+SRC = Path(__file__).resolve().parents[1] / "src"
+FRONT = "---\ntitle: T\n---\n\n"
+
+
+def rules(result, rule: str) -> list[str]: # noqa: ANN001
+ return [d.message for d in result.diagnostics if d.rule == rule]
+
+
+# -- 1: acyclic terms are free ------------------------------------------------------
+
+
+def _independent(n: int, *, heading: bool) -> str:
+ parts = []
+ for i in range(n):
+ if heading:
+ parts.append(f"## H{i} {{{{term: t{i}}}}}\n\n")
+ parts.append(f'"Term {i}" {{{{def: t{i}}}}} used {{{{term: t{i}}}}}\n\n')
+ return "# A\n\n" + "".join(parts)
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("heading", [False, True])
+def test_3000_independent_terms_show_their_text_and_no_warning(scheme: str, heading: bool) -> None:
+ result = render(FRONT + _independent(3000, heading=heading), format="text", overrides={"numbering.scheme": scheme})
+ assert rules(result, "render-term-limit") == []
+ lines = result.output.splitlines()
+ assert "Term 2999 used Term 2999" in lines
+ assert "Term 0 used Term 0" in lines
+ assert not any(line.endswith(" used t2999") for line in lines)
+ if heading and scheme == "none":
+ assert any(line.endswith("H2999 Term 2999") for line in lines)
+
+
+# -- 2: a work budget on repeat computations ------------------------------------------
+
+
+def _ring(k: int, n: int) -> str:
+ """k terms in a cycle, each holding n references to the next."""
+ defs = [f'"T{i} {" ".join(f"{{{{term: t{(i + 1) % k}}}}}" for _ in range(n))}" {{{{def: t{i}}}}}' for i in range(k)]
+ return "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def test_a_ring_of_terms_with_many_references_costs_a_small_multiple_of_the_document() -> None:
+ document = FRONT + _ring(12, 2000)
+ started = time.perf_counter()
+ result = render(document, format="text", overrides={"numbering.scheme": "none"})
+ cyclic = time.perf_counter() - started
+ # The same document with the references pointing at a term that names nothing.
+ flat = document.replace("{{term: t0}}", "{{term: leaf}}")
+ for i in range(12):
+ flat = flat.replace(f"{{{{term: t{i}}}}}", "{{term: leaf}}")
+ flat += '\n"Leaf" {{def: leaf}}\n'
+ started = time.perf_counter()
+ render(flat, format="text", overrides={"numbering.scheme": "none"})
+ acyclic = time.perf_counter() - started
+ assert rules(result, "render-term-limit")
+ assert cyclic < 3 * acyclic + 1
+
+
+@pytest.mark.parametrize("k", [7, 12, 40])
+def test_all_to_all_terms_stay_fast(k: int) -> None:
+ defs = [f'"T{i} {" ".join(f"{{{{term: t{j}}}}}" for j in range(k))}" {{{{def: t{i}}}}}' for i in range(k)]
+ started = time.perf_counter()
+ result = render(FRONT + "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n", format="text")
+ assert time.perf_counter() - started < 2
+ assert "{{" not in result.output
+
+
+def test_a_chain_of_500_terms_stays_fast_and_reports_the_limit() -> None:
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(500)] + ['"End" {{def: t500}}']
+ started = time.perf_counter()
+ result = render(FRONT + "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n", format="text")
+ assert time.perf_counter() - started < 2
+ assert len(rules(result, "render-term-limit")) == 1
+
+
+# -- 3: command-line values are escaped on stderr -------------------------------------
+
+
+def _cli(*arguments: str, cwd: Path) -> subprocess.CompletedProcess[bytes]:
+ env = {**os.environ, "PYTHONPATH": str(SRC)}
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *arguments],
+ capture_output=True, cwd=cwd, env=env, check=False)
+
+
+def _clean(errors: bytes) -> str:
+ text = errors.decode("utf-8")
+ assert not any((ord(c) < 0x20 and c != "\n") or 0x7F <= ord(c) < 0xA0 for c in text), text
+ return text
+
+
+ESC = "x\x1b]0;pwn\x07y"
+
+
+def test_an_input_file_name_is_escaped(tmp_path: Path) -> None:
+ result = _cli(ESC + ".lgd", cwd=tmp_path)
+ assert result.returncode == 1
+ assert "cannot read" in _clean(result.stderr) and "\\x1b" in _clean(result.stderr)
+
+
+def test_an_answers_path_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ result = _cli(str(document), "--answers", ESC + ".yaml", cwd=tmp_path)
+ assert result.returncode == 1
+ assert "\\x1b" in _clean(result.stderr)
+
+
+def test_an_output_path_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ result = _cli(str(document), "-o", "no-such-dir\x1b[2J/out.html", cwd=tmp_path)
+ assert result.returncode == 1
+ assert "\\x1b" in _clean(result.stderr)
+ refused = _cli(str(document), "-o", "x\x1b[2J.pdf", cwd=tmp_path)
+ assert refused.returncode == 2
+ assert "\\x1b" in _clean(refused.stderr)
+
+
+def test_a_style_name_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ for arguments in (["-s", ESC], ["-s", ESC, "--print-style"]):
+ result = _cli(str(document), *arguments, cwd=tmp_path)
+ assert result.returncode == 2
+ assert "\\x1b" in _clean(result.stderr)
+
+
+def test_a_locale_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ result = _cli(str(document), "--locale", "zz\x1b[31m", cwd=tmp_path)
+ _clean(result.stderr)
+
+
+def test_a_set_key_is_escaped_in_argparse_errors(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ for setting in ("bad\x1b[31m", "no\x1b[31m.such=1"):
+ result = _cli(str(document), "--set", setting, cwd=tmp_path)
+ assert result.returncode == 2
+ assert "\\x1b" in _clean(result.stderr)
+
+
+def test_an_unrecognized_argument_is_escaped(tmp_path: Path) -> None:
+ result = _cli("--bogus\x1b[31m", cwd=tmp_path)
+ assert result.returncode == 2
+ assert "\\x1b" in _clean(result.stderr)
From 1336bbb9ab3230bd7acedbbfb9736cdae535ab06 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 21:33:26 +0000
Subject: [PATCH 030/118] Reorganise the tests by topic
The tests that grew in modules named after the order they were written in
are now grouped by what they check: values, numbering, links, building the
tree, defined terms (text and budget), the plain-text writer, the HTML
print stylesheet, built-in labels, style validation, the command line (options
and byte-level input and output), errors and escaping of diagnostics.
Shared helpers (the minimal document header, the text and html renderers,
and running the command as a program) are in conftest.py. Docstrings and
comments no longer refer to internal work items.
Tests that asserted the same thing twice are merged or dropped: the
extension and link-scheme cases that appeared in two modules, the all-to-all
and 500-term chain cases of the term budget, and a few single assertions
already covered by an existing test.
---
tests/conftest.py | 39 +++
tests/{test_wp4_build.py => test_build.py} | 148 ++--------
tests/test_cli.py | 24 +-
tests/{test_wp2_cli.py => test_cli_io.py} | 250 ++++++++--------
tests/test_errors.py | 78 +++++
tests/test_escaping.py | 130 +++++++++
tests/test_html.py | 34 +++
tests/{test_wp8_labels.py => test_labels.py} | 5 +-
tests/{test_wp3_links.py => test_links.py} | 133 ++++++---
tests/test_numbering.py | 71 +++++
tests/test_render.py | 31 +-
tests/test_style.py | 6 +-
..._wp6_style.py => test_style_validation.py} | 104 +++++--
tests/test_terms.py | 211 ++++++++++++++
tests/test_terms_budget.py | 219 ++++++++++++++
.../{test_wp5_text.py => test_text_writer.py} | 45 ++-
tests/test_values.py | 275 +++++++++++++++++-
tests/test_wp10_review_fixes.py | 220 --------------
tests/test_wp11_review_fixes.py | 253 ----------------
tests/test_wp12_final_fixes.py | 150 ----------
tests/test_wp13_budget.py | 162 -----------
tests/test_wp1_values.py | 206 -------------
tests/test_wp7_small.py | 106 -------
tests/test_wp9_review_fixes.py | 200 -------------
24 files changed, 1429 insertions(+), 1671 deletions(-)
rename tests/{test_wp4_build.py => test_build.py} (60%)
rename tests/{test_wp2_cli.py => test_cli_io.py} (51%)
create mode 100644 tests/test_errors.py
create mode 100644 tests/test_escaping.py
create mode 100644 tests/test_html.py
rename tests/{test_wp8_labels.py => test_labels.py} (90%)
rename tests/{test_wp3_links.py => test_links.py} (64%)
create mode 100644 tests/test_numbering.py
rename tests/{test_wp6_style.py => test_style_validation.py} (74%)
create mode 100644 tests/test_terms.py
create mode 100644 tests/test_terms_budget.py
rename tests/{test_wp5_text.py => test_text_writer.py} (78%)
delete mode 100644 tests/test_wp10_review_fixes.py
delete mode 100644 tests/test_wp11_review_fixes.py
delete mode 100644 tests/test_wp12_final_fixes.py
delete mode 100644 tests/test_wp13_budget.py
delete mode 100644 tests/test_wp1_values.py
delete mode 100644 tests/test_wp7_small.py
delete mode 100644 tests/test_wp9_review_fixes.py
diff --git a/tests/conftest.py b/tests/conftest.py
index 61a55d5..a77cca9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,13 +1,52 @@
from __future__ import annotations
import os
+import subprocess
+import sys
from pathlib import Path
import pytest
+from legaldown_render import render
+
ROOT = Path(__file__).parent
+SRC = Path(__file__).resolve().parents[1] / "src"
DOCUMENTS = ROOT / "documents"
GOLDEN = ROOT / "golden"
+FEATURES = str(DOCUMENTS / "features.lgd")
+
+#: The smallest valid header, for tests whose body is all that matters.
+FRONT = "---\ntitle: T\n---\n"
+
+
+def text(body: str, **settings: object) -> str:
+ """The plain-text rendering of *body* under :data:`FRONT`."""
+ return render(FRONT + body, format="text", **settings).output
+
+
+def html(body: str, **settings: object) -> str:
+ """The HTML rendering of *body* under :data:`FRONT`."""
+ return render(FRONT + body, format="html", **settings).output
+
+
+def run_cli(*args: str, stdin: bytes | str | None = None, cwd: Path | None = None,
+ narrow: bool = False) -> subprocess.CompletedProcess[bytes]:
+ """Run ``legaldown-render`` as a program on this checkout's sources.
+
+ *narrow* gives it a Windows-like console, where neither stream is UTF-8."""
+ env = dict(os.environ, PYTHONPATH=str(SRC))
+ if narrow:
+ env.update(PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
+ data = stdin.encode("utf-8") if isinstance(stdin, str) else stdin
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=data,
+ capture_output=True, cwd=cwd, env=env, check=False)
+
+
+def run_cli_text(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
+ """Like :func:`run_cli`, with UTF-8 text for the streams."""
+ env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONUTF8="1")
+ return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
+ capture_output=True, text=True, env=env, check=False)
def pytest_addoption(parser: pytest.Parser) -> None:
diff --git a/tests/test_wp4_build.py b/tests/test_build.py
similarity index 60%
rename from tests/test_wp4_build.py
rename to tests/test_build.py
index 6862f29..38020f9 100644
--- a/tests/test_wp4_build.py
+++ b/tests/test_build.py
@@ -1,17 +1,17 @@
-"""Build fidelity (WP4): what the builder keeps of a comment-only item or
-quote (B2), how emphasis next to a directive parses (B3), the spacing where
-an inline comment was dropped (B4), and what ``{{term:}}`` shows (B8)."""
+"""Building the tree from the validator's document: what is kept of a
+comment-only list item or quote (§8.6), how emphasis next to a directive
+parses, and the spacing where an inline comment was dropped."""
from __future__ import annotations
import html as html_escape
import unicodedata
import pytest
+from conftest import FRONT, html, text
from legaldown import parse, validate
from markdown_it import MarkdownIt
from markdown_it.common.utils import isPunctChar
-from legaldown_render import render
from legaldown_render.build import _WRAPPERS, build_tree
from legaldown_render.tree import (
DirectiveSource,
@@ -23,16 +23,6 @@
Text,
)
-FRONT = "---\ntitle: T\n---\n"
-
-
-def text(body: str, **settings: object) -> str:
- return render(FRONT + body, format="text", **settings).output
-
-
-def html(body: str, **settings: object) -> str:
- return render(FRONT + body, format="html", **settings).output
-
def built(body: str):
document = parse(FRONT + body)
@@ -43,8 +33,7 @@ def blocks_of(body: str):
return built(body).sections[0].blocks
-# -- B2: comment-only list items and quotes ----------------------------------
-
+# -- Comment-only list items and quotes ------------------------------------------
def test_a_list_item_holding_only_a_comment_is_dropped() -> None:
output = text("# A\n\n- a\n- \n- c\n")
@@ -69,13 +58,6 @@ def test_a_list_left_with_no_items_is_dropped() -> None:
assert "\n")
-def test_an_empty_item_keeps_its_place_but_a_comment_only_one_does_not() -> None:
- # The validator keeps an empty item (CommonMark); a comment is the
- # author's way to remove one.
- output = text("# A\n\n- \n- \n- b\n")
- assert "(a)\n(b) b" in output
-
-
def test_an_item_with_a_comment_and_text_stays() -> None:
assert "(a) kept" in text("# A\n\n- kept \n")
@@ -111,8 +93,25 @@ def test_a_comment_only_item_does_not_leave_an_empty_list_in_an_item() -> None:
assert "(a) a\n(b) b" in output
-# -- B3: emphasis flanking next to a directive -------------------------------
+def test_a_comment_only_item_with_a_marker_is_kept() -> None:
+ # Defensive: an item a marker belongs to never vanishes. The
+ # validator does not place a marker on a comment-only item, so a marker
+ # is made up here to drive the builder's guard.
+ from legaldown import PlacedMarker
+
+ from legaldown_render.build import _Builder
+
+ document = parse(FRONT + "# A\n\n- \n- b\n")
+ result = validate(document)
+ marker = PlacedMarker(section=0, block=0, fragment=0, offset=0, source="",
+ identifier="x", condition="", item=0)
+ out = _Builder(document, result).list(document.sections[0].blocks[0], [marker])
+ assert out is not None and [item.anchor_id for item in out.items] == ["x", ""]
+ plain = _Builder(document, result).list(document.sections[0].blocks[0], [])
+ assert plain is not None and len(plain.items) == 1
+
+# -- Emphasis flanking next to a directive ---------------------------------------
def _signature(inlines) -> str:
out = []
@@ -173,8 +172,7 @@ def test_wrappers_do_not_leak_into_code_urls_or_alt_text() -> None:
assert char not in output
-# -- B4: spacing where an inline comment was dropped -------------------------
-
+# -- Spacing where an inline comment was dropped ---------------------------------
@pytest.mark.parametrize(("body", "expected"), [
("one \ntwo\n", "one two"),
@@ -201,101 +199,3 @@ def test_a_dropped_comment_between_text_and_a_hard_break_leaves_no_space() -> No
paragraph = blocks_of("# A\n\none \\\ntwo\n")[0]
first = paragraph.inlines[0]
assert isinstance(first, Text) and first.text == "one"
-
-
-# -- B8: what {{term:}} shows ------------------------------------------------
-
-
-def test_a_term_reference_shows_the_rendered_text_of_the_term() -> None:
- output = text('# A\n\n"A *b* c" {{def: abc}} means it.\n\nSee {{term: abc}}.\n')
- assert "See A b c." in output
- assert "*" not in output.split("See", 1)[1]
-
-
-def test_a_term_reference_label_still_wins() -> None:
- output = text('# A\n\n"A *b* c" {{def: abc}} means it.\n\nSee {{term: abc, label=the thing}}.\n')
- assert "See the thing." in output
-
-
-def test_a_term_reference_resolves_directives_inside_the_term() -> None:
- body = '# A {#a}\n\n"Fee **under** {{ref: a}}" {{def: fee}} means it.\n\nSee {{term: fee}}.\n'
- output = text(body)
- assert "See Fee under" in output
-
-
-def test_a_term_reference_uses_the_first_definition() -> None:
- body = '# A\n\n"First *one*" {{def: t}} here.\n\n"Second" {{def: t}} there.\n\nSee {{term: t}}.\n'
- assert "See First one." in text(body)
-
-
-def test_a_term_reference_before_its_definition() -> None:
- body = '# A\n\nSee {{term: abc}} first.\n\n"A *b* c" {{def: abc}} means it.\n'
- assert "See A b c first." in text(body)
-
-
-def test_a_plain_term_reference_is_unchanged() -> None:
- assert "See Fee." in text('# A\n\n"Fee" {{def: fee}} means it.\n\nSee {{term: fee}}.\n')
-
-
-def test_an_empty_term_falls_back_to_the_validator() -> None:
- output = text('# A\n\n"" {{def: late-fee}} means it.\n\nSee {{term: late-fee}}.\n')
- assert "See Late Fee." in output
-
-
-def test_an_undefined_term_is_still_reported() -> None:
- assert "[UNDEFINED: nope]" in text("# A\n\nSee {{term: nope}}.\n")
-
-
-# -- B8: a term that refers to itself ----------------------------------------
-
-SELF = '# A\n\n"Self {{term: s}}" {{def: s}} means it.\n\nSee {{term: s}} and {{term: s, label=the label}}.\n'
-INDIRECT = ('# A\n\n"A {{term: b}}" {{def: a}} x.\n\n"B {{term: a}}" {{def: b}} y.\n\n'
- "See {{term: a}}, {{term: b}} and {{term: a, label=the label}}.\n")
-
-
-@pytest.mark.parametrize("output_format", ["text", "html"])
-@pytest.mark.parametrize("body", [SELF, INDIRECT], ids=["direct", "indirect"])
-def test_a_cyclic_term_renders_in_both_writers(body: str, output_format: str) -> None:
- result = render(FRONT + body, format=output_format)
- assert "the label" in result.output
- assert "[UNDEFINED" not in result.output
-
-
-def test_a_term_inside_its_own_definition_shows_its_id() -> None:
- output = text(SELF)
- # At the definition and at a reference alike: the inner reference shows
- # the id (its label when it has one), and goes no further.
- assert "Self Self s means it." in output
- assert "See Self s and the label." in output
-
-
-def test_a_term_reference_in_a_cycle_shows_its_label_else_its_id() -> None:
- output = text(INDIRECT)
- assert output.rstrip().endswith("and the label.")
- # No term shows its own reference as raw directive source.
- assert "{{" not in output
-
-
-def test_a_cycle_through_a_reference_site_inside_another_terms_definition() -> None:
- body = ('# A\n\n"A" {{def: a}} x.\n\n"B {{term: a}} and {{term: c}}" {{def: b}} y.\n\n'
- '"C {{term: b}}" {{def: c}} z.\n\nSee {{term: b}}.\n')
- assert "See B A and" in text(body)
- assert "B A and" in html(body)
-
-
-def test_a_comment_only_item_with_a_marker_is_kept() -> None:
- # Defensive (B2): an item a marker belongs to never vanishes. The
- # validator does not place a marker on a comment-only item, so a marker
- # is made up here to drive the builder's guard.
- from legaldown import PlacedMarker
-
- from legaldown_render.build import _Builder
-
- document = parse(FRONT + "# A\n\n- \n- b\n")
- result = validate(document)
- marker = PlacedMarker(section=0, block=0, fragment=0, offset=0, source="",
- identifier="x", condition="", item=0)
- out = _Builder(document, result).list(document.sections[0].blocks[0], [marker])
- assert out is not None and [item.anchor_id for item in out.items] == ["x", ""]
- plain = _Builder(document, result).list(document.sections[0].blocks[0], [])
- assert plain is not None and len(plain.items) == 1
diff --git a/tests/test_cli.py b/tests/test_cli.py
index aad5ff3..c3565d7 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,14 +1,18 @@
+"""The command line: the format from ``-o``, ``--style``, ``--set``,
+``--locale``, ``--strict``, ``--final``, ``--answers``, ``--print-style`` and
+``--list-styles``, their exit codes, and the diagnostics on standard error. How
+output is encoded and written is in test_cli_io.py."""
from __future__ import annotations
from pathlib import Path
import pytest
-from conftest import DOCUMENTS
+from conftest import DOCUMENTS, FEATURES
+import legaldown_render.cli as cli
+from legaldown_render import StyleError
from legaldown_render.cli import main
-FEATURES = str(DOCUMENTS / "features.lgd")
-
def test_render_to_file_infers_the_format(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
out = tmp_path / "out.txt"
@@ -90,3 +94,17 @@ def test_set_keeps_text_values_as_written(capsys: pytest.CaptureFixture[str]) ->
def test_quote_bounded_literal_setting_is_kept(capsys: pytest.CaptureFixture[str]) -> None:
assert main([FEATURES, "-f", "text", "-q", "--set", 'references.format="§" {designation} "x"']) == 0
assert 'under Section "§" 2.1 "x".' in capsys.readouterr().out
+
+
+def test_set_style_error_is_shown_by_argparse(monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ def refuse(key: str, text: str) -> object:
+ raise StyleError("overrides", [f"{key}: the value is no good"])
+
+ monkeypatch.setattr(cli, "parse_override", refuse)
+ with pytest.raises(SystemExit) as exit_info:
+ main([FEATURES, "--set", "numbering.scheme=oops"])
+ assert exit_info.value.code == 2
+ err = capsys.readouterr().err
+ assert "numbering.scheme: the value is no good" in err
+ assert "--set" in err
diff --git a/tests/test_wp2_cli.py b/tests/test_cli_io.py
similarity index 51%
rename from tests/test_wp2_cli.py
rename to tests/test_cli_io.py
index 1d1e199..58da13c 100644
--- a/tests/test_wp2_cli.py
+++ b/tests/test_cli_io.py
@@ -1,59 +1,39 @@
-"""CLI and API robustness: stream encodings (S1), non-UTF-8 input (S4), an
-unwritable output (S5), an unknown output extension (S6), the error
-hierarchy (S2), picklable errors (S3), and the ``--set`` error path (S10, CLI
-side)."""
+"""How the command line reads and writes bytes: UTF-8 whatever the console's
+encoding, a clean error for input that is not UTF-8 or output that cannot be
+written, LF line endings, in-process ``main()`` leaving the streams alone, and
+the rule that ``-o`` names a format by its extension (§13.8)."""
from __future__ import annotations
import io
-import os
-import pickle
-import subprocess
import sys
from pathlib import Path
import pytest
-from conftest import DOCUMENTS
-from legaldown import Diagnostic
+from conftest import FEATURES, SRC, run_cli
import legaldown_render
-import legaldown_render.cli as cli
-from legaldown_render import (
- DocumentError,
- OptionsError,
- RenderError,
- RenderOptions,
- RenderRefused,
- StyleError,
- load_style,
- render,
- render_file,
-)
+from legaldown_render import DocumentError, StyleError, load_style, render, render_file
from legaldown_render.cli import main
+from legaldown_render.writers import file_extension, format_for_path
-SRC = Path(__file__).resolve().parents[1] / "src"
-FEATURES = str(DOCUMENTS / "features.lgd")
CZECH = "---\nlegaldown: '0.2'\ntitle: Smlouva o dílo\nlanguage: cs\n---\n\n# Příloha\n\nŽluťoučký kůň úpěl ďábelské ódy – “citát” → konec, ōsaka.\n"
-def _run(args: list[str], stdin: bytes | None = None, *, narrow: bool = True) -> subprocess.CompletedProcess[bytes]:
- env = dict(os.environ, PYTHONPATH=str(SRC))
- if narrow:
- # A Windows-like console: neither stream is UTF-8.
- env.update(PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
- capture_output=True, env=env, check=False)
+def _narrow(args: list[str], stdin: bytes | None = None): # type: ignore[no-untyped-def]
+ """The program on a Windows-like console: neither stream is UTF-8."""
+ return run_cli(*args, stdin=stdin, narrow=True)
def test_the_package_under_test_is_this_worktree() -> None:
assert Path(legaldown_render.__file__).resolve().is_relative_to(SRC)
-# --- S1: encodings ---------------------------------------------------------------------------
+# -- Encodings -------------------------------------------------------------------
@pytest.mark.parametrize("fmt", ["html", "text"])
def test_stdin_to_stdout_is_utf8_whatever_the_console_encoding(fmt: str) -> None:
expected = render(CZECH, format=fmt).output.encode("utf-8")
- done = _run(["-", "-f", fmt, "-q"], CZECH.encode("utf-8"))
+ done = _narrow(["-", "-f", fmt, "-q"], CZECH.encode("utf-8"))
assert done.returncode == 0, done.stderr
# Exact bytes: the output is UTF-8 with LF line endings on every platform, so
# the same comparison holds on Windows (no CRLF translation of stdout).
@@ -64,27 +44,27 @@ def test_stdin_to_stdout_is_utf8_whatever_the_console_encoding(fmt: str) -> None
def test_file_to_stdout_is_utf8_whatever_the_console_encoding(tmp_path: Path) -> None:
path = tmp_path / "smlouva.lgd"
path.write_bytes(CZECH.encode("utf-8"))
- done = _run([str(path), "-f", "text", "-q"])
+ done = _narrow([str(path), "-f", "text", "-q"])
assert done.returncode == 0, done.stderr
# Exact bytes, as above: UTF-8 with LF line endings on every platform.
assert done.stdout == render(CZECH, format="text").output.encode("utf-8")
def test_print_style_survives_a_narrow_console() -> None:
- done = _run(["--print-style", "--style", "continental", "--set", "placeholders.blank=…"])
+ done = _narrow(["--print-style", "--style", "continental", "--set", "placeholders.blank=…"])
assert done.returncode == 0, done.stderr
assert "…".encode() in done.stdout
def test_stderr_never_crashes_on_a_narrow_console() -> None:
- done = _run(["žlutý→.lgd"])
+ done = _narrow(["žlutý→.lgd"])
assert done.returncode == 1
assert b"Traceback" not in done.stderr
assert b"cannot read" in done.stderr
assert b"\\u2192" in done.stderr # not encodable in cp1252: backslash-escaped, not an error
-# --- S4: not UTF-8 ---------------------------------------------------------------------------
+# -- Input that is not UTF-8 -----------------------------------------------------
def test_latin1_input_file_is_a_clean_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = tmp_path / "latin1.lgd"
@@ -137,7 +117,7 @@ def test_render_file_raises_document_error_for_non_utf8(tmp_path: Path) -> None:
assert "Příloha" in render_file(good, format="text").output
-# --- S5: unwritable output -------------------------------------------------------------------
+# -- Output that cannot be written -----------------------------------------------
def test_unwritable_output_is_a_clean_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
target = tmp_path / "missing-folder" / "out.html"
@@ -152,10 +132,96 @@ def test_output_that_is_a_directory(tmp_path: Path, capsys: pytest.CaptureFixtur
assert "cannot write" in capsys.readouterr().err
-# --- S6: an extension of a format not written -----------------------------------------------
+# -- Streams and line endings ----------------------------------------------------
+
+def _wrapper(encoding: str, **options: object) -> io.TextIOWrapper:
+ return io.TextIOWrapper(io.BytesIO(), encoding=encoding, **options) # type: ignore[arg-type]
+
+
+def test_in_process_main_leaves_the_streams_alone(monkeypatch: pytest.MonkeyPatch) -> None:
+ out, err = _wrapper("cp1252", newline="\r\n"), _wrapper("cp1252")
+ monkeypatch.setattr(sys, "stdout", out)
+ monkeypatch.setattr(sys, "stderr", err)
+ assert main([FEATURES, "-f", "text", "-q"]) == 0
+ assert main(["--list-styles"]) == 0
+ assert (sys.stdout.encoding, sys.stdout.errors) == ("cp1252", "strict")
+ assert (sys.stderr.encoding, sys.stderr.errors) == ("cp1252", "strict")
+ out.flush()
+ # Not even the newline translation of the caller's stream changed.
+ assert b"\r\n" in out.buffer.getvalue() # type: ignore[attr-defined]
+
+
+def test_main_as_the_program_reconfigures_the_streams(monkeypatch: pytest.MonkeyPatch) -> None:
+ out, err = _wrapper("cp1252", newline="\r\n"), _wrapper("cp1252")
+ monkeypatch.setattr(sys, "stdout", out)
+ monkeypatch.setattr(sys, "stderr", err)
+ monkeypatch.setattr(sys, "argv", ["legaldown-render", "--list-styles"])
+ assert main() == 0
+ out.flush()
+ assert (out.encoding, err.errors) == ("utf-8", "backslashreplace")
+ data = out.buffer.getvalue() # type: ignore[attr-defined]
+ assert data and b"\r" not in data and data.endswith(b"\n")
+
+
+def test_the_console_script_calls_main_without_argv() -> None:
+ # The script entry point calls main() with no argument, so it is "the program".
+ pyproject = (SRC.parent / "pyproject.toml").read_text(encoding="utf-8")
+ assert 'legaldown-render = "legaldown_render.cli:main"' in pyproject
+
+
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_standard_output_has_lf_line_endings(fmt: str) -> None:
+ done = _narrow([FEATURES, "-f", fmt, "-q"])
+ assert done.returncode == 0, done.stderr
+ assert b"\n" in done.stdout and b"\r" not in done.stdout
+
+
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_an_output_file_has_lf_line_endings(tmp_path: Path, fmt: str) -> None:
+ target = tmp_path / "out.bin"
+ assert main([FEATURES, "-q", "-f", fmt, "-o", str(target)]) == 0
+ data = target.read_bytes()
+ assert b"\n" in data and b"\r" not in data
+ assert data == render(Path(FEATURES).read_text(encoding="utf-8"), format=fmt).output.encode("utf-8")
+
+
+def test_an_output_file_is_written_without_newline_translation(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ # Windows' text mode would turn "\n" into CRLF unless the file is opened
+ # with newline="\n": that is what the CLI asks for.
+ calls: list[dict[str, object]] = []
+ real = Path.write_text
+
+ def spy(self: Path, data: str, *args: object, **kwargs: object) -> int:
+ calls.append(kwargs)
+ return real(self, data, *args, **kwargs) # type: ignore[arg-type]
+
+ monkeypatch.setattr(Path, "write_text", spy)
+ assert main([FEATURES, "-q", "-o", str(tmp_path / "out.txt")]) == 0
+ assert calls == [{"encoding": "utf-8", "newline": "\n"}]
+
+
+# -- The extension of -o sets the format, but only for a format that is written ----
+
+@pytest.mark.parametrize(("name", "extension"), [
+ ("x.pdf", "pdf"), (".pdf", "pdf"), (".md", "md"), ("x.pdf.", "pdf"), ("x.docx ", "docx"),
+ ("x.pdf. .", "pdf"), ("X.PDF", "pdf"), ("a.b.txt", "txt"), ("contract", ""), ("", ""), (".", ""),
+ ("..", ""), ("x.", ""),
+])
+def test_the_extension_of_a_file_name(name: str, extension: str) -> None:
+ assert file_extension(name) == extension
+
+
+@pytest.mark.parametrize(("name", "expected"), [
+ (".txt", "text"), ("x.txt.", "text"), ("x.html ", "html"), (".htm", "html"), ("x.unknown", None),
+])
+def test_the_format_a_file_name_implies(name: str, expected: str | None) -> None:
+ assert format_for_path(name) == expected
+
@pytest.mark.parametrize("name", ["x.docx", "x.pdf", "x.md", "x.DOCX", "x.doc", "x.odt", "x.rtf", "x.markdown",
- "x.epub", "x.tex", "x.v2.Pdf"])
+ "x.epub", "x.tex", "x.v2.Pdf", ".pdf", ".md", "x.pdf.", "x.docx ", "x.docx. .",
+ "dir.pdf", "x.MD"])
def test_unknown_output_extension_without_format_is_a_usage_error(
tmp_path: Path, capsys: pytest.CaptureFixture[str], name: str) -> None:
target = tmp_path / name
@@ -166,10 +232,12 @@ def test_unknown_output_extension_without_format_is_a_usage_error(
assert f"cannot tell the output format from '{target}'" in err
assert "--format html or text" in err
assert not target.exists()
+ assert list(tmp_path.iterdir()) == []
-def test_explicit_format_is_honoured_whatever_the_extension(tmp_path: Path) -> None:
- target = tmp_path / "x.docx"
+@pytest.mark.parametrize("name", ["x.docx", "x.pdf.", ".md", "x.docx "])
+def test_explicit_format_is_honoured_whatever_the_extension(tmp_path: Path, name: str) -> None:
+ target = tmp_path / name
assert main([FEATURES, "-q", "-f", "text", "-o", str(target)]) == 0
assert target.read_text(encoding="utf-8").startswith("Services Agreement")
@@ -181,19 +249,22 @@ def test_output_without_an_extension_keeps_the_html_default(tmp_path: Path) -> N
assert target.read_text(encoding="utf-8").startswith("")
-@pytest.mark.parametrize("name", ["contract.v2", "report.2025-10-27", ".out", "out.", "notes.final", "x.json"])
-def test_any_other_extension_keeps_the_html_default(tmp_path: Path, name: str) -> None:
+@pytest.mark.parametrize("name", ["contract", "contract.v2", "report.2025-10-27", ".out", "out.", "notes.final",
+ "x.json", "x.."])
+def test_any_other_name_keeps_the_html_default(tmp_path: Path, name: str) -> None:
target = tmp_path / name
assert main([FEATURES, "-q", "-o", str(target)]) == 0
assert target.read_text(encoding="utf-8").startswith("")
-def test_a_known_extension_still_sets_the_format(tmp_path: Path) -> None:
- for name, start in (("a.TXT", "Services Agreement"), ("a.text", "Services Agreement"),
- ("a.htm", ""), ("a.html", "")):
- target = tmp_path / name
- assert main([FEATURES, "-q", "-o", str(target)]) == 0
- assert target.read_text(encoding="utf-8").startswith(start)
+@pytest.mark.parametrize(("name", "start"), [
+ ("a.TXT", "Services Agreement"), ("a.text", "Services Agreement"), ("a.htm", ""),
+ ("a.html", ""), (".txt", "Services Agreement"), ("x.txt.", "Services Agreement"),
+])
+def test_a_known_extension_still_sets_the_format(tmp_path: Path, name: str, start: str) -> None:
+ target = tmp_path / name
+ assert main([FEATURES, "-q", "-o", str(target)]) == 0
+ assert target.read_text(encoding="utf-8").startswith(start)
def test_the_help_states_the_output_rule(capsys: pytest.CaptureFixture[str]) -> None:
@@ -203,84 +274,3 @@ def test_the_help_states_the_output_rule(capsys: pytest.CaptureFixture[str]) ->
assert "its extension sets the format" not in help_text
assert "any other name, with or without an extension, writes HTML" in help_text
assert ".docx, .doc, .odt, .rtf, .pdf, .md, .markdown, .epub, .tex" in help_text
-
-
-# --- S2: error hierarchy ---------------------------------------------------------------------
-
-def test_style_error_is_a_render_error_and_still_a_value_error() -> None:
- assert issubclass(StyleError, RenderError)
- assert issubclass(StyleError, ValueError)
-
-
-def test_style_error_is_importable_from_where_it_always_was() -> None:
- from legaldown_render import errors
- from legaldown_render.style import StyleError as from_style
- from legaldown_render.style.loader import StyleError as from_loader
-
- assert errors.StyleError is StyleError is from_style is from_loader
-
-
-def test_unknown_format_raises_options_error() -> None:
- assert issubclass(OptionsError, RenderError)
- assert issubclass(OptionsError, ValueError)
- with pytest.raises(OptionsError, match="unknown output format 'pdf'"):
- render("# x\n", RenderOptions(format="pdf"))
- with pytest.raises(OptionsError):
- render("# x\n", format="docx")
- with pytest.raises(RenderError):
- render("# x\n", format="docx")
-
-
-def test_invalid_style_through_the_api_is_a_render_error() -> None:
- with pytest.raises(RenderError):
- render("# x\n", style="no-such-style")
-
-
-# --- S3: pickling ----------------------------------------------------------------------------
-
-def test_render_refused_round_trips_through_pickle() -> None:
- diagnostics = [Diagnostic(rule="ref-broken", level="error", message="No such target", line=3)]
- for reason in (None, "Assembly refused: 1 error(s)."):
- error = RenderRefused(diagnostics, reason)
- copy = pickle.loads(pickle.dumps(error))
- assert type(copy) is RenderRefused
- assert copy.diagnostics == diagnostics
- assert str(copy) == str(error)
- assert copy.reason == error.reason
-
-
-def test_a_real_refusal_round_trips_through_pickle() -> None:
- with pytest.raises(RenderRefused) as caught:
- render(Path(FEATURES).read_text(encoding="utf-8"), strict=True)
- copy = pickle.loads(pickle.dumps(caught.value))
- assert copy.diagnostics == caught.value.diagnostics
- assert str(copy) == str(caught.value)
-
-
-def test_style_error_round_trips_through_pickle() -> None:
- error = StyleError("overrides", ["a.b: wrong", "c: also wrong"])
- copy = pickle.loads(pickle.dumps(error))
- assert type(copy) is StyleError
- assert (copy.source, copy.problems) == ("overrides", ["a.b: wrong", "c: also wrong"])
- assert str(copy) == str(error)
-
-
-def test_options_error_round_trips_through_pickle() -> None:
- error = OptionsError("unknown output format 'pdf'")
- assert str(pickle.loads(pickle.dumps(error))) == str(error)
-
-
-# --- S10 (CLI side): a StyleError from --set reaches the user ---------------------------------
-
-def test_set_style_error_is_shown_by_argparse(monkeypatch: pytest.MonkeyPatch,
- capsys: pytest.CaptureFixture[str]) -> None:
- def refuse(key: str, text: str) -> object:
- raise StyleError("overrides", [f"{key}: the value is no good"])
-
- monkeypatch.setattr(cli, "parse_override", refuse)
- with pytest.raises(SystemExit) as exit_info:
- main([FEATURES, "--set", "numbering.scheme=oops"])
- assert exit_info.value.code == 2
- err = capsys.readouterr().err
- assert "numbering.scheme: the value is no good" in err
- assert "--set" in err
diff --git a/tests/test_errors.py b/tests/test_errors.py
new file mode 100644
index 0000000..1968390
--- /dev/null
+++ b/tests/test_errors.py
@@ -0,0 +1,78 @@
+"""The exception hierarchy: every error the renderer raises is a
+``RenderError``, ``StyleError`` and ``OptionsError`` are still ``ValueError``,
+``StyleError`` is importable from where it always was, and the errors survive
+pickling (for a process pool)."""
+from __future__ import annotations
+
+import pickle
+from pathlib import Path
+
+import pytest
+from conftest import FEATURES
+from legaldown import Diagnostic
+
+from legaldown_render import OptionsError, RenderError, RenderOptions, RenderRefused, StyleError, render
+
+# -- Hierarchy -------------------------------------------------------------------
+
+def test_style_error_is_a_render_error_and_still_a_value_error() -> None:
+ assert issubclass(StyleError, RenderError)
+ assert issubclass(StyleError, ValueError)
+
+
+def test_style_error_is_importable_from_where_it_always_was() -> None:
+ from legaldown_render import errors
+ from legaldown_render.style import StyleError as from_style
+ from legaldown_render.style.loader import StyleError as from_loader
+
+ assert errors.StyleError is StyleError is from_style is from_loader
+
+
+def test_unknown_format_raises_options_error() -> None:
+ assert issubclass(OptionsError, RenderError)
+ assert issubclass(OptionsError, ValueError)
+ with pytest.raises(OptionsError, match="unknown output format 'pdf'"):
+ render("# x\n", RenderOptions(format="pdf"))
+ with pytest.raises(OptionsError):
+ render("# x\n", format="docx")
+ with pytest.raises(RenderError):
+ render("# x\n", format="docx")
+
+
+def test_invalid_style_through_the_api_is_a_render_error() -> None:
+ with pytest.raises(RenderError):
+ render("# x\n", style="no-such-style")
+
+
+# -- Pickling --------------------------------------------------------------------
+
+def test_render_refused_round_trips_through_pickle() -> None:
+ diagnostics = [Diagnostic(rule="ref-broken", level="error", message="No such target", line=3)]
+ for reason in (None, "Assembly refused: 1 error(s)."):
+ error = RenderRefused(diagnostics, reason)
+ copy = pickle.loads(pickle.dumps(error))
+ assert type(copy) is RenderRefused
+ assert copy.diagnostics == diagnostics
+ assert str(copy) == str(error)
+ assert copy.reason == error.reason
+
+
+def test_a_real_refusal_round_trips_through_pickle() -> None:
+ with pytest.raises(RenderRefused) as caught:
+ render(Path(FEATURES).read_text(encoding="utf-8"), strict=True)
+ copy = pickle.loads(pickle.dumps(caught.value))
+ assert copy.diagnostics == caught.value.diagnostics
+ assert str(copy) == str(caught.value)
+
+
+def test_style_error_round_trips_through_pickle() -> None:
+ error = StyleError("overrides", ["a.b: wrong", "c: also wrong"])
+ copy = pickle.loads(pickle.dumps(error))
+ assert type(copy) is StyleError
+ assert (copy.source, copy.problems) == ("overrides", ["a.b: wrong", "c: also wrong"])
+ assert str(copy) == str(error)
+
+
+def test_options_error_round_trips_through_pickle() -> None:
+ error = OptionsError("unknown output format 'pdf'")
+ assert str(pickle.loads(pickle.dumps(error))) == str(error)
diff --git a/tests/test_escaping.py b/tests/test_escaping.py
new file mode 100644
index 0000000..696bdc0
--- /dev/null
+++ b/tests/test_escaping.py
@@ -0,0 +1,130 @@
+"""Nothing a document or the command line supplies reaches a diagnostic or
+standard error as a control character: controls, format characters (bidi
+overrides, zero-width) and line separators are written out as ``\\xNN`` or
+``\\uNNNN``, so a hostile path cannot move the cursor, set a title or forge a
+line of its own."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from conftest import FRONT, run_cli
+
+from legaldown_render import render
+from legaldown_render.urls import printable
+
+ESC = "x\x1b]0;pwn\x07y"
+
+
+# -- What a diagnostic shows of a document ---------------------------------------
+
+def test_printable_escapes_controls_and_format_characters() -> None:
+ assert printable("a\x1b[2J\x07\n\x7f\x85") == "a\\x1b[2J\\x07\\x0a\\x7f\\x85"
+ assert printable("\u202e\u200b\ufeff\u2028\u2029") == "\\u202e\\u200b\\ufeff\\u2028\\u2029"
+ assert printable("ž é €") == "ž é €"
+
+
+EVIL = "https://e/%1b[2J%1b]0;pwned%07%0aerror: [x] forged%e2%80%ae"
+
+
+def test_a_control_character_in_an_image_path_is_escaped_in_the_message() -> None:
+ result = render(FRONT + f"# A\n\n\n")
+ messages = [d.message for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert messages
+ for message in messages:
+ assert "\x1b" not in message and "\x07" not in message and "\n" not in message
+ assert any("\\x1b[2J\\x1b]0;pwned\\x07\\x0aerror: [x] forged\\u202e" in m for m in messages)
+
+
+def test_a_control_character_in_a_link_is_escaped_in_the_message() -> None:
+ result = render(FRONT + f"# A\n\n[x]()\n")
+ message = next(d.message for d in result.diagnostics if d.rule == "render-link-refused")
+ assert "\x1b" not in message and "\x07" not in message and "\n" not in message and "\u202e" not in message
+ assert "\\x1b" in message
+
+
+# -- Standard error of the command -----------------------------------------------
+
+def _clean(errors: bytes) -> str:
+ text = errors.decode("utf-8")
+ assert not any((ord(c) < 0x20 and c != "\n") or 0x7F <= ord(c) < 0xA0 for c in text), text
+ return text
+
+
+def test_the_cli_writes_no_control_character_from_a_document_to_standard_error(tmp_path: Path) -> None:
+ source = tmp_path / "evil.lgd"
+ source.write_text(FRONT + f"# A\n\n [y]()\n", encoding="utf-8")
+ done = run_cli(str(source), "-f", "text")
+ err = done.stderr.decode("utf-8")
+ assert "path-not-relative" in err and "render-link-refused" in err
+ assert "\x1b" not in err and "\x07" not in err and "\u202e" not in err
+ assert "\r" not in err
+ assert not any(line.startswith("error: [x]") for line in err.splitlines())
+
+
+def test_no_control_character_from_the_document_reaches_stderr(tmp_path: Path) -> None:
+ body = ("# A {#a}\n\n{{include: a\x1b]0;pwn\x07.lgd}}\n\n{{ref: x\x1b[2Jy}}\n\n{{term: q\x1b[31m}}\n\n"
+ "See {{ref: a}}.\n")
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + body, encoding="utf-8")
+ errors = _clean(run_cli(str(document), "-f", "text", cwd=tmp_path).stderr)
+ assert errors.count("\n") >= 3 # the three findings, one line each
+ assert "\\x1b" in errors # written out, not dropped
+ assert "pwn" in errors
+
+
+# -- Values from the command line ------------------------------------------------
+
+def test_an_input_file_name_is_escaped(tmp_path: Path) -> None:
+ result = run_cli(ESC + ".lgd", cwd=tmp_path)
+ assert result.returncode == 1
+ assert "cannot read" in _clean(result.stderr) and "\\x1b" in _clean(result.stderr)
+
+
+def test_an_answers_path_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ result = run_cli(str(document), "--answers", ESC + ".yaml", cwd=tmp_path)
+ assert result.returncode == 1
+ assert "\\x1b" in _clean(result.stderr)
+
+
+def test_an_output_path_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ result = run_cli(str(document), "-o", "no-such-dir\x1b[2J/out.html", cwd=tmp_path)
+ assert result.returncode == 1
+ assert "\\x1b" in _clean(result.stderr)
+ refused = run_cli(str(document), "-o", "x\x1b[2J.pdf", cwd=tmp_path)
+ assert refused.returncode == 2
+ assert "\\x1b" in _clean(refused.stderr)
+
+
+def test_a_style_name_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ for arguments in (["-s", ESC], ["-s", ESC, "--print-style"]):
+ result = run_cli(str(document), *arguments, cwd=tmp_path)
+ assert result.returncode == 2
+ assert "\\x1b" in _clean(result.stderr)
+
+
+def test_a_locale_is_escaped(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ result = run_cli(str(document), "--locale", "zz\x1b[31m", cwd=tmp_path)
+ _clean(result.stderr)
+
+
+def test_a_set_key_is_escaped_in_argparse_errors(tmp_path: Path) -> None:
+ document = tmp_path / "d.lgd"
+ document.write_text(FRONT + "# A\n", encoding="utf-8")
+ for setting in ("bad\x1b[31m", "no\x1b[31m.such=1"):
+ result = run_cli(str(document), "--set", setting, cwd=tmp_path)
+ assert result.returncode == 2
+ assert "\\x1b" in _clean(result.stderr)
+
+
+def test_an_unrecognized_argument_is_escaped(tmp_path: Path) -> None:
+ result = run_cli("--bogus\x1b[31m", cwd=tmp_path)
+ assert result.returncode == 2
+ assert "\\x1b" in _clean(result.stderr)
diff --git a/tests/test_html.py b/tests/test_html.py
new file mode 100644
index 0000000..0d4cd2c
--- /dev/null
+++ b/tests/test_html.py
@@ -0,0 +1,34 @@
+"""The HTML writer's stylesheet for print (§13.8): a page break goes before the
+attachments container, not inside it, and only where the separator asks for
+one."""
+from __future__ import annotations
+
+from conftest import run_cli_text
+
+ATTACHED = (
+ "---\ntitle: T\nattachments:\n"
+ " - id: one\n title: First attachment\n file: one.pdf\n"
+ " - id: two\n title: Second attachment\n file: two.pdf\n"
+ "---\n\n# A {#a}\n\nBody.\n"
+)
+
+
+def test_a_standalone_page_break_render_has_the_attachment_break_rules() -> None:
+ done = run_cli_text("-", "--set", "attachments.separator=page-break", stdin=ATTACHED)
+ assert done.returncode == 0, done.stderr
+ assert "" in done.stdout
+ assert 'class="ld-attachments ld-separator-page-break"' in done.stdout
+ # The break goes before the container (heading included), then before each later attachment.
+ assert ".ld-attachments.ld-separator-page-break { break-before: page; }" in done.stdout
+ assert ".ld-separator-page-break .ld-attachment + .ld-attachment { break-before: page; }" in done.stdout
+ assert ".ld-attachments-heading { break-after: avoid; }" in done.stdout
+ # The old rule broke before every attachment, the first one included, inside the container.
+ assert ".ld-separator-page-break .ld-attachment { break-before: page; }" not in done.stdout
+
+
+def test_without_a_page_break_separator_the_container_is_not_broken() -> None:
+ done = run_cli_text("-", "--set", "attachments.separator=none", stdin=ATTACHED)
+ assert done.returncode == 0, done.stderr
+ # The rules are scoped by the separator class, which no element carries here.
+ assert 'class="ld-attachments ld-separator-none"' in done.stdout
+ assert 'ld-separator-page-break"' not in done.stdout
diff --git a/tests/test_wp8_labels.py b/tests/test_labels.py
similarity index 90%
rename from tests/test_wp8_labels.py
rename to tests/test_labels.py
index 5cfbce2..ff876d1 100644
--- a/tests/test_wp8_labels.py
+++ b/tests/test_labels.py
@@ -1,5 +1,6 @@
-"""WP8 (S13): the party identifier label is neutral, since §3.4's
-``identification_number`` also covers a natural person's national ID."""
+"""Built-in labels: the party identifier label is neutral, since §3.4's
+``identification_number`` also covers a natural person's national ID, and each
+language words the party labels in its own way."""
from __future__ import annotations
import pytest
diff --git a/tests/test_wp3_links.py b/tests/test_links.py
similarity index 64%
rename from tests/test_wp3_links.py
rename to tests/test_links.py
index c212fbe..d79107a 100644
--- a/tests/test_wp3_links.py
+++ b/tests/test_links.py
@@ -1,24 +1,15 @@
-"""Links and images (WP3): which image paths are shown (B6, §2.3), which links
-are links (W3, §8.7), and links with no URL or no text (B9/W8)."""
+"""Links and images: which image paths are shown (§2.3), which links are links
+(§8.7), a link with no URL or no text, and how a refused link or image is
+reported."""
from __future__ import annotations
import pytest
+from conftest import FRONT, html, text
from legaldown_render import RenderRefused, render
-from legaldown_render.writers.html import is_safe_href
+from legaldown_render.urls import displayed_url, is_link_target, is_safe_href
-FRONT = "---\ntitle: T\n---\n"
-
-
-def text(body: str, **settings: object) -> str:
- return render(FRONT + body, format="text", **settings).output
-
-
-def html(body: str, **settings: object) -> str:
- return render(FRONT + body, format="html", **settings).output
-
-
-# -- B6: image paths (§2.3) ---------------------------------------------------
+# -- Image paths (§2.3) ----------------------------------------------------------
NON_RELATIVE = [
"https://example.com/p.png",
@@ -122,16 +113,66 @@ def validate(document, **kwargs): # type: ignore[no-untyped-def]
assert any("/other.png" in d.message for d in errors)
-# -- W3: which links are links (§8.7) -----------------------------------------
+def test_a_path_that_starts_another_is_not_covered_by_the_validators_report() -> None:
+ # Whatever the validator reports for one path, a different path whose text
+ # it contains is still the renderer's to report.
+ result = render(FRONT + "# A\n\n \n")
+ messages = [d.message for d in result.diagnostics if d.rule == "path-not-relative"]
+ assert any("'/x.png2'" in m for m in messages)
+ assert any("'/x.png'" in m for m in messages)
-@pytest.mark.parametrize("href", [
+@pytest.mark.parametrize("path", ["javascript:alert(1)", "data:text/html,x", "https://e/p.png"])
+def test_the_not_relative_message_is_generic(path: str) -> None:
+ result = render(FRONT + f"# A\n\n\n")
+ message = next(d.message for d in result.diagnostics if d.rule == "path-not-relative")
+ assert "never fetched" not in message
+ assert "file references are relative paths with no URI scheme. Its alt text is shown instead." in message
+
+
+@pytest.mark.parametrize("empty", ["", " ", "%20"])
+def test_an_image_with_no_path_is_its_alt_text(empty: str) -> None:
+ for fmt in ("html", "text"):
+ result = render(FRONT + f"# A\n\nBefore  after.\n", format=fmt, strict=True)
+ assert " None:
- output = html(f"# A\n\nSee [the link](<{href}>).\n")
- assert f'the link' in output
+ "mailto:a@example.com", "tel:+420123456789", "attachments/a.pdf",
+]
+
+
+@pytest.mark.parametrize("href", ALLOWED)
+def test_an_allowed_scheme_or_a_relative_url_is_a_link(href: str) -> None:
+ assert is_link_target(href)
+ assert f'click' in html(f"# A\n\nSee [click](<{href}>).\n")
+
+
+@pytest.mark.parametrize("href", HANDLERS)
+def test_a_protocol_handler_or_pseudo_scheme_is_not_a_link(href: str) -> None:
+ assert not is_link_target(href)
+ body = f"# A\n\nSee [click](<{href}>) now.\n"
+ result = render(FRONT + body, format="html")
+ assert "See click now." in result.output
+ assert "")[1]
+ assert "See click now." in text(body)
+ assert "<" not in text(body).split("See click now.")[0].split("\n")[-1]
@pytest.mark.parametrize("href", [
@@ -152,10 +193,14 @@ def test_a_refused_link_is_its_text_in_both_writers(href: str) -> None:
assert not is_safe_href(href)
-def test_the_allow_list_is_judged_as_a_browser_reads_the_url() -> None:
+def test_the_scheme_is_judged_as_a_browser_reads_the_url() -> None:
+ # markdown-it percent-encodes a tab in a URL it parses ("ms%09-msdt:x" is a
+ # relative path to a browser); the check still holds for a URL it is given
+ # directly, as a browser would read it.
for href in ("java\tscript:alert(1)", "jav\nascript:x", "\x01javascript:x", " JAVASCRIPT:x", "data:text/html,x",
- "VBScript:x", "FILE:///x"):
+ "VBScript:x", "FILE:///x", "ms\t-msdt:x", "\u0000 vscode://x"):
assert not is_safe_href(href), repr(href)
+ assert not is_link_target(href), repr(href)
for href in ("https://example.com", "#section", "attachments/a.pdf", "mailto:a@example.com", "ftp://x/y",
"sip:a@b", "urn:x:y", "x/data:y", "javascript", "HTTPS://X", "\thttps://x"):
assert is_safe_href(href), href
@@ -176,8 +221,24 @@ def test_a_link_with_an_unusual_scheme_shows_its_url_in_text() -> None:
assert "the file " in text("# A\n\nGet [the file](ftp://example.com/f).\n")
-# -- B9/W8: links without a URL or without text -----------------------------
+def test_a_refused_link_is_reported_once_as_a_warning() -> None:
+ body = "# A\n\n[a](ms-msdt:x) and [b](ms-msdt:x) and [c](vscode://x) and [d](http://ok) and [e]().\n"
+ result = render(FRONT + body, format="html")
+ refused = [d for d in result.diagnostics if d.rule == "render-link-refused"]
+ assert [d.level for d in refused] == ["warning", "warning"]
+ assert "Link to 'ms-msdt:x' is not rendered as a link" in refused[0].message
+ assert "its text is shown" in refused[0].message
+ assert "vscode://x" in refused[1].message
+ assert result.ok # a Warning does not refuse, even under --strict's Errors
+ assert render(FRONT + body, format="text").diagnostics == result.diagnostics
+
+def test_an_empty_or_allowed_link_is_not_reported() -> None:
+ result = render(FRONT + "# A\n\n[a]() [b](<>) [c](https://e) [d](#x) [e](rel/path)\n")
+ assert not [d for d in result.diagnostics if d.rule == "render-link-refused"]
+
+
+# -- Links without a URL or without text -----------------------------------------
def test_a_link_with_no_url_is_its_text() -> None:
assert "")[1]
@@ -198,6 +259,18 @@ def test_a_link_with_no_text_shows_its_url() -> None:
assert "See http://z.y." in text("# A\n\nSee [](http://z.y).\n")
+def test_displayed_url_undoes_the_encoding_unless_it_shows_a_control() -> None:
+ assert displayed_url(" https://e/a%20b ") == "https://e/a b"
+ assert displayed_url("https://e/a%0ab") == "https://e/a%0ab"
+ assert displayed_url("https://e/%E2%80%AE") == "https://e/%E2%80%AE"
+
+
+def test_a_link_with_no_text_shows_the_written_url() -> None:
+ body = "# A\n\nSee []().\n"
+ assert 'href="https://e/a%20b/%C3%A9">https://e/a b/é' in html(body)
+ assert "See https://e/a b/é." in text(body)
+
+
def test_an_autolink_is_its_address_alone_in_text() -> None:
assert "Mail a@b.c now" in text("# A\n\nMail now\n")
assert "Call tel:+1 now" in text("# A\n\nCall [tel:+1](tel:+1) now\n")
@@ -214,13 +287,3 @@ def test_a_link_text_that_differs_from_its_url_shows_both_in_text() -> None:
def test_a_link_around_a_picture_keeps_the_picture_not_its_url() -> None:
output = html("# A\n\n[](https://example.com)\n")
assert '' in output
-
-
-@pytest.mark.parametrize("empty", ["", " ", "%20"])
-def test_an_image_with_no_path_is_its_alt_text(empty: str) -> None:
- for fmt in ("html", "text"):
- result = render(FRONT + f"# A\n\nBefore  after.\n", format=fmt, strict=True)
- assert " None:
+ assert format_counter(value, counter) == expected
+
+
+def test_fill_and_extend() -> None:
+ assert fill("{section}.{n} {other}", n="2", section="5") == "5.2 {other}"
+ assert extend("", ".1", textual=False) == "1"
+ assert extend("2.1", "(b)", textual=False) == "2.1(b)"
+ assert extend("Termination", "(a)", textual=True) == "Termination (a)"
+
+
+def test_fill_drops_an_empty_section_with_one_following_dot() -> None:
+ assert fill("{section}.{n}", n="1", section="") == "1"
+ assert fill("{section}.{n}", n="1", section="2") == "2.1"
+ # Only ONE dot goes with the field.
+ assert fill("{section}..{n}", n="1", section="") == ".1"
+
+
+def test_fill_keeps_other_text_of_an_empty_section_as_written() -> None:
+ # No dot right after the field: the field alone is removed, as before.
+ assert fill("({section}){n}", n="a", section="") == "()a"
+ assert fill("{section}", n="1", section="") == ""
+
+
+def test_fill_without_a_section_value_keeps_the_dot() -> None:
+ # The rule applies only when the section value is given and empty.
+ assert fill("{section}.{n}", n="1") == ".1"
+
+
+# -- Preamble list items agree with their designation ----------------------------
+
+PREAMBLE_AND_SECTION = (
+ "\nPreamble.\n\n- first pre\n- second pre\n\n# A {#a}\n\n- in a\n"
+)
+
+
+def test_a_preamble_list_is_numbered_without_a_leading_dot_in_text() -> None:
+ output = text(PREAMBLE_AND_SECTION, style="continental")
+ assert "1 first pre" in output
+ assert "2 second pre" in output
+ assert not any(line.startswith(".") for line in output.splitlines())
+
+
+def test_a_preamble_list_is_numbered_without_a_leading_dot_in_html() -> None:
+ output = html(PREAMBLE_AND_SECTION, style="continental")
+ assert '1
first pre
' in output
+ assert '2
second pre
' in output
+ assert 'class="ld-label">.1<' not in output
+
+
+def test_a_list_in_a_section_keeps_its_section_prefix() -> None:
+ output = text(PREAMBLE_AND_SECTION, style="continental")
+ assert "1.1 in a" in output
+ html_output = html(PREAMBLE_AND_SECTION, style="continental")
+ assert '1.1
in a
' in html_output
diff --git a/tests/test_render.py b/tests/test_render.py
index 93c507d..42e5746 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -473,16 +473,11 @@ def test_options_object_and_keywords_are_exclusive() -> None:
render(FRONT, RenderOptions(), format="text")
-# -- regressions from code review -------------------------------------------------
+# -- alternatives, sentinels and emphasis around terms ----------------------------
-def test_disguised_javascript_urls_are_not_links() -> None:
- from legaldown_render.writers.html import is_safe_href
-
- for href in ("java\tscript:alert(1)", "jav\nascript:x", "\x01javascript:x", " JAVASCRIPT:x", "data:text/html,x"):
- assert not is_safe_href(href), repr(href)
- for href in ("https://example.com", "#section", "attachments/a.pdf", "mailto:a@example.com"):
- assert is_safe_href(href), href
+def test_a_disguised_javascript_attachment_path_is_not_a_link() -> None:
+ # The scheme judgement itself is tested in test_links.py; this is the attachment's own link.
source = FRONT.replace("title: T", 'title: T\nattachments:\n - id: annex\n title: Annex\n file: "java\\tscript:alert(1)"')
output = render(source + "# A\n\nSee {{attach: annex}}.\n", overrides={"attachments.render": "omit"}).output
assert "script:alert" not in "".join(re.findall(r'href="([^"]*)"', output))
@@ -564,7 +559,7 @@ def test_an_unresolved_node_is_an_internal_error() -> None:
arb: Arbitration"""
-# -- regressions from the second code review ---------------------------------------
+# -- sentinels, link titles and which alternatives share a number ------------------
def test_sentinels_written_as_entities_are_plain_text() -> None:
@@ -610,7 +605,7 @@ def test_units_that_can_appear_together_do_not_share_a_number() -> None:
assert "2. Two" in output
-# -- regressions from the third code review -------------------------------------------
+# -- alternatives and definitions in titles ------------------------------------------
THREE_WAY = """title: T
questions:
@@ -675,7 +670,7 @@ def test_section_numbers_agree_with_the_validator() -> None:
assert [section.designation for section in result.tree.sections] == expected == ["1", "2", "3", "4", "4"]
-# -- regressions from the fourth code review -------------------------------------------
+# -- the preamble, level-six headings and definitions in headings ----------------------
def test_preamble_condition_is_literal_outside_a_template() -> None:
@@ -738,7 +733,7 @@ def test_definition_in_a_link_inside_alt_text_takes_no_anchor() -> None:
assert 'alt="pic the Fee"' in output
-# -- regressions from the fifth code review --------------------------------------------
+# -- includes, ambiguous references and the structure of lists -------------------------
def test_a_malformed_include_is_not_an_include_paragraph() -> None:
@@ -764,12 +759,6 @@ def test_the_first_definition_in_document_order_keeps_the_anchor() -> None:
assert 'id="def:fee"' not in sections
-def test_nested_lists_line_up_with_their_item_s_text() -> None:
- output = text("# A\n\n1. one\n\n more\n\n - child\n")
- # Blocks of an item are set apart (W5); a nested list stays tight under its paragraph.
- assert "1. one\n\n more\n (i) child" in output
-
-
def _nested_list(levels: int) -> str:
return "# A\n\n" + "".join(" " * depth + f"- l{depth}\n" for depth in range(levels))
@@ -1075,13 +1064,13 @@ def test_template_decision_is_the_validators() -> None:
assert "[Only if: flag] Preamble para" in result.output
-# -- regressions from the seventh code review ------------------------------------------
+# -- quotes, markers, comments and breaks ----------------------------------------------
def test_a_heading_in_a_quote_or_item_is_a_bold_line_not_a_section() -> None:
result = render(FRONT + "# A\n\n> # Inner\n> text\n\n- ## Item heading\n\n> title: x\n> ---\n> after\n",
format="text")
- # One quote stays one quote: its blank line keeps the `>` (W5).
+ # One quote stays one quote: its blank line keeps the `>`.
assert "\n\n> Inner\n>\n> text\n\n(a) Item heading\n\n> title: x\n>\n> after\n" in result.output
assert [s.designation for s in result.tree.sections] == ["1"]
html = render(FRONT + "# A\n\n> # Inner\n", standalone=False).output
@@ -1113,7 +1102,7 @@ def test_a_marker_copy_inside_a_comment_is_not_the_marker() -> None:
def test_an_item_opening_with_a_drafting_note_or_code() -> None:
output = text("# A\n\n- > [!DRAFTING]\n > note here\n- ```\n code\n ```\n")
- # The label stands alone and the block is indented under the item text (WP11): flattened
+ # The label stands alone and the block is indented under the item text: flattened
# onto the label line, a quote is no quote and code is mis-indented.
assert "(a)\n > [Drafting note]\n > note here" in output
assert "(b)\n code" in output
diff --git a/tests/test_style.py b/tests/test_style.py
index c0e5495..b3f0a09 100644
--- a/tests/test_style.py
+++ b/tests/test_style.py
@@ -1,3 +1,7 @@
+"""Styles: the built-in ones, layering with ``extends``, dotted overrides merged
+field by field, how every problem is reported with its path, dumping a style to
+YAML, and the typing of command-line values. Validation of templates and the
+loader's edge cases are in test_style_validation.py."""
from __future__ import annotations
from dataclasses import replace
@@ -69,7 +73,7 @@ def test_dump_round_trips(tmp_path: Path) -> None:
style = load_style("continental", overrides={"locale": "de-DE"})
path = tmp_path / "dumped.yaml"
path.write_text(dump_style(style))
- # The dump is flattened (S8): the same style, without the record of what it extended.
+ # The dump is flattened: the same style, without the record of what it extended.
assert load_style(path) == replace(style, extends=None)
diff --git a/tests/test_wp6_style.py b/tests/test_style_validation.py
similarity index 74%
rename from tests/test_wp6_style.py
rename to tests/test_style_validation.py
index c033687..73069da 100644
--- a/tests/test_wp6_style.py
+++ b/tests/test_style_validation.py
@@ -1,33 +1,26 @@
-"""Style validation: an empty blank falls back to ``[TBD: id]`` (R6), ``extends``
-cycles are found on resolved paths (S7), ``--print-style`` is self-contained
-(S8), ``version`` and ``extends`` cannot be overridden (S9), a YAML error in a
-``--set`` value is reported (S10, loader side), and the ``{field}``
-placeholders of every text template are validated (S11)."""
+"""Style validation and the style loader's edge cases: an empty placeholder
+blank falls back to ``[TBD: id]`` (§13.5 rule 6), ``extends`` cycles are found
+on resolved paths, ``--print-style`` is self-contained, ``version`` and
+``extends`` cannot be overridden, a YAML error in a ``--set`` value is
+reported, and the ``{field}`` placeholders of every text template are checked
+where the setting is used (and for typos everywhere). Loading, layering and
+dotted overrides are in test_style.py."""
from __future__ import annotations
import dataclasses
-import os
-import subprocess
-import sys
from dataclasses import replace
from pathlib import Path
import pytest
+from conftest import FRONT, run_cli_text
from legaldown_render import RenderOptions, StyleError, builtin_styles, dump_style, load_style, render
from legaldown_render.style import parse_override
-SRC = Path(__file__).resolve().parents[1] / "src"
DOC = "---\nlegaldown: '0.2'\ntitle: T\n---\n\n# One {#one}\n\nAmount {{placeholder: fee}} and {{ref: one}}.\n"
-def _cli(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
- env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONUTF8="1")
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
- capture_output=True, text=True, env=env, check=False)
-
-
-# --- R6: an empty blank --------------------------------------------------------------------------
+# -- An empty or invisible blank (§13.5) -----------------------------------------
@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
@pytest.mark.parametrize("fmt", ["html", "text"])
@@ -43,7 +36,20 @@ def test_a_visible_blank_is_kept() -> None:
assert "TBD" not in result.output
-# --- S7: extends cycles ----------------------------------------------------------------------------
+@pytest.mark.parametrize("blank", ["", "", " ", " ", ""])
+@pytest.mark.parametrize("fmt", ["html", "text"])
+def test_a_blank_of_invisible_characters_falls_back_to_tbd(blank: str, fmt: str) -> None:
+ source = FRONT.replace("title: T", "title: T\nplaceholders:\n - id: fee\n type: text") + "# A\n\nFee {{placeholder: fee}}.\n"
+ result = render(source, format=fmt, overrides={"placeholders.blank": blank})
+ assert "[TBD: fee]" in result.output
+
+
+def test_a_blank_with_a_visible_character_is_kept() -> None:
+ source = FRONT.replace("title: T", "title: T\nplaceholders:\n - id: fee\n type: text") + "# A\n\nFee {{placeholder: fee}}.\n"
+ assert "Fee __." in render(source, format="text", overrides={"placeholders.blank": "__"}).output
+
+
+# -- extends cycles --------------------------------------------------------------
def test_a_cycle_through_a_parent_directory_is_found(tmp_path: Path) -> None:
(tmp_path / "dir").mkdir()
@@ -77,7 +83,7 @@ def test_the_same_base_used_twice_is_not_a_cycle(tmp_path: Path) -> None:
assert load_style(tmp_path / "top.yaml").numbering.scheme == "none"
-# --- S8: a dump is self-contained ----------------------------------------------------------------------
+# -- A dump is self-contained ----------------------------------------------------
def test_a_dump_does_not_extend_anything(tmp_path: Path) -> None:
(tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n")
@@ -108,7 +114,7 @@ def test_a_dumped_builtin_reloads_equal(name: str, tmp_path: Path) -> None:
def test_print_style_of_a_relative_extends_reloads_from_anywhere(tmp_path: Path) -> None:
(tmp_path / "base.yaml").write_text("version: 1\nplaceholders: {blank: '[__]'}\n")
(tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\n")
- done = _cli("--print-style", "--style", str(tmp_path / "child.yaml"))
+ done = run_cli_text("--print-style", "--style", str(tmp_path / "child.yaml"))
assert done.returncode == 0, done.stderr
assert "extends" not in done.stdout
elsewhere = tmp_path / "other"
@@ -117,7 +123,7 @@ def test_print_style_of_a_relative_extends_reloads_from_anywhere(tmp_path: Path)
assert load_style(elsewhere / "dump.yaml").placeholders.blank == "[__]"
-# --- S9: version and extends are not overridable ------------------------------------------------------------
+# -- version and extends are not overridable -------------------------------------
@pytest.mark.parametrize("key", ["version", "extends"])
def test_version_and_extends_cannot_be_overridden(key: str) -> None:
@@ -134,14 +140,14 @@ def test_a_nested_override_of_version_is_refused_too() -> None:
@pytest.mark.parametrize("print_style", [True, False])
def test_the_cli_refuses_them(setting: str, print_style: bool) -> None:
args = ["--print-style"] if print_style else ["-", "-q"]
- done = _cli(*args, "--set", setting, stdin=DOC)
+ done = run_cli_text(*args, "--set", setting, stdin=DOC)
assert done.returncode == 2, done.stdout
assert setting.split("=")[0] in done.stderr
assert "cannot be overridden" in done.stderr
assert "Traceback" not in done.stderr
-# --- S10: a YAML error in a --set value -------------------------------------------------------------------
+# -- A YAML error in a --set value -----------------------------------------------
def test_a_yaml_error_in_an_override_names_the_key() -> None:
with pytest.raises(StyleError) as caught:
@@ -153,7 +159,7 @@ def test_a_yaml_error_in_an_override_names_the_key() -> None:
def test_a_yaml_error_in_an_override_reaches_stderr() -> None:
- done = _cli("--print-style", "--set", "numbering.levels=[{counter: decimal")
+ done = run_cli_text("--print-style", "--set", "numbering.levels=[{counter: decimal")
assert done.returncode == 2
assert "numbering.levels" in done.stderr
assert "YAML" in done.stderr
@@ -162,7 +168,7 @@ def test_a_yaml_error_in_an_override_reaches_stderr() -> None:
def test_a_yaml_error_in_a_render_run_reaches_stderr() -> None:
- done = _cli("-", "-q", "--set", "enumeration.levels=[{a", stdin=DOC)
+ done = run_cli_text("-", "-q", "--set", "enumeration.levels=[{a", stdin=DOC)
assert done.returncode != 0
assert "enumeration.levels" in done.stderr
assert "YAML" in done.stderr
@@ -172,15 +178,9 @@ def test_a_yaml_error_in_a_render_run_reaches_stderr() -> None:
def test_valid_yaml_overrides_still_parse() -> None:
assert parse_override("title_block.version", "true") is True
assert parse_override("contents.depth", "3") == 3
- assert parse_override("numbering.levels", "[{counter: decimal}]") == [{"counter": "decimal"}]
-
-
-# --- S11: template fields ------------------------------------------------------------------------------------
-@pytest.mark.parametrize("name", builtin_styles())
-def test_every_builtin_style_passes(name: str) -> None:
- load_style(name)
+# -- Template fields -------------------------------------------------------------
@pytest.mark.parametrize("language", ["en", "cs", "de", "fr", "pl", "sk"])
def test_every_builtin_label_passes(language: str) -> None:
@@ -295,3 +295,45 @@ def test_all_template_problems_are_reported_together() -> None:
assert any(p.startswith("references.format") for p in problems)
assert any(p.startswith("labels.condition") for p in problems)
assert any(p.startswith("placeholders.blank") for p in problems)
+
+
+# -- Templates are validated where their setting is used -------------------------
+
+NO_N = [{"label": "x", "ref": "x"}]
+
+
+@pytest.mark.parametrize(("overrides", "key"), [
+ ({"enumeration.levels": NO_N}, "enumeration.levels[0].ref"),
+ ({"enumeration.levels": NO_N, "enumeration.enabled": False, "enumeration.ordered": "enumerate"},
+ "enumeration.levels[0].ref"),
+ ({"numbering.levels": NO_N}, "numbering.levels[0].ref"),
+ ({"paragraphs.numbered": True, "paragraphs.format": {"label": "x", "ref": "x"}}, "paragraphs.format.ref"),
+])
+def test_a_ref_that_is_in_use_must_have_its_counter(overrides: dict[str, object], key: str) -> None:
+ assert any(p.startswith(key) and "{n}" in p for p in _problems(overrides))
+
+
+@pytest.mark.parametrize("overrides", [
+ {"enumeration.levels": NO_N, "enumeration.enabled": False},
+ {"numbering.levels": NO_N, "numbering.scheme": "none"},
+ {"paragraphs.format": {"label": "x", "ref": "x"}},
+ {"paragraphs.format": {"label": "x", "ref": ""}},
+])
+def test_a_ref_that_is_not_in_use_may_leave_out_its_counter(overrides: dict[str, object]) -> None:
+ load_style("default", overrides=overrides)
+
+
+def test_a_typo_is_flagged_even_where_the_setting_is_not_used() -> None:
+ problems = _problems({"numbering.scheme": "none", "numbering.levels": [{"label": "{n}", "ref": "{m}"}]})
+ assert any(p.startswith("numbering.levels[0].ref") and "{m}" in p and "unknown field" in p for p in problems)
+
+
+def test_a_plain_label_may_contain_braced_words() -> None:
+ style = load_style("default", overrides={"labels.drafting_note": "Note {see below}", "labels.colon": "{x}:"})
+ assert style.labels.drafting_note == "Note {see below}"
+ assert style.labels.colon == "{x}:"
+
+
+def test_the_labels_that_substitute_still_check_their_fields() -> None:
+ assert _problems({"labels.condition": "{condtion}"})
+ assert _problems({"labels.attachment_file": "{fil}"})
diff --git a/tests/test_terms.py b/tests/test_terms.py
new file mode 100644
index 0000000..3d97fbe
--- /dev/null
+++ b/tests/test_terms.py
@@ -0,0 +1,211 @@
+"""Defined terms: what ``{{term:}}`` shows (§7.3), which is the rendered text of
+the term where it is defined, with its own Markdown, references and
+placeholders resolved; terms that refer to themselves or to each other; and
+the cap on the length of a term text. How much work nested terms may cost is in
+test_terms_budget.py."""
+from __future__ import annotations
+
+import pytest
+from conftest import FRONT, html, text
+
+from legaldown_render import render
+
+# -- The text of a term ----------------------------------------------------------
+
+def test_a_term_reference_shows_the_rendered_text_of_the_term() -> None:
+ output = text('# A\n\n"A *b* c" {{def: abc}} means it.\n\nSee {{term: abc}}.\n')
+ assert "See A b c." in output
+ assert "*" not in output.split("See", 1)[1]
+
+
+def test_a_term_reference_label_still_wins() -> None:
+ output = text('# A\n\n"A *b* c" {{def: abc}} means it.\n\nSee {{term: abc, label=the thing}}.\n')
+ assert "See the thing." in output
+
+
+def test_a_term_reference_resolves_directives_inside_the_term() -> None:
+ body = '# A {#a}\n\n"Fee **under** {{ref: a}}" {{def: fee}} means it.\n\nSee {{term: fee}}.\n'
+ output = text(body)
+ assert "See Fee under" in output
+
+
+def test_a_term_reference_uses_the_first_definition() -> None:
+ body = '# A\n\n"First *one*" {{def: t}} here.\n\n"Second" {{def: t}} there.\n\nSee {{term: t}}.\n'
+ assert "See First one." in text(body)
+
+
+def test_a_term_reference_before_its_definition() -> None:
+ body = '# A\n\nSee {{term: abc}} first.\n\n"A *b* c" {{def: abc}} means it.\n'
+ assert "See A b c first." in text(body)
+
+
+def test_a_plain_term_reference_is_unchanged() -> None:
+ assert "See Fee." in text('# A\n\n"Fee" {{def: fee}} means it.\n\nSee {{term: fee}}.\n')
+
+
+def test_an_empty_term_falls_back_to_the_validator() -> None:
+ output = text('# A\n\n"" {{def: late-fee}} means it.\n\nSee {{term: late-fee}}.\n')
+ assert "See Late Fee." in output
+
+
+def test_an_undefined_term_is_still_reported() -> None:
+ assert "[UNDEFINED: nope]" in text("# A\n\nSee {{term: nope}}.\n")
+
+
+# -- A reference, placeholder or definition inside a term ------------------------
+
+def test_a_term_reference_keeps_a_ref_inside_the_term() -> None:
+ output = text('# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n\n{{term: a}}\n')
+ assert output.count("Fee per 1") == 2
+
+
+def test_a_term_reference_before_the_heading_keeps_the_ref() -> None:
+ output = text('{{term: a}}\n\n# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n')
+ assert output.count("Fee per 1") == 2
+
+
+def test_a_term_reference_before_its_definition_and_its_target() -> None:
+ output = text('# First\n\nSee {{term: a}}.\n\n# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n')
+ assert "See Fee per 2." in output
+
+
+def test_a_term_reference_keeps_a_ref_to_a_subsection() -> None:
+ body = '# A\n\n"Fee per {{ref: s}}" {{def: a}}\n\n{{term: a}}\n\n## Sub {#s}\n\nx\n'
+ output = text(body)
+ assert output.count("Fee per 1.1") == 2
+
+
+def test_a_term_reference_in_a_textual_heading_and_in_the_body() -> None:
+ # The none scheme reads a heading as text before every target is known; the
+ # term text must not be fixed from that early pass.
+ body = '# Fee {{term: a}}\n\n"Fee per {{ref: h}}" {{def: a}}\n\n# H {#h}\n\n{{term: a}}\n'
+ output = render(FRONT + body, format="text", overrides={"numbering.scheme": "none"}).output
+ assert output.rstrip().endswith("Fee per H")
+ assert "[BROKEN REF" not in output
+
+
+def test_a_term_reference_shows_the_placeholder_as_the_definition_does() -> None:
+ body = '# A\n\n"Fee {{placeholder: p}}" {{def: a}} {{term: a}} {{placeholder: p, type=money}}\n'
+ result = render(FRONT + body, format="text")
+ line = next(row for row in result.output.splitlines() if "INVALID PLACEHOLDER" in row)
+ assert line.count("Fee [INVALID PLACEHOLDER: p]") == 2
+ assert "[_____]" not in result.output
+
+
+def test_a_definition_that_defines_a_term_twice_uses_the_first() -> None:
+ body = '# H {#h}\n\n"First {{ref: h}}" {{def: t}}\n\n"Second" {{def: t}}\n\n{{term: t}}\n'
+ assert text(body).rstrip().endswith("First 1")
+
+
+def test_a_term_with_a_ref_adds_no_duplicate_diagnostics() -> None:
+ # The term holds an image with a remote path: one report however often it is resolved.
+ body = '# H {#h}\n\n"Fee  {{ref: h}}" {{def: a}}\n\n{{term: a}} {{term: a}}\n'
+ diagnostics = render(FRONT + body, format="text").diagnostics
+ keys = [(d.rule, d.message) for d in diagnostics]
+ assert len(keys) == len(set(keys))
+ assert sum(d.rule == "path-not-relative" for d in diagnostics) == 1
+
+
+def test_a_term_that_holds_a_definition_does_not_take_its_anchor() -> None:
+ body = '# A\n\nSee {{term: outer}}.\n\n"Outer "Inner" {{def: inner}}" {{def: outer}}\n\n{{term: inner}}\n'
+ html = render(FRONT + body, format="html").output
+ assert html.count('id="def:inner"') == 1
+ assert html.count('id="def:outer"') == 1
+
+
+# -- Terms that refer to themselves or to each other -----------------------------
+
+SELF = '# A\n\n"Self {{term: s}}" {{def: s}} means it.\n\nSee {{term: s}} and {{term: s, label=the label}}.\n'
+
+INDIRECT = ('# A\n\n"A {{term: b}}" {{def: a}} x.\n\n"B {{term: a}}" {{def: b}} y.\n\n'
+ "See {{term: a}}, {{term: b}} and {{term: a, label=the label}}.\n")
+
+
+@pytest.mark.parametrize("output_format", ["text", "html"])
+@pytest.mark.parametrize("body", [SELF, INDIRECT], ids=["direct", "indirect"])
+def test_a_cyclic_term_renders_in_both_writers(body: str, output_format: str) -> None:
+ result = render(FRONT + body, format=output_format)
+ assert "the label" in result.output
+ assert "[UNDEFINED" not in result.output
+
+
+def test_a_term_inside_its_own_definition_shows_its_id() -> None:
+ output = text(SELF)
+ # At the definition and at a reference alike: the inner reference shows
+ # the id (its label when it has one), and goes no further.
+ assert "Self Self s means it." in output
+ assert "See Self s and the label." in output
+
+
+def test_a_term_reference_in_a_cycle_shows_its_label_else_its_id() -> None:
+ output = text(INDIRECT)
+ assert output.rstrip().endswith("and the label.")
+ # No term shows its own reference as raw directive source.
+ assert "{{" not in output
+
+
+def test_a_cycle_through_a_reference_site_inside_another_terms_definition() -> None:
+ body = ('# A\n\n"A" {{def: a}} x.\n\n"B {{term: a}} and {{term: c}}" {{def: b}} y.\n\n'
+ '"C {{term: b}}" {{def: c}} z.\n\nSee {{term: b}}.\n')
+ assert "See B A and" in text(body)
+ assert "B A and" in html(body)
+
+
+@pytest.mark.parametrize("output_format", ["text", "html"])
+def test_cycles_through_refs_still_terminate(output_format: str) -> None:
+ body = ('# H {#h}\n\n"A {{term: b}} {{ref: h}}" {{def: a}} x.\n\n"B {{term: a}}" {{def: b}} y.\n\n'
+ '"C {{term: c}}" {{def: c}}\n\nSee {{term: a}}, {{term: b}}, {{term: c}}.\n')
+ out = render(FRONT + body, format=output_format).output
+ assert "{{" not in out
+ # Which term shows what inside a cycle follows the order they are first needed in.
+ assert "A b 1" in out and "C c" in out
+
+
+CYCLE = ('# A\n\nUsed first: {{term: ca}} and {{term: cb}}.\n\n'
+ '"Alpha {{term: cb}}" {{def: ca}}\n\n"Beta {{term: ca}}" {{def: cb}}\n')
+
+
+def test_a_term_reference_in_a_cycle_does_not_depend_on_the_order_of_use() -> None:
+ # A term shown by {{term:}} is its text with that term open (a reference back to it
+ # shows its id), whichever term is used first. (The definition site renders with no term
+ # open, so it can read longer: "Beta Alpha Beta ca" there, "Beta Alpha cb" in a reference.)
+ ca_first = text(CYCLE)
+ cb_first = text(CYCLE.replace("{{term: ca}} and {{term: cb}}", "{{term: cb}} and {{term: ca}}"))
+ assert "Used first: Alpha Beta ca and Beta Alpha cb." in ca_first
+ assert "Used first: Beta Alpha cb and Alpha Beta ca." in cb_first
+
+
+# -- The cap on a term text ------------------------------------------------------
+
+def _warnings(body: str, **settings: object) -> list[str]:
+ return [d.message for d in render(FRONT + body, format="text", **settings).diagnostics
+ if d.rule == "render-term-too-long"]
+
+
+def test_a_term_over_the_cap_shows_its_id_and_one_warning_names_it() -> None:
+ long_term = "word " * 250
+ body = f'# A\n\n"{long_term}" {{{{def: big}}}}\n\nSee {{{{term: big}}}} and {{{{term: big}}}}.\n'
+ out = text(body)
+ assert "See big and big." in out
+ assert _warnings(body) == [
+ "The term of 'big' is longer than 1000 characters once its references are resolved; "
+ "'{{term: big}}' shows the definition id."]
+
+
+def test_a_label_still_wins_over_the_cap() -> None:
+ body = '# A\n\n"' + "word " * 250 + '" {{def: big}}\n\nSee {{term: big, label=Big}}.\n'
+ assert "See Big." in text(body)
+
+
+def test_a_term_at_the_cap_is_shown_whole() -> None:
+ term = "x" * 1000
+ body = f'# A\n\n"{term}" {{{{def: edge}}}}\n\nSee {{{{term: edge}}}}.\n'
+ assert f"See {term}." in text(body)
+ assert _warnings(body) == []
+
+
+def test_a_normal_nested_term_is_unaffected() -> None:
+ body = ('# A\n\n"Company" {{def: company}}\n\n"Affiliate of the {{term: company}}" {{def: affiliate}}\n\n'
+ 'See {{term: affiliate}}.\n')
+ assert "See Affiliate of the Company." in text(body)
+ assert _warnings(body) == []
diff --git a/tests/test_terms_budget.py b/tests/test_terms_budget.py
new file mode 100644
index 0000000..13d34e9
--- /dev/null
+++ b/tests/test_terms_budget.py
@@ -0,0 +1,219 @@
+"""Terms that name each other, and how much is worked out for them (§7.3).
+
+A text worked out for a term is kept and reused only where it would read the
+same (what it shows depends on which terms are open around it), so a chain of
+terms is worked out once per term, not once per use. Terms that name each
+other in a tangle have more texts than any cache holds, so the work is
+limited: a text worked out again is charged to a budget, and past it, or past a
+depth of nested terms, a reference shows its definition id and the Warning
+``render-term-limit`` says so. A term text is also capped in length
+(``render-term-too-long``).
+"""
+from __future__ import annotations
+
+import random
+import time
+
+import pytest
+from conftest import FRONT, text
+
+from legaldown_render import RenderResult, render
+from legaldown_render.resolve.resolver import Resolver
+
+LIMIT = ("Defined terms refer to each other too deeply or too often to resolve fully; "
+ "some {{term:}} references show their definition id.")
+
+
+def messages(result: RenderResult, rule: str) -> list[str]:
+ return [d.message for d in result.diagnostics if d.rule == rule]
+
+
+def _chain(n: int, *, cycle: bool, heading: bool = False) -> str:
+ """Terms t0..tn, each reading the next one twice; the last one reads t0 when *cycle*."""
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)]
+ defs.append(f'"T{n}{" {{term: t0}}" if cycle else ""}" {{{{def: t{n}}}}}')
+ title = "About {{term: t0}}" if heading else "A"
+ return f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def _line(n: int, *, heading: bool) -> str:
+ """Terms t0..tn, each reading the next one once."""
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
+ title = "About {{term: t0}}" if heading else "A"
+ return f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def _tangle(k: int) -> str:
+ defs = [f'"T{i} {" ".join(f"{{{{term: t{j}}}}}" for j in range(k))}" {{{{def: t{i}}}}}' for i in range(k)]
+ return "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def _ring(k: int, n: int) -> str:
+ """k terms in a cycle, each holding n references to the next."""
+ defs = [f'"T{i} {" ".join(f"{{{{term: t{(i + 1) % k}}}}}" for _ in range(n))}" {{{{def: t{i}}}}}' for i in range(k)]
+ return "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def _independent(n: int, *, heading: bool) -> str:
+ parts = []
+ for i in range(n):
+ if heading:
+ parts.append(f"## H{i} {{{{term: t{i}}}}}\n\n")
+ parts.append(f'"Term {i}" {{{{def: t{i}}}}} used {{{{term: t{i}}}}}\n\n')
+ return "# A\n\n" + "".join(parts)
+
+
+# -- A text is worked out once per term ---------------------------------------
+
+
+@pytest.mark.parametrize("heading", [False, True])
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("cycle", [False, True])
+def test_a_branching_chain_of_terms_is_worked_out_once_per_term(cycle: bool, scheme: str, heading: bool) -> None:
+ # Each term reads the next twice: worked out afresh at each use, 20 terms is 2**20 resolutions.
+ # The text of t0 is itself 2**n words long (4**n with a cycle, which reads the chain round
+ # twice), so the chains cannot be longer. Under the none scheme a heading is read as text
+ # before the targets are known (§13.3).
+ started = time.perf_counter()
+ out = text(_chain(9 if cycle else 20, cycle=cycle, heading=heading), overrides={"numbering.scheme": scheme})
+ assert time.perf_counter() - started < 2
+ assert "{{" not in out
+
+
+def test_a_chain_without_a_cycle_reads_as_each_term_does() -> None:
+ lines = text(_chain(3, cycle=False)).splitlines()
+ used = next(line for line in lines if line.startswith("Used: "))
+ # t1 as used inside t0 is the same text as at t1's own definition.
+ site = next(line for line in lines if line.startswith("T1 "))
+ assert used == f"Used: T0 {site} {site}"
+
+
+@pytest.mark.parametrize(("n", "cycle"), [(40, False), (14, True)])
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+def test_a_branching_chain_of_terms_is_capped_and_reported(n: int, cycle: bool, scheme: str) -> None:
+ started = time.perf_counter()
+ result = render(FRONT + _chain(n, cycle=cycle, heading=True), format="text",
+ overrides={"numbering.scheme": scheme})
+ assert time.perf_counter() - started < 2
+ assert len(result.output) < 100_000
+ messages = [d.message for d in result.diagnostics if d.rule == "render-term-too-long"]
+ assert messages
+ assert len(messages) == len(set(messages)) # one per id
+ assert all("longer than 1000 characters" in message and "shows the definition id" in message
+ for message in messages)
+ assert "{{" not in result.output
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("heading", [False, True])
+def test_3000_independent_terms_show_their_text_and_no_warning(scheme: str, heading: bool) -> None:
+ result = render(FRONT + _independent(3000, heading=heading), format="text", overrides={"numbering.scheme": scheme})
+ assert messages(result, "render-term-limit") == []
+ lines = result.output.splitlines()
+ assert "Term 2999 used Term 2999" in lines
+ assert "Term 0 used Term 0" in lines
+ assert not any(line.endswith(" used t2999") for line in lines)
+ if heading and scheme == "none":
+ assert any(line.endswith("H2999 Term 2999") for line in lines)
+
+# -- A cached text is reused only where it would read the same -----------------
+
+def _oracle(self: Resolver, cache: object, definition_id: str, work, *, key: str | None = None) -> str: # noqa: ANN001
+ """The term text with the open terms alone deciding, nothing kept."""
+ self._terms_open.append(definition_id)
+ try:
+ return work()
+ finally:
+ self._terms_open.pop()
+
+
+def _graph(rng: random.Random) -> str:
+ ids = [f"t{i}" for i in range(rng.randint(2, 6))]
+ defs = []
+ for identifier in ids:
+ parts = [identifier.upper()]
+ for _ in range(rng.randint(0, 3)):
+ target = rng.choice(ids)
+ parts.append(f"{{{{term: {target}, label=L{target}}}}}" if rng.random() < 0.15
+ else f"{{{{term: {target}}}}}")
+ defs.append(f'"{" ".join(parts)}" {{{{def: {identifier}}}}}')
+ rng.shuffle(defs)
+ heading = rng.choice(["A", f"About {{{{term: {rng.choice(ids)}}}}}"])
+ use = " ".join(f"{{{{term: {rng.choice(ids)}}}}}" for _ in range(rng.randint(1, 4)))
+ return f"# {heading}\n\nUsed: {use}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def test_cached_term_texts_equal_the_texts_worked_out_afresh(monkeypatch: pytest.MonkeyPatch) -> None:
+ rng = random.Random(20261027)
+ cached_original = Resolver._while_open
+ for _ in range(150):
+ source = FRONT + _graph(rng)
+ for scheme in ("decimal", "none"):
+ overrides = {"numbering.scheme": scheme}
+ monkeypatch.setattr(Resolver, "_while_open", cached_original)
+ cached = render(source, format="text", overrides=overrides)
+ monkeypatch.setattr(Resolver, "_while_open", _oracle)
+ fresh = render(source, format="text", overrides=overrides)
+ assert cached.output == fresh.output, source
+ assert [(d.rule, d.message) for d in cached.diagnostics] == \
+ [(d.rule, d.message) for d in fresh.diagnostics], source
+
+
+# -- Terms that all name each other: the work budget ----------------------------
+
+
+@pytest.mark.parametrize("k", [3, 7, 9, 12, 20, 40])
+def test_k_terms_that_each_name_all_k_render_quickly(k: int) -> None:
+ started = time.perf_counter()
+ result = render(FRONT + _tangle(k), format="text")
+ assert time.perf_counter() - started < 2
+ assert "{{" not in result.output
+ if k >= 9:
+ assert messages(result, "render-term-limit") == [LIMIT]
+
+
+def test_a_small_cycle_is_resolved_in_full_without_the_warning() -> None:
+ assert messages(render(FRONT + _tangle(3), format="text"), "render-term-limit") == []
+
+
+def test_a_ring_of_terms_with_many_references_costs_a_small_multiple_of_the_document() -> None:
+ document = FRONT + _ring(12, 2000)
+ started = time.perf_counter()
+ result = render(document, format="text", overrides={"numbering.scheme": "none"})
+ cyclic = time.perf_counter() - started
+ # The same document with the references pointing at a term that names nothing.
+ flat = document.replace("{{term: t0}}", "{{term: leaf}}")
+ for i in range(12):
+ flat = flat.replace(f"{{{{term: t{i}}}}}", "{{term: leaf}}")
+ flat += '\n"Leaf" {{def: leaf}}\n'
+ started = time.perf_counter()
+ render(flat, format="text", overrides={"numbering.scheme": "none"})
+ acyclic = time.perf_counter() - started
+ assert messages(result, "render-term-limit")
+ assert cyclic < 3 * acyclic + 1
+
+
+# -- A long chain of terms: the depth limit ------------------------------------
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("heading", [False, True])
+def test_a_chain_of_500_terms_does_not_exhaust_the_stack(scheme: str, heading: bool) -> None:
+ n = 500
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
+ title = "About {{term: t0}}" if heading else "A"
+ body = f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
+ started = time.perf_counter()
+ result = render(FRONT + body, format="text", overrides={"numbering.scheme": scheme})
+ assert time.perf_counter() - started < 2
+ assert messages(result, "render-term-limit") == [LIMIT]
+ used = next(line for line in result.output.splitlines() if line.startswith("Used: "))
+ assert used.startswith("Used: T0 T1 T2 ")
+ assert "{{" not in result.output
+
+
+def test_a_chain_inside_the_depth_limit_is_resolved_in_full() -> None:
+ n = 20
+ result = render(FRONT + _line(n, heading=False), format="text")
+ assert f"Used: {' '.join(f'T{i}' for i in range(n))} End" in result.output
+ assert messages(result, "render-term-limit") == []
diff --git a/tests/test_wp5_text.py b/tests/test_text_writer.py
similarity index 78%
rename from tests/test_wp5_text.py
rename to tests/test_text_writer.py
index a896a6d..9ffef26 100644
--- a/tests/test_wp5_text.py
+++ b/tests/test_text_writer.py
@@ -1,5 +1,6 @@
-"""Text writer fidelity (WP5): attachment separators (W1), table cells and
-alignment (W4), quotes and list items (W5), list indent (W6), blank labels (W7)."""
+"""The plain-text writer's layout (§13.8): attachment separators, table cells
+and alignment, quotes and list items (also those that open with a table, code
+or a quote), list indent, and the labels a style may blank."""
from __future__ import annotations
import pytest
@@ -39,8 +40,7 @@ def text(body: str, **overrides: object) -> str:
return render(FRONT + body, format="text", overrides=overrides).output
-# -- W1: attachments.separator (§13.8) -------------------------------------------
-
+# -- Attachment separators (§13.8) -----------------------------------------------
@pytest.mark.parametrize("separator", ["rule", "page-break"])
def test_a_separator_precedes_each_attachment(separator: str) -> None:
@@ -67,8 +67,7 @@ def test_the_writer_needs_no_style_and_defaults_to_the_default_styles_separator(
assert TextWriter().write(result.tree) == result.output
-# -- W4: tables ------------------------------------------------------------------
-
+# -- Tables ----------------------------------------------------------------------
def test_a_pipe_in_a_cell_is_escaped() -> None:
output = text("# A\n\n| a | b |\n|---|---|\n| x \\| y | z |\n")
@@ -86,8 +85,7 @@ def test_a_table_with_fewer_alignments_than_columns_still_prints() -> None:
assert TextWriter().table(table) == "| x | x |\n|---:|---|\n| x | x |"
-# -- W5: quotes and list items ---------------------------------------------------
-
+# -- Quotes and list items -------------------------------------------------------
def test_one_quote_stays_one_quote() -> None:
output = text("# A\n\n> first\n>\n> second\n")
@@ -120,9 +118,35 @@ def test_a_paragraph_after_a_nested_list_is_separated() -> None:
assert "1. one\n (i) child\n\n after\n" in output
-# -- W6: list indent -------------------------------------------------------------
+def test_an_item_that_opens_with_a_table_keeps_a_valid_table() -> None:
+ out = text("# A\n\n1. | x | y |\n |---|---|\n | 1 | 2 |\n")
+ assert "\n1.\n | x | y |\n |---|---|\n | 1 | 2 |\n" in out
+ assert "1. |" not in out
+
+
+def test_an_item_that_opens_with_code_indents_it_under_the_item_text() -> None:
+ out = text("# A\n\n1. ```\n code\n more\n ```\n2. next\n")
+ assert "\n1.\n code\n more\n2. next\n" in out
+
+
+def test_an_item_that_opens_with_a_quote_puts_the_quote_on_its_own_lines() -> None:
+ out = text("# A\n\n1. > one\n >\n > two\n2. next\n")
+ assert "\n1.\n > one\n >\n > two\n2. next\n" in out
+ assert "1. >" not in out
+def test_blocks_after_a_non_paragraph_first_block_are_set_apart() -> None:
+ out = text("# A\n\n1. | x |\n |---|\n | 1 |\n\n then text\n")
+ assert "\n1.\n | x |\n |---|\n | 1 |\n\n then text\n" in out
+
+
+def test_an_item_that_opens_with_a_paragraph_is_unchanged() -> None:
+ out = text("# A\n\n1. first\n > quote\n2. second\n | x |\n |---|\n | 1 |\n")
+ assert "\n1. first\n\n > quote\n2. second\n\n | x |\n |---|\n | 1 |\n" in out
+
+
+# -- List indent -----------------------------------------------------------------
+
def test_a_conditional_item_indents_by_its_label_not_the_condition_label() -> None:
body = ("# A\n\n- first\n- by courts {#i when=forum:courts}\n\n nested\n\n - deep\n"
"- by arbitration {#i when=forum:arb}\n- last\n")
@@ -130,8 +154,7 @@ def test_a_conditional_item_indents_by_its_label_not_the_condition_label() -> No
assert "(b) [Only if: forum:courts] by courts\n\n nested\n (i) deep\n" in output
-# -- W7: blank labels ------------------------------------------------------------
-
+# -- Blank labels ----------------------------------------------------------------
def test_a_blanked_label_drops_its_prefix_in_party_details() -> None:
output = text("# A\n\nBody.\n", **{"labels.identification_number": ""})
diff --git a/tests/test_values.py b/tests/test_values.py
index 55bea26..2bcd94a 100644
--- a/tests/test_values.py
+++ b/tests/test_values.py
@@ -1,9 +1,20 @@
+"""Value formatting (§10, §13.5): money, dates and durations in a locale.
+
+Money keeps every digit it was written with and is padded to the currency's
+minor units, never rounded. A duration keeps its digits and takes the plural
+of the value shown, and no raw CLDR unit id is ever printed: a locale without
+a pattern falls back within the locale first and to English last, and says so.
+"""
from __future__ import annotations
+import re
+
import pytest
+from conftest import FRONT
-from legaldown_render.resolve import Formatter, parse_locale
-from legaldown_render.resolve.numbering import extend, fill, format_counter
+from legaldown_render import render
+from legaldown_render.resolve import DURATION_UNITS, Formatter, parse_locale
+from legaldown_render.resolve.values import DurationText
from legaldown_render.style.model import Values
@@ -13,20 +24,7 @@ def formatter(tag: str, **values: str) -> Formatter:
return Formatter(locale, Values(**values))
-@pytest.mark.parametrize(("value", "counter", "expected"), [
- (1, "lower-alpha", "a"), (26, "lower-alpha", "z"), (27, "lower-alpha", "aa"), (3, "upper-alpha", "C"),
- (4, "lower-roman", "iv"), (14, "upper-roman", "XIV"), (7, "decimal", "7"),
-])
-def test_counters(value: int, counter: str, expected: str) -> None:
- assert format_counter(value, counter) == expected
-
-
-def test_fill_and_extend() -> None:
- assert fill("{section}.{n} {other}", n="2", section="5") == "5.2 {other}"
- assert extend("", ".1", textual=False) == "1"
- assert extend("2.1", "(b)", textual=False) == "2.1(b)"
- assert extend("Termination", "(a)", textual=True) == "Termination (a)"
-
+# -- Money: exact amounts (§10.3) ------------------------------------------------
def test_money_is_padded_never_rounded() -> None:
en = formatter("en-US")
@@ -36,11 +34,256 @@ def test_money_is_padded_never_rounded() -> None:
assert en.money("1234.50", None) == "1,234.50"
+@pytest.mark.parametrize("digits", [29, 40, 60])
+def test_long_integers_format_exactly(digits: int) -> None:
+ value = "1234567890" * 6
+ value = value[:digits]
+ grouped = f"{int(value):,}"
+ en = formatter("en-US")
+ assert en.number(value) == grouped
+ assert en.money(value, "USD") == f"${grouped}.00"
+ assert formatter("en-US", money="code").money(value, "USD") == f"USD\xa0{grouped}.00"
+ assert formatter("en-US", money="name").money(value, "USD") == f"{grouped}.00 US dollars"
+
+
+def test_long_fraction_keeps_every_digit() -> None:
+ fraction = "1234567891" * 4
+ en = formatter("en-US")
+ assert en.number("0." + fraction) == "0." + fraction
+ assert en.money("1." + fraction, "EUR") == "€1." + fraction
+ assert en.number("1" * 29 + ".5") == f"{int('1' * 29):,}.5"
+
+
+def test_long_amount_renders_end_to_end() -> None:
+ source = "---\ntitle: T\n---\nPay {{money: " + "9" * 29 + "}} {{money: 12345678901234567890123456789.1234567890123456789}}.\n"
+ output = render(source, format="text", locale="en-US").output
+ assert f"{int('9' * 29):,}" in output
+ assert "12,345,678,901,234,567,890,123,456,789.1234567890123456789" in output
+
+
+@pytest.mark.parametrize(("amount", "currency", "expected"), [
+ ("1.1230", "EUR", "€1.1230"),
+ ("1.500", "EUR", "€1.500"),
+ ("1.50", "EUR", "€1.50"),
+ ("5", "EUR", "€5.00"),
+ ("5.5", "EUR", "€5.50"),
+ ("5", "JPY", "¥5"),
+ ("0.125", "JPY", "¥0.125"),
+ ("5.0", "JPY", "¥5.0"),
+ ("1234.5670", "USD", "$1,234.5670"),
+])
+def test_money_fraction_digits_symbol(amount: str, currency: str, expected: str) -> None:
+ assert formatter("en-US").money(amount, currency) == expected
+
+
+def test_money_fraction_digits_code_and_name() -> None:
+ code = formatter("en-US", money="code")
+ name = formatter("en-US", money="name")
+ assert code.money("1.500", "EUR") == "EUR\xa01.500"
+ assert code.money("5", "EUR") == "EUR\xa05.00"
+ assert code.money("0.125", "JPY") == "JPY\xa00.125"
+ assert code.money("5", "JPY") == "JPY\xa05"
+ assert name.money("1.500", "EUR") == "1.500 euros"
+ assert name.money("5", "EUR") == "5.00 euros"
+ assert name.money("0.125", "JPY") == "0.125 Japanese yen"
+ assert name.money("5", "JPY") == "5 Japanese yen"
+
+
+def test_money_trailing_zeros_in_other_locales_and_long_amounts() -> None:
+ assert formatter("cs-CZ").money("1.500", "CZK") == "1,500\xa0Kč"
+ assert formatter("de", money="code").money("1.1230", "EUR") == "1,1230\xa0EUR"
+ assert formatter("en-US").money("1" * 40 + ".1230", "EUR") == f"€{int('1' * 40):,}.1230"
+
+
+# -- Money: how the currency is shown --------------------------------------------
+
def test_money_display_styles() -> None:
assert formatter("en-US", money="code").money("10", "USD") == "USD\xa010.00"
assert formatter("en-US", money="name").money("10", "USD") == "10.00 US dollars"
+@pytest.mark.parametrize(("tag", "currency", "expected"), [
+ ("en", "CZK", "CZK\xa05.00"),
+ ("en", "CHF", "CHF\xa05.00"),
+ ("en", "USD", "$5.00"),
+ ("en", "EUR", "€5.00"),
+ ("pt-BR", "BRL", "R$\xa05,00"),
+ ("en", "CAD", "CA$5.00"),
+ ("cs", "CZK", "5,00\xa0Kč"),
+])
+def test_symbol_mode_spacing(tag: str, currency: str, expected: str) -> None:
+ assert formatter(tag).money("5", currency) == expected
+
+
+def test_symbol_after_the_digits_is_spaced_too() -> None:
+ # Whatever the locale puts after the amount, a letter never touches a digit.
+ for tag in ("en", "de", "fr", "cs", "pl", "sv", "ja", "ru", "pt-PT", "es", "nl", "it"):
+ for currency in ("CZK", "CHF", "USD", "EUR", "SEK", "PLN", "XAF"):
+ text = formatter(tag).money("5", currency)
+ assert not re.search(r"[^\W\d_]\d|\d[^\W\d_]", text), (tag, currency, text)
+
+
+def test_code_mode_still_spaced() -> None:
+ assert formatter("en", money="code").money("5", "CZK") == "CZK\xa05.00"
+ assert formatter("de", money="code").money("5", "CZK") == "5,00\xa0CZK"
+
+
+# -- Durations: digits and plural ------------------------------------------------
+
+def test_long_duration_keeps_every_digit() -> None:
+ assert formatter("en-US").duration("1" * 40, "D") == f"{int('1' * 40):,} days"
+ assert formatter("en-US").duration("0." + "1" * 40, "D") == "0." + "1" * 40 + " days"
+
+
+@pytest.mark.parametrize(("tag", "value", "unit", "expected"), [
+ ("en", "0.0001", "D", "0.0001 days"),
+ ("en", "1.23456", "Y", "1.23456 years"),
+ ("en", "1", "D", "1 day"),
+ ("en", "1.0", "D", "1.0 days"),
+ ("en", "12", "MO", "12 months"),
+ ("cs", "1", "Y", "1 rok"),
+ ("cs", "2", "Y", "2 roky"),
+ ("cs", "5", "Y", "5 let"),
+ ("cs", "2.5", "Y", "2,5 roku"),
+ ("de", "1.50", "W", "1,50 Wochen"),
+])
+def test_duration_digits_and_plural(tag: str, value: str, unit: str, expected: str) -> None:
+ assert formatter(tag).duration(value, unit) == expected
+
+
+# -- Fractions beyond Python's int/str digit limit: the plural follows the first digits ----
+
+LONG = "2" * 4400
+
+
+def test_a_duration_with_a_4400_digit_fraction_renders() -> None:
+ output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=D}}}}", format="text").output
+ assert f"1.{LONG} days" in output
+
+
+def test_a_money_amount_in_name_mode_with_a_4400_digit_fraction_renders() -> None:
+ output = render(FRONT + f"# A\n\n{{{{money: 1.{LONG}, currency=EUR}}}}", format="text",
+ overrides={"values.money": "name"}).output
+ assert f"1.{LONG} euros" in output
+
+
+def test_a_4400_digit_fraction_uses_the_many_category_in_czech() -> None:
+ # The category comes from the first 30 fraction digits, so it is the
+ # "many" of "2,5 roku", not "other" (" let").
+ output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=Y}}}}", format="text", locale="cs-CZ").output
+ assert output.rstrip().endswith(" roku")
+
+
+@pytest.mark.parametrize("unit", ["D", "Y", "W"])
+@pytest.mark.parametrize("locale", ["cs", "sk", "lt", "pl", "ru", "en"])
+def test_a_huge_fraction_has_the_unit_form_of_its_first_digits(locale: str, unit: str) -> None:
+ fmt = formatter(locale)
+ huge = fmt.format_duration("1." + "3" * 4400, unit).text
+ short = fmt.format_duration("1." + "3" * 40, unit).text
+ assert huge.replace("3" * 4400, "") == short.replace("3" * 40, "")
+
+
+def test_a_huge_fraction_is_many_in_czech() -> None:
+ fmt = formatter("cs")
+ assert fmt.format_duration("1." + "3" * 4400, "D").text.endswith(" dne")
+ assert fmt.format_duration("1." + "3" * 40, "D").text.endswith(" dne")
+
+
+def test_a_huge_fraction_of_zeros_ending_in_a_digit_keeps_its_fraction() -> None:
+ # Cut to 30 digits it is "1.000...0": still a fraction (v != 0), so "many" in Czech.
+ value = "1." + "0" * 4399 + "1"
+ assert formatter("cs").format_duration(value, "D").text.rsplit(" ", 1)[-1] == "dne"
+
+
+def test_a_name_mode_money_amount_with_a_huge_fraction_uses_the_form_of_its_first_digits() -> None:
+ fmt = formatter("cs", money="name")
+ huge = fmt.money("1." + "3" * 4400, "EUR")
+ short = fmt.money("1." + "3" * 40, "EUR")
+ assert huge.replace("3" * 4400, "") == short.replace("3" * 40, "")
+ assert huge.endswith("eura") # "many", not the "other" form "eur"
+
+
+def test_an_integer_part_beyond_the_limit_falls_back_to_other() -> None:
+ # No category can be computed (not even from the first digits): "other" ("dnů" in Czech).
+ other = formatter("cs").format_duration("1.5", "D").text.rsplit(" ", 1)[-1]
+ assert other == "dne"
+ assert formatter("cs").format_duration("1" * 4400, "D").text.endswith(" dnů")
+
+
+# -- CLDR duration patterns and the English fallback -----------------------------
+
+@pytest.mark.parametrize("tag", ["pt-BR", "ja", "zh", "ko", "it", "tr", "nl", "cs", "de", "sk", "pl", "en"])
+@pytest.mark.parametrize("length", ["long", "short", "narrow"])
+@pytest.mark.parametrize("value", ["1", "2", "5", "1.5"])
+def test_no_cldr_unit_id_is_ever_printed(tag: str, length: str, value: str) -> None:
+ f = formatter(tag, duration=length)
+ for unit in DURATION_UNITS:
+ text = f.duration(value, unit)
+ assert "duration-" not in text
+ assert value.replace(".", f.locale.number_symbols["latn"]["decimal"]) in text
+
+
+def test_pt_br_long_falls_back_to_the_short_pattern() -> None:
+ pt = formatter("pt-BR")
+ assert pt.duration("30", "D") == "30 dias"
+ assert pt.duration("12", "MO") == "12 meses"
+ assert pt.duration("1", "Y") == "1 ano"
+
+
+def test_fallback_goes_to_the_other_length_first() -> None:
+ # cs has a long and a narrow pattern: narrow is asked, narrow is used.
+ assert formatter("cs", duration="narrow").duration("30", "D") == "30 d."
+ # zh has only "short": long and narrow fall back to it, still Chinese.
+ assert formatter("zh", duration="long").duration("30", "D") == "30天"
+ assert formatter("zh", duration="narrow").duration("30", "D") == "30天"
+
+
+def test_a_fallback_inside_the_locale_is_not_signalled() -> None:
+ # pt-BR long has no pattern but short has: Portuguese, no English.
+ assert formatter("pt-BR").format_duration("30", "D") == DurationText("30 dias", False)
+ assert formatter("en").format_duration("1", "D") == DurationText("1 day", False)
+
+
+def test_english_is_the_last_resort_and_is_signalled(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown_render.resolve import values
+
+ real = values._unit_patterns
+ # A Czech locale without any duration pattern (Babel's data has none such).
+ monkeypatch.setattr(values, "_unit_patterns",
+ lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
+ f = formatter("cs")
+ assert f.format_duration("5", "D") == DurationText("5 days", True)
+ assert f.duration("1", "D") == "1 day"
+ assert f.duration("1.5", "Y") == "1,5 years" # the number keeps the locale's separator
+
+
+def test_fallback_warning_is_emitted_once_by_the_resolver(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown_render.resolve import values
+
+ real = values._unit_patterns
+ monkeypatch.setattr(values, "_unit_patterns",
+ lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
+ source = ("---\ntitle: T\nlanguage: cs\n---\n"
+ "Lhůta {{duration: 5, unit=D}} a {{duration: 6, unit=D}} a {{duration: 2, unit=MO}}.\n")
+ result = render(source, format="text")
+ fallbacks = [d for d in result.diagnostics if d.rule == "render-locale-fallback"]
+ assert [d.message for d in fallbacks] == [
+ "No CLDR pattern for 'duration-day' in 'cs'; English used.",
+ "No CLDR pattern for 'duration-month' in 'cs'; English used.",
+ ]
+ assert all(d.level == "warning" for d in fallbacks)
+ assert "5 days" in result.output
+
+
+def test_normal_locales_emit_no_fallback_warning() -> None:
+ for language in ("cs", "pt-BR", "ja", "en"):
+ source = f"---\ntitle: T\nlanguage: {language}\n---\nX {{{{duration: 5, unit=D}}}}.\n"
+ result = render(source, format="text")
+ assert not [d for d in result.diagnostics if d.rule == "render-locale-fallback"], language
+
+
+# -- Locales ---------------------------------------------------------------------
+
def test_locales() -> None:
cs = formatter("cs-CZ")
assert cs.date("2026-06-01") == "1. června 2026"
diff --git a/tests/test_wp10_review_fixes.py b/tests/test_wp10_review_fixes.py
deleted file mode 100644
index 2c6ca00..0000000
--- a/tests/test_wp10_review_fixes.py
+++ /dev/null
@@ -1,220 +0,0 @@
-"""Review fixes after wave 2 (WP10): links are an allow-list and a refused one is
-reported, control characters never reach a diagnostic, style templates are
-validated only where a setting is used, and small wording fixes."""
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-from pathlib import Path
-
-import pytest
-
-from legaldown_render import StyleError, render
-from legaldown_render.style import load_style
-from legaldown_render.urls import displayed_url, is_link_target, printable
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-FRONT = "---\ntitle: T\n---\n\n"
-
-
-def html(body: str, **settings: object) -> str:
- return render(FRONT + body, format="html", **settings).output
-
-
-def text(body: str, **settings: object) -> str:
- return render(FRONT + body, format="text", **settings).output
-
-
-# -- 1: links are an allow-list ---------------------------------------------------
-
-HANDLERS = [
- "ms-msdt:/id PCWDiagnostic", "ms-word:ofe|u|https://x/a.docx", "ms-officecmd:{}", "search-ms:query=x",
- "ms-appinstaller:?source=https://x/a.appx", "vscode://file/x", "intent://x#Intent;end", "blob:https://x/1",
- "about:blank", "jar:https://x/a.jar!/", "view-source:https://x", "mhtml:file://x", "ms-its:x.chm::/a.htm",
- "C:\\x", "C:/x", "localhost:8080", "magnet:?xt=urn:btih:x", "javascript:alert(1)", "file:///etc/passwd",
- "data:text/html,x", " MS-MSDT:x",
-]
-ALLOWED = [
- "http://e/x", "https://e/x", "mailto:a@e", "tel:+1", "ftp://e/x", "ftps://e/x", "sftp://e/x", "sip:a@e",
- "sips:a@e", "sms:+1", "urn:isbn:1", "geo:1,2", "news:a.b", "a/b.pdf", "../a.pdf", "#top", "?q=1",
-]
-
-
-@pytest.mark.parametrize("href", HANDLERS)
-def test_a_protocol_handler_or_pseudo_scheme_is_not_a_link(href: str) -> None:
- assert not is_link_target(href)
- body = f"# A\n\nSee [click](<{href}>) now.\n"
- result = render(FRONT + body, format="html")
- assert "See click now." in result.output
- assert "")[1]
- assert "See click now." in text(body)
- assert "<" not in text(body).split("See click now.")[0].split("\n")[-1]
-
-
-def test_the_scheme_is_read_as_a_browser_reads_it() -> None:
- # markdown-it percent-encodes a tab in a URL it parses ("ms%09-msdt:x" is a
- # relative path to a browser); the check still holds for a URL it is given
- # directly, as a browser would read it.
- for href in ("ms\t-msdt:x", "jav\nascript:x", "\x01javascript:x", "\u0000 vscode://x"):
- assert not is_link_target(href), repr(href)
-
-
-@pytest.mark.parametrize("href", ALLOWED)
-def test_an_allowed_scheme_or_a_relative_url_is_a_link(href: str) -> None:
- assert is_link_target(href)
- assert f'click' in html(f"# A\n\nSee [click](<{href}>).\n")
-
-
-def test_a_refused_link_is_reported_once_as_a_warning() -> None:
- body = "# A\n\n[a](ms-msdt:x) and [b](ms-msdt:x) and [c](vscode://x) and [d](http://ok) and [e]().\n"
- result = render(FRONT + body, format="html")
- refused = [d for d in result.diagnostics if d.rule == "render-link-refused"]
- assert [d.level for d in refused] == ["warning", "warning"]
- assert "Link to 'ms-msdt:x' is not rendered as a link" in refused[0].message
- assert "its text is shown" in refused[0].message
- assert "vscode://x" in refused[1].message
- assert result.ok # a Warning does not refuse, even under --strict's Errors
- assert render(FRONT + body, format="text").diagnostics == result.diagnostics
-
-
-def test_an_empty_or_allowed_link_is_not_reported() -> None:
- result = render(FRONT + "# A\n\n[a]() [b](<>) [c](https://e) [d](#x) [e](rel/path)\n")
- assert not [d for d in result.diagnostics if d.rule == "render-link-refused"]
-
-
-# -- 3, 4, 5: what a diagnostic shows of a document ------------------------------
-
-
-def test_printable_escapes_controls_and_format_characters() -> None:
- assert printable("a\x1b[2J\x07\n\x7f\x85") == "a\\x1b[2J\\x07\\x0a\\x7f\\x85"
- assert printable("\u202e\u200b\ufeff\u2028\u2029") == "\\u202e\\u200b\\ufeff\\u2028\\u2029"
- assert printable("ž é €") == "ž é €"
-
-
-EVIL = "https://e/%1b[2J%1b]0;pwned%07%0aerror: [x] forged%e2%80%ae"
-
-
-def test_a_control_character_in_an_image_path_is_escaped_in_the_message() -> None:
- result = render(FRONT + f"# A\n\n\n")
- messages = [d.message for d in result.diagnostics if d.rule == "path-not-relative"]
- assert messages
- for message in messages:
- assert "\x1b" not in message and "\x07" not in message and "\n" not in message
- assert any("\\x1b[2J\\x1b]0;pwned\\x07\\x0aerror: [x] forged\\u202e" in m for m in messages)
-
-
-def test_a_control_character_in_a_link_is_escaped_in_the_message() -> None:
- result = render(FRONT + f"# A\n\n[x]()\n")
- message = next(d.message for d in result.diagnostics if d.rule == "render-link-refused")
- assert "\x1b" not in message and "\x07" not in message and "\n" not in message and "\u202e" not in message
- assert "\\x1b" in message
-
-
-def test_the_cli_writes_no_control_character_from_a_document_to_standard_error(tmp_path: Path) -> None:
- source = tmp_path / "evil.lgd"
- source.write_text(FRONT + f"# A\n\n [y]()\n", encoding="utf-8")
- env = dict(os.environ, PYTHONPATH=str(SRC))
- done = subprocess.run([sys.executable, "-m", "legaldown_render.cli", str(source), "-f", "text"],
- capture_output=True, env=env, check=False)
- err = done.stderr.decode("utf-8")
- assert "path-not-relative" in err and "render-link-refused" in err
- assert "\x1b" not in err and "\x07" not in err and "\u202e" not in err
- assert "\r" not in err
- assert not any(line.startswith("error: [x]") for line in err.splitlines())
-
-
-def test_a_path_that_starts_another_is_not_covered_by_the_validators_report() -> None:
- # Whatever the validator reports for one path, a different path whose text
- # it contains is still the renderer's to report.
- result = render(FRONT + "# A\n\n \n")
- messages = [d.message for d in result.diagnostics if d.rule == "path-not-relative"]
- assert any("'/x.png2'" in m for m in messages)
- assert any("'/x.png'" in m for m in messages)
-
-
-@pytest.mark.parametrize("path", ["javascript:alert(1)", "data:text/html,x", "https://e/p.png"])
-def test_the_not_relative_message_is_generic(path: str) -> None:
- result = render(FRONT + f"# A\n\n\n")
- message = next(d.message for d in result.diagnostics if d.rule == "path-not-relative")
- assert "never fetched" not in message
- assert "file references are relative paths with no URI scheme. Its alt text is shown instead." in message
-
-
-# -- 8: a link with no text shows the URL as written ----------------------------
-
-
-def test_displayed_url_undoes_the_encoding_unless_it_shows_a_control() -> None:
- assert displayed_url(" https://e/a%20b ") == "https://e/a b"
- assert displayed_url("https://e/a%0ab") == "https://e/a%0ab"
- assert displayed_url("https://e/%E2%80%AE") == "https://e/%E2%80%AE"
-
-
-def test_a_link_with_no_text_shows_the_written_url() -> None:
- body = "# A\n\nSee []().\n"
- assert 'href="https://e/a%20b/%C3%A9">https://e/a b/é' in html(body)
- assert "See https://e/a b/é." in text(body)
-
-
-# -- 6: a style is validated where its settings are used ------------------------
-
-def _problems(overrides: dict[str, object]) -> list[str]:
- with pytest.raises(StyleError) as caught:
- load_style("default", overrides=overrides)
- return caught.value.problems
-
-
-NO_N = [{"label": "x", "ref": "x"}]
-
-
-@pytest.mark.parametrize(("overrides", "key"), [
- ({"enumeration.levels": NO_N}, "enumeration.levels[0].ref"),
- ({"enumeration.levels": NO_N, "enumeration.enabled": False, "enumeration.ordered": "enumerate"},
- "enumeration.levels[0].ref"),
- ({"numbering.levels": NO_N}, "numbering.levels[0].ref"),
- ({"paragraphs.numbered": True, "paragraphs.format": {"label": "x", "ref": "x"}}, "paragraphs.format.ref"),
-])
-def test_a_ref_that_is_in_use_must_have_its_counter(overrides: dict[str, object], key: str) -> None:
- assert any(p.startswith(key) and "{n}" in p for p in _problems(overrides))
-
-
-@pytest.mark.parametrize("overrides", [
- {"enumeration.levels": NO_N, "enumeration.enabled": False},
- {"numbering.levels": NO_N, "numbering.scheme": "none"},
- {"paragraphs.format": {"label": "x", "ref": "x"}},
- {"paragraphs.format": {"label": "x", "ref": ""}},
-])
-def test_a_ref_that_is_not_in_use_may_leave_out_its_counter(overrides: dict[str, object]) -> None:
- load_style("default", overrides=overrides)
-
-
-def test_a_typo_is_flagged_even_where_the_setting_is_not_used() -> None:
- problems = _problems({"numbering.scheme": "none", "numbering.levels": [{"label": "{n}", "ref": "{m}"}]})
- assert any(p.startswith("numbering.levels[0].ref") and "{m}" in p and "unknown field" in p for p in problems)
-
-
-def test_a_plain_label_may_contain_braced_words() -> None:
- style = load_style("default", overrides={"labels.drafting_note": "Note {see below}", "labels.colon": "{x}:"})
- assert style.labels.drafting_note == "Note {see below}"
- assert style.labels.colon == "{x}:"
-
-
-def test_the_labels_that_substitute_still_check_their_fields() -> None:
- assert _problems({"labels.condition": "{condtion}"})
- assert _problems({"labels.attachment_file": "{fil}"})
-
-
-# -- 7: a blank of invisible characters is no visual blank ------------------------
-
-
-@pytest.mark.parametrize("blank", ["", "", " ", " ", ""])
-@pytest.mark.parametrize("fmt", ["html", "text"])
-def test_a_blank_of_invisible_characters_falls_back_to_tbd(blank: str, fmt: str) -> None:
- source = FRONT.replace("title: T", "title: T\nplaceholders:\n - id: fee\n type: text") + "# A\n\nFee {{placeholder: fee}}.\n"
- result = render(source, format=fmt, overrides={"placeholders.blank": blank})
- assert "[TBD: fee]" in result.output
-
-
-def test_a_blank_with_a_visible_character_is_kept() -> None:
- source = FRONT.replace("title: T", "title: T\nplaceholders:\n - id: fee\n type: text") + "# A\n\nFee {{placeholder: fee}}.\n"
- assert "Fee __." in render(source, format="text", overrides={"placeholders.blank": "__"}).output
diff --git a/tests/test_wp11_review_fixes.py b/tests/test_wp11_review_fixes.py
deleted file mode 100644
index 9cf45af..0000000
--- a/tests/test_wp11_review_fixes.py
+++ /dev/null
@@ -1,253 +0,0 @@
-"""Review fixes after wave 3 (WP11): the plural of a huge fraction is computed
-from its first digits, a list item opening with a table, code or quote keeps
-that block intact in plain text, the ``-o`` extension rule has no holes, and a
-term text worked out inside a cycle is not kept."""
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-import time
-from pathlib import Path
-
-import pytest
-from babel import Locale
-
-from legaldown_render import render
-from legaldown_render.resolve.values import Formatter
-from legaldown_render.style.model import Values
-from legaldown_render.writers import file_extension, format_for_path
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-FRONT = "---\ntitle: T\n---\n\n"
-FEATURES = str(Path(__file__).resolve().parent / "documents" / "features.lgd")
-
-
-def text(body: str, **settings: object) -> str:
- return render(FRONT + body, format="text", **settings).output
-
-
-# -- 1: the plural category of a huge fraction -----------------------------------
-
-
-def _formatter(locale: str, **values: str) -> Formatter:
- return Formatter(Locale.parse(locale), Values(**values))
-
-
-@pytest.mark.parametrize("unit", ["D", "Y", "W"])
-@pytest.mark.parametrize("locale", ["cs", "sk", "lt", "pl", "ru", "en"])
-def test_a_huge_fraction_has_the_unit_form_of_its_first_digits(locale: str, unit: str) -> None:
- formatter = _formatter(locale)
- huge = formatter.format_duration("1." + "3" * 4400, unit).text
- short = formatter.format_duration("1." + "3" * 40, unit).text
- assert huge.replace("3" * 4400, "") == short.replace("3" * 40, "")
-
-
-def test_a_huge_fraction_is_many_in_czech() -> None:
- formatter = _formatter("cs")
- assert formatter.format_duration("1." + "3" * 4400, "D").text.endswith(" dne")
- assert formatter.format_duration("1." + "3" * 40, "D").text.endswith(" dne")
-
-
-def test_a_huge_fraction_of_zeros_ending_in_a_digit_keeps_its_fraction() -> None:
- # Cut to 30 digits it is "1.000...0": still a fraction (v != 0), so "many" in Czech.
- value = "1." + "0" * 4399 + "1"
- assert _formatter("cs").format_duration(value, "D").text.rsplit(" ", 1)[-1] == "dne"
-
-
-def test_a_name_mode_money_amount_with_a_huge_fraction_uses_the_form_of_its_first_digits() -> None:
- formatter = _formatter("cs", money="name")
- huge = formatter.money("1." + "3" * 4400, "EUR")
- short = formatter.money("1." + "3" * 40, "EUR")
- assert huge.replace("3" * 4400, "") == short.replace("3" * 40, "")
- assert huge.endswith("eura") # "many", not the "other" form "eur"
-
-
-def test_an_integer_part_beyond_the_limit_falls_back_to_other() -> None:
- # No category can be computed (not even from the first digits): "other" ("dnů" in Czech).
- other = _formatter("cs").format_duration("1.5", "D").text.rsplit(" ", 1)[-1]
- assert other == "dne"
- assert _formatter("cs").format_duration("1" * 4400, "D").text.endswith(" dnů")
-
-
-# -- 2: an item that opens with a table, code or a quote -------------------------
-
-
-def test_an_item_that_opens_with_a_table_keeps_a_valid_table() -> None:
- out = text("# A\n\n1. | x | y |\n |---|---|\n | 1 | 2 |\n")
- assert "\n1.\n | x | y |\n |---|---|\n | 1 | 2 |\n" in out
- assert "1. |" not in out
-
-
-def test_an_item_that_opens_with_code_indents_it_under_the_item_text() -> None:
- out = text("# A\n\n1. ```\n code\n more\n ```\n2. next\n")
- assert "\n1.\n code\n more\n2. next\n" in out
-
-
-def test_an_item_that_opens_with_a_quote_puts_the_quote_on_its_own_lines() -> None:
- out = text("# A\n\n1. > one\n >\n > two\n2. next\n")
- assert "\n1.\n > one\n >\n > two\n2. next\n" in out
- assert "1. >" not in out
-
-
-def test_blocks_after_a_non_paragraph_first_block_are_set_apart() -> None:
- out = text("# A\n\n1. | x |\n |---|\n | 1 |\n\n then text\n")
- assert "\n1.\n | x |\n |---|\n | 1 |\n\n then text\n" in out
-
-
-def test_an_item_that_opens_with_a_paragraph_is_unchanged() -> None:
- out = text("# A\n\n1. first\n > quote\n2. second\n | x |\n |---|\n | 1 |\n")
- assert "\n1. first\n\n > quote\n2. second\n\n | x |\n |---|\n | 1 |\n" in out
-
-
-# -- 3: the extension rule of -o --------------------------------------------------
-
-
-@pytest.mark.parametrize(("name", "extension"), [
- ("x.pdf", "pdf"), (".pdf", "pdf"), (".md", "md"), ("x.pdf.", "pdf"), ("x.docx ", "docx"),
- ("x.pdf. .", "pdf"), ("X.PDF", "pdf"), ("a.b.txt", "txt"), ("contract", ""), ("", ""), (".", ""),
- ("..", ""), ("x.", ""),
-])
-def test_the_extension_of_a_file_name(name: str, extension: str) -> None:
- assert file_extension(name) == extension
-
-
-@pytest.mark.parametrize(("name", "expected"), [
- (".txt", "text"), ("x.txt.", "text"), ("x.html ", "html"), (".htm", "html"), ("x.unknown", None),
-])
-def test_the_format_a_file_name_implies(name: str, expected: str | None) -> None:
- assert format_for_path(name) == expected
-
-
-def _run(*arguments: str, cwd: Path) -> subprocess.CompletedProcess[str]:
- env = {**os.environ, "PYTHONPATH": str(SRC)}
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *arguments],
- capture_output=True, text=True, cwd=cwd, env=env, check=False)
-
-
-@pytest.mark.parametrize("name", [".pdf", ".md", "x.pdf.", "x.docx ", "x.docx. .", "dir.pdf", "x.MD"])
-def test_an_unwritten_extension_is_refused_however_it_is_spelled(tmp_path: Path, name: str) -> None:
- result = _run(FEATURES, "-o", name, "-q", cwd=tmp_path)
- assert result.returncode == 2, result.stderr
- assert "cannot tell the output format" in result.stderr
- assert list(tmp_path.iterdir()) == []
-
-
-@pytest.mark.parametrize("name", ["x.pdf.", ".md", "x.docx "])
-def test_format_overrides_an_unwritten_extension(tmp_path: Path, name: str) -> None:
- result = _run(FEATURES, "-o", name, "-f", "text", "-q", cwd=tmp_path)
- assert result.returncode == 0, result.stderr
- assert not (tmp_path / name).read_text(encoding="utf-8").lstrip().startswith("<")
-
-
-@pytest.mark.parametrize("name", ["contract", "contract.v2", "report.2025-10-27", ".out", "x.", "x.."])
-def test_a_name_without_a_known_extension_still_writes_html(tmp_path: Path, name: str) -> None:
- result = _run(FEATURES, "-o", name, "-q", cwd=tmp_path)
- assert result.returncode == 0, result.stderr
- assert " None:
- result = _run(FEATURES, "-o", name, "-q", cwd=tmp_path)
- assert result.returncode == 0, result.stderr
- assert marker not in (tmp_path / name).read_text(encoding="utf-8").lower()
-
-
-# -- 4: a term text worked out inside a cycle is not kept ------------------------
-
-CYCLE = ('# A\n\nUsed first: {{term: ca}} and {{term: cb}}.\n\n'
- '"Alpha {{term: cb}}" {{def: ca}}\n\n"Beta {{term: ca}}" {{def: cb}}\n')
-
-
-def test_a_term_reference_in_a_cycle_does_not_depend_on_the_order_of_use() -> None:
- # WP12: a term shown by {{term:}} is its text with that term open (a reference back to it
- # shows its id), whichever term is used first. (The definition site renders with no term
- # open, so it can read longer: "Beta Alpha Beta ca" there, "Beta Alpha cb" in a reference.)
- ca_first = text(CYCLE)
- cb_first = text(CYCLE.replace("{{term: ca}} and {{term: cb}}", "{{term: cb}} and {{term: ca}}"))
- assert "Used first: Alpha Beta ca and Beta Alpha cb." in ca_first
- assert "Used first: Beta Alpha cb and Alpha Beta ca." in cb_first
-
-
-def _chain(n: int, *, cycle: bool, heading: bool = False) -> str:
- """Terms t0..tn, each reading the next one twice; the last one reads t0 when *cycle*."""
- defs = [f'"T{i} {{{{term: t{i + 1}}}}} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)]
- defs.append(f'"T{n}{" {{term: t0}}" if cycle else ""}" {{{{def: t{n}}}}}')
- title = "About {{term: t0}}" if heading else "A"
- return f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
-
-
-@pytest.mark.parametrize("heading", [False, True])
-@pytest.mark.parametrize("scheme", ["decimal", "none"])
-@pytest.mark.parametrize("cycle", [False, True])
-def test_a_branching_chain_of_terms_is_worked_out_once_per_term(cycle: bool, scheme: str, heading: bool) -> None:
- # Each term reads the next twice: worked out afresh at each use, 20 terms is 2**20 resolutions.
- # The text of t0 is itself 2**n words long (4**n with a cycle, which reads the chain round
- # twice), so the chains cannot be longer. Under the none scheme a heading is read as text
- # before the targets are known (§13.3).
- started = time.perf_counter()
- out = text(_chain(9 if cycle else 20, cycle=cycle, heading=heading), overrides={"numbering.scheme": scheme})
- assert time.perf_counter() - started < 2
- assert "{{" not in out
-
-
-def test_a_chain_without_a_cycle_reads_as_each_term_does() -> None:
- lines = text(_chain(3, cycle=False)).splitlines()
- used = next(line for line in lines if line.startswith("Used: "))
- # t1 as used inside t0 is the same text as at t1's own definition.
- site = next(line for line in lines if line.startswith("T1 "))
- assert used == f"Used: T0 {site} {site}"
-
-
-# -- 4b: a term text is capped ----------------------------------------------------
-
-
-def _warnings(body: str, **settings: object) -> list[str]:
- return [d.message for d in render(FRONT + body, format="text", **settings).diagnostics
- if d.rule == "render-term-too-long"]
-
-
-@pytest.mark.parametrize(("n", "cycle"), [(40, False), (14, True)])
-@pytest.mark.parametrize("scheme", ["decimal", "none"])
-def test_a_branching_chain_of_terms_is_capped_and_reported(n: int, cycle: bool, scheme: str) -> None:
- started = time.perf_counter()
- result = render(FRONT + _chain(n, cycle=cycle, heading=True), format="text",
- overrides={"numbering.scheme": scheme})
- assert time.perf_counter() - started < 2
- assert len(result.output) < 100_000
- messages = [d.message for d in result.diagnostics if d.rule == "render-term-too-long"]
- assert messages
- assert len(messages) == len(set(messages)) # one per id
- assert all("longer than 1000 characters" in message and "shows the definition id" in message
- for message in messages)
- assert "{{" not in result.output
-
-
-def test_a_term_over_the_cap_shows_its_id_and_one_warning_names_it() -> None:
- long_term = "word " * 250
- body = f'# A\n\n"{long_term}" {{{{def: big}}}}\n\nSee {{{{term: big}}}} and {{{{term: big}}}}.\n'
- out = text(body)
- assert "See big and big." in out
- assert _warnings(body) == [
- "The term of 'big' is longer than 1000 characters once its references are resolved; "
- "'{{term: big}}' shows the definition id."]
-
-
-def test_a_label_still_wins_over_the_cap() -> None:
- body = '# A\n\n"' + "word " * 250 + '" {{def: big}}\n\nSee {{term: big, label=Big}}.\n'
- assert "See Big." in text(body)
-
-
-def test_a_term_at_the_cap_is_shown_whole() -> None:
- term = "x" * 1000
- body = f'# A\n\n"{term}" {{{{def: edge}}}}\n\nSee {{{{term: edge}}}}.\n'
- assert f"See {term}." in text(body)
- assert _warnings(body) == []
-
-
-def test_a_normal_nested_term_is_unaffected() -> None:
- body = ('# A\n\n"Company" {{def: company}}\n\n"Affiliate of the {{term: company}}" {{def: affiliate}}\n\n'
- 'See {{term: affiliate}}.\n')
- assert "See Affiliate of the Company." in text(body)
- assert _warnings(body) == []
diff --git a/tests/test_wp12_final_fixes.py b/tests/test_wp12_final_fixes.py
deleted file mode 100644
index 704c927..0000000
--- a/tests/test_wp12_final_fixes.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""Final review fixes (WP12): terms that name each other are resolved within a
-budget (``render-term-limit``), a cached term text is reused only where it would
-read the same, long chains of terms do not exhaust the stack, and nothing from a
-document reaches stderr as a control character."""
-from __future__ import annotations
-
-import os
-import random
-import subprocess
-import sys
-import time
-from pathlib import Path
-
-import pytest
-
-from legaldown_render import render
-from legaldown_render.resolve.resolver import Resolver
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-FRONT = "---\ntitle: T\n---\n\n"
-LIMIT = ("Defined terms refer to each other too deeply or too often to resolve fully; "
- "some {{term:}} references show their definition id.")
-
-
-def messages(result, rule: str) -> list[str]: # noqa: ANN001
- return [d.message for d in result.diagnostics if d.rule == rule]
-
-
-# -- 1: terms that all name each other ---------------------------------------------
-
-
-def _tangle(k: int) -> str:
- defs = [f'"T{i} {" ".join(f"{{{{term: t{j}}}}}" for j in range(k))}" {{{{def: t{i}}}}}' for i in range(k)]
- return "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
-
-
-@pytest.mark.parametrize("k", [3, 9, 12, 20, 40])
-def test_k_terms_that_each_name_all_k_render_quickly(k: int) -> None:
- started = time.perf_counter()
- result = render(FRONT + _tangle(k), format="text")
- assert time.perf_counter() - started < 2
- assert "{{" not in result.output
- if k >= 9:
- assert messages(result, "render-term-limit") == [LIMIT]
-
-
-def test_a_small_cycle_is_resolved_in_full_without_the_warning() -> None:
- assert messages(render(FRONT + _tangle(3), format="text"), "render-term-limit") == []
-
-
-# -- 3: a long chain of terms ------------------------------------------------------
-
-
-@pytest.mark.parametrize("scheme", ["decimal", "none"])
-@pytest.mark.parametrize("heading", [False, True])
-def test_a_chain_of_500_terms_does_not_exhaust_the_stack(scheme: str, heading: bool) -> None:
- n = 500
- defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
- title = "About {{term: t0}}" if heading else "A"
- body = f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
- started = time.perf_counter()
- result = render(FRONT + body, format="text", overrides={"numbering.scheme": scheme})
- assert time.perf_counter() - started < 2
- assert messages(result, "render-term-limit") == [LIMIT]
- used = next(line for line in result.output.splitlines() if line.startswith("Used: "))
- assert used.startswith("Used: T0 T1 T2 ")
- assert "{{" not in result.output
-
-
-def test_a_chain_inside_the_depth_limit_is_resolved_in_full() -> None:
- n = 20
- defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
- result = render(FRONT + "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n", format="text")
- assert f"Used: {' '.join(f'T{i}' for i in range(n))} End" in result.output
- assert messages(result, "render-term-limit") == []
-
-
-# -- 2: a cached text is reused only where it would read the same ------------------
-
-
-def _oracle(self: Resolver, cache: object, definition_id: str, work, *, key: str | None = None) -> str: # noqa: ANN001
- """The term text with the open terms alone deciding, nothing kept."""
- self._terms_open.append(definition_id)
- try:
- return work()
- finally:
- self._terms_open.pop()
-
-
-def _graph(rng: random.Random) -> str:
- ids = [f"t{i}" for i in range(rng.randint(2, 6))]
- defs = []
- for identifier in ids:
- parts = [identifier.upper()]
- for _ in range(rng.randint(0, 3)):
- target = rng.choice(ids)
- parts.append(f"{{{{term: {target}, label=L{target}}}}}" if rng.random() < 0.15
- else f"{{{{term: {target}}}}}")
- defs.append(f'"{" ".join(parts)}" {{{{def: {identifier}}}}}')
- rng.shuffle(defs)
- heading = rng.choice(["A", f"About {{{{term: {rng.choice(ids)}}}}}"])
- use = " ".join(f"{{{{term: {rng.choice(ids)}}}}}" for _ in range(rng.randint(1, 4)))
- return f"# {heading}\n\nUsed: {use}\n\n" + "\n\n".join(defs) + "\n"
-
-
-def test_cached_term_texts_equal_the_texts_worked_out_afresh(monkeypatch: pytest.MonkeyPatch) -> None:
- rng = random.Random(20261027)
- cached_original = Resolver._while_open
- for _ in range(150):
- source = FRONT + _graph(rng)
- for scheme in ("decimal", "none"):
- overrides = {"numbering.scheme": scheme}
- monkeypatch.setattr(Resolver, "_while_open", cached_original)
- cached = render(source, format="text", overrides=overrides)
- monkeypatch.setattr(Resolver, "_while_open", _oracle)
- fresh = render(source, format="text", overrides=overrides)
- assert cached.output == fresh.output, source
- assert [(d.rule, d.message) for d in cached.diagnostics] == \
- [(d.rule, d.message) for d in fresh.diagnostics], source
-
-
-def test_a_text_that_named_a_term_is_not_reused_where_that_term_is_open() -> None:
- # ca's text names cb. Worked out with nothing open it is "Alpha Beta ca"; with cb open it is
- # "Alpha cb", and that is what a reference inside cb must read.
- body = ('# A\n\nFirst {{term: ca}}. Then {{term: cb}}.\n\n"Alpha {{term: cb}}" {{def: ca}}\n\n'
- '"Beta {{term: ca}}" {{def: cb}}\n')
- out = render(FRONT + body, format="text").output
- assert "First Alpha Beta ca. Then Beta Alpha cb." in out
-
-
-# -- 4: control characters never reach stderr ----------------------------------------
-
-
-def _run(source: str, tmp_path: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]:
- document = tmp_path / "d.lgd"
- document.write_text(source, encoding="utf-8")
- env = {**os.environ, "PYTHONPATH": str(SRC)}
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", str(document), "-f", "text", *arguments],
- capture_output=True, cwd=tmp_path, env=env, check=False)
-
-
-def test_no_control_character_from_the_document_reaches_stderr(tmp_path: Path) -> None:
- body = ("# A {#a}\n\n{{include: a\x1b]0;pwn\x07.lgd}}\n\n{{ref: x\x1b[2Jy}}\n\n{{term: q\x1b[31m}}\n\n"
- "See {{ref: a}}.\n")
- result = _run(FRONT + body, tmp_path)
- errors = result.stderr.decode("utf-8")
- assert errors.count("\n") >= 3 # the three findings, one line each
- assert not any(ord(c) < 0x20 and c != "\n" or 0x7F <= ord(c) < 0xA0 for c in errors)
- assert "\\x1b" in errors # written out, not dropped
- assert "pwn" in errors
diff --git a/tests/test_wp13_budget.py b/tests/test_wp13_budget.py
deleted file mode 100644
index f9e9aed..0000000
--- a/tests/test_wp13_budget.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""Review fixes (WP13): the term budget charges only repeat work, and every
-command-line value echoed to stderr is escaped."""
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-import time
-from pathlib import Path
-
-import pytest
-
-from legaldown_render import render
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-FRONT = "---\ntitle: T\n---\n\n"
-
-
-def rules(result, rule: str) -> list[str]: # noqa: ANN001
- return [d.message for d in result.diagnostics if d.rule == rule]
-
-
-# -- 1: acyclic terms are free ------------------------------------------------------
-
-
-def _independent(n: int, *, heading: bool) -> str:
- parts = []
- for i in range(n):
- if heading:
- parts.append(f"## H{i} {{{{term: t{i}}}}}\n\n")
- parts.append(f'"Term {i}" {{{{def: t{i}}}}} used {{{{term: t{i}}}}}\n\n')
- return "# A\n\n" + "".join(parts)
-
-
-@pytest.mark.parametrize("scheme", ["decimal", "none"])
-@pytest.mark.parametrize("heading", [False, True])
-def test_3000_independent_terms_show_their_text_and_no_warning(scheme: str, heading: bool) -> None:
- result = render(FRONT + _independent(3000, heading=heading), format="text", overrides={"numbering.scheme": scheme})
- assert rules(result, "render-term-limit") == []
- lines = result.output.splitlines()
- assert "Term 2999 used Term 2999" in lines
- assert "Term 0 used Term 0" in lines
- assert not any(line.endswith(" used t2999") for line in lines)
- if heading and scheme == "none":
- assert any(line.endswith("H2999 Term 2999") for line in lines)
-
-
-# -- 2: a work budget on repeat computations ------------------------------------------
-
-
-def _ring(k: int, n: int) -> str:
- """k terms in a cycle, each holding n references to the next."""
- defs = [f'"T{i} {" ".join(f"{{{{term: t{(i + 1) % k}}}}}" for _ in range(n))}" {{{{def: t{i}}}}}' for i in range(k)]
- return "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
-
-
-def test_a_ring_of_terms_with_many_references_costs_a_small_multiple_of_the_document() -> None:
- document = FRONT + _ring(12, 2000)
- started = time.perf_counter()
- result = render(document, format="text", overrides={"numbering.scheme": "none"})
- cyclic = time.perf_counter() - started
- # The same document with the references pointing at a term that names nothing.
- flat = document.replace("{{term: t0}}", "{{term: leaf}}")
- for i in range(12):
- flat = flat.replace(f"{{{{term: t{i}}}}}", "{{term: leaf}}")
- flat += '\n"Leaf" {{def: leaf}}\n'
- started = time.perf_counter()
- render(flat, format="text", overrides={"numbering.scheme": "none"})
- acyclic = time.perf_counter() - started
- assert rules(result, "render-term-limit")
- assert cyclic < 3 * acyclic + 1
-
-
-@pytest.mark.parametrize("k", [7, 12, 40])
-def test_all_to_all_terms_stay_fast(k: int) -> None:
- defs = [f'"T{i} {" ".join(f"{{{{term: t{j}}}}}" for j in range(k))}" {{{{def: t{i}}}}}' for i in range(k)]
- started = time.perf_counter()
- result = render(FRONT + "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n", format="text")
- assert time.perf_counter() - started < 2
- assert "{{" not in result.output
-
-
-def test_a_chain_of_500_terms_stays_fast_and_reports_the_limit() -> None:
- defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(500)] + ['"End" {{def: t500}}']
- started = time.perf_counter()
- result = render(FRONT + "# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n", format="text")
- assert time.perf_counter() - started < 2
- assert len(rules(result, "render-term-limit")) == 1
-
-
-# -- 3: command-line values are escaped on stderr -------------------------------------
-
-
-def _cli(*arguments: str, cwd: Path) -> subprocess.CompletedProcess[bytes]:
- env = {**os.environ, "PYTHONPATH": str(SRC)}
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *arguments],
- capture_output=True, cwd=cwd, env=env, check=False)
-
-
-def _clean(errors: bytes) -> str:
- text = errors.decode("utf-8")
- assert not any((ord(c) < 0x20 and c != "\n") or 0x7F <= ord(c) < 0xA0 for c in text), text
- return text
-
-
-ESC = "x\x1b]0;pwn\x07y"
-
-
-def test_an_input_file_name_is_escaped(tmp_path: Path) -> None:
- result = _cli(ESC + ".lgd", cwd=tmp_path)
- assert result.returncode == 1
- assert "cannot read" in _clean(result.stderr) and "\\x1b" in _clean(result.stderr)
-
-
-def test_an_answers_path_is_escaped(tmp_path: Path) -> None:
- document = tmp_path / "d.lgd"
- document.write_text(FRONT + "# A\n", encoding="utf-8")
- result = _cli(str(document), "--answers", ESC + ".yaml", cwd=tmp_path)
- assert result.returncode == 1
- assert "\\x1b" in _clean(result.stderr)
-
-
-def test_an_output_path_is_escaped(tmp_path: Path) -> None:
- document = tmp_path / "d.lgd"
- document.write_text(FRONT + "# A\n", encoding="utf-8")
- result = _cli(str(document), "-o", "no-such-dir\x1b[2J/out.html", cwd=tmp_path)
- assert result.returncode == 1
- assert "\\x1b" in _clean(result.stderr)
- refused = _cli(str(document), "-o", "x\x1b[2J.pdf", cwd=tmp_path)
- assert refused.returncode == 2
- assert "\\x1b" in _clean(refused.stderr)
-
-
-def test_a_style_name_is_escaped(tmp_path: Path) -> None:
- document = tmp_path / "d.lgd"
- document.write_text(FRONT + "# A\n", encoding="utf-8")
- for arguments in (["-s", ESC], ["-s", ESC, "--print-style"]):
- result = _cli(str(document), *arguments, cwd=tmp_path)
- assert result.returncode == 2
- assert "\\x1b" in _clean(result.stderr)
-
-
-def test_a_locale_is_escaped(tmp_path: Path) -> None:
- document = tmp_path / "d.lgd"
- document.write_text(FRONT + "# A\n", encoding="utf-8")
- result = _cli(str(document), "--locale", "zz\x1b[31m", cwd=tmp_path)
- _clean(result.stderr)
-
-
-def test_a_set_key_is_escaped_in_argparse_errors(tmp_path: Path) -> None:
- document = tmp_path / "d.lgd"
- document.write_text(FRONT + "# A\n", encoding="utf-8")
- for setting in ("bad\x1b[31m", "no\x1b[31m.such=1"):
- result = _cli(str(document), "--set", setting, cwd=tmp_path)
- assert result.returncode == 2
- assert "\\x1b" in _clean(result.stderr)
-
-
-def test_an_unrecognized_argument_is_escaped(tmp_path: Path) -> None:
- result = _cli("--bogus\x1b[31m", cwd=tmp_path)
- assert result.returncode == 2
- assert "\\x1b" in _clean(result.stderr)
diff --git a/tests/test_wp1_values.py b/tests/test_wp1_values.py
deleted file mode 100644
index 8549a9c..0000000
--- a/tests/test_wp1_values.py
+++ /dev/null
@@ -1,206 +0,0 @@
-"""Value formatting (§10, §13.5): exact amounts, durations that keep their
-written digits, no raw CLDR unit id, and spaced alphabetic currency symbols."""
-from __future__ import annotations
-
-import pytest
-
-from legaldown_render import render
-from legaldown_render.resolve import DURATION_UNITS, Formatter, parse_locale
-from legaldown_render.resolve.values import DurationText
-from legaldown_render.style.model import Values
-
-
-def formatter(tag: str, **values: str) -> Formatter:
- locale = parse_locale(tag)
- assert locale is not None
- return Formatter(locale, Values(**values))
-
-
-# -- R2: no crash, no rounding on long amounts (§10.3) -----------------------
-
-@pytest.mark.parametrize("digits", [29, 40, 60])
-def test_long_integers_format_exactly(digits: int) -> None:
- value = "1234567890" * 6
- value = value[:digits]
- grouped = f"{int(value):,}"
- en = formatter("en-US")
- assert en.number(value) == grouped
- assert en.money(value, "USD") == f"${grouped}.00"
- assert formatter("en-US", money="code").money(value, "USD") == f"USD\xa0{grouped}.00"
- assert formatter("en-US", money="name").money(value, "USD") == f"{grouped}.00 US dollars"
-
-
-def test_long_fraction_keeps_every_digit() -> None:
- fraction = "1234567891" * 4
- en = formatter("en-US")
- assert en.number("0." + fraction) == "0." + fraction
- assert en.money("1." + fraction, "EUR") == "€1." + fraction
- assert en.number("1" * 29 + ".5") == f"{int('1' * 29):,}.5"
-
-
-def test_long_duration_keeps_every_digit() -> None:
- assert formatter("en-US").duration("1" * 40, "D") == f"{int('1' * 40):,} days"
- assert formatter("en-US").duration("0." + "1" * 40, "D") == "0." + "1" * 40 + " days"
-
-
-def test_long_amount_renders_end_to_end() -> None:
- source = "---\ntitle: T\n---\nPay {{money: " + "9" * 29 + "}} {{money: 12345678901234567890123456789.1234567890123456789}}.\n"
- output = render(source, format="text", locale="en-US").output
- assert f"{int('9' * 29):,}" in output
- assert "12,345,678,901,234,567,890,123,456,789.1234567890123456789" in output
-
-
-# -- R3: durations keep their digits; the plural follows the displayed value -
-
-@pytest.mark.parametrize(("tag", "value", "unit", "expected"), [
- ("en", "0.0001", "D", "0.0001 days"),
- ("en", "1.23456", "Y", "1.23456 years"),
- ("en", "1", "D", "1 day"),
- ("en", "1.0", "D", "1.0 days"),
- ("en", "12", "MO", "12 months"),
- ("cs", "1", "Y", "1 rok"),
- ("cs", "2", "Y", "2 roky"),
- ("cs", "5", "Y", "5 let"),
- ("cs", "2.5", "Y", "2,5 roku"),
- ("de", "1.50", "W", "1,50 Wochen"),
-])
-def test_duration_digits_and_plural(tag: str, value: str, unit: str, expected: str) -> None:
- assert formatter(tag).duration(value, unit) == expected
-
-
-# -- R1: never a raw CLDR unit id ---------------------------------------------
-
-@pytest.mark.parametrize("tag", ["pt-BR", "ja", "zh", "ko", "it", "tr", "nl", "cs", "de", "sk", "pl", "en"])
-@pytest.mark.parametrize("length", ["long", "short", "narrow"])
-@pytest.mark.parametrize("value", ["1", "2", "5", "1.5"])
-def test_no_cldr_unit_id_is_ever_printed(tag: str, length: str, value: str) -> None:
- f = formatter(tag, duration=length)
- for unit in DURATION_UNITS:
- text = f.duration(value, unit)
- assert "duration-" not in text
- assert value.replace(".", f.locale.number_symbols["latn"]["decimal"]) in text
-
-
-def test_pt_br_long_falls_back_to_the_short_pattern() -> None:
- pt = formatter("pt-BR")
- assert pt.duration("30", "D") == "30 dias"
- assert pt.duration("12", "MO") == "12 meses"
- assert pt.duration("1", "Y") == "1 ano"
-
-
-def test_fallback_goes_to_the_other_length_first() -> None:
- # cs has a long and a narrow pattern: narrow is asked, narrow is used.
- assert formatter("cs", duration="narrow").duration("30", "D") == "30 d."
- # zh has only "short": long and narrow fall back to it, still Chinese.
- assert formatter("zh", duration="long").duration("30", "D") == "30天"
- assert formatter("zh", duration="narrow").duration("30", "D") == "30天"
-
-
-def test_a_fallback_inside_the_locale_is_not_signalled() -> None:
- # pt-BR long has no pattern but short has: Portuguese, no English.
- assert formatter("pt-BR").format_duration("30", "D") == DurationText("30 dias", False)
- assert formatter("en").format_duration("1", "D") == DurationText("1 day", False)
-
-
-def test_english_is_the_last_resort_and_is_signalled(monkeypatch: pytest.MonkeyPatch) -> None:
- from legaldown_render.resolve import values
-
- real = values._unit_patterns
- # A Czech locale without any duration pattern (Babel's data has none such).
- monkeypatch.setattr(values, "_unit_patterns",
- lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
- f = formatter("cs")
- assert f.format_duration("5", "D") == DurationText("5 days", True)
- assert f.duration("1", "D") == "1 day"
- assert f.duration("1.5", "Y") == "1,5 years" # the number keeps the locale's separator
-
-
-def test_fallback_warning_is_emitted_once_by_the_resolver(monkeypatch: pytest.MonkeyPatch) -> None:
- from legaldown_render.resolve import values
-
- real = values._unit_patterns
- monkeypatch.setattr(values, "_unit_patterns",
- lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
- source = ("---\ntitle: T\nlanguage: cs\n---\n"
- "Lhůta {{duration: 5, unit=D}} a {{duration: 6, unit=D}} a {{duration: 2, unit=MO}}.\n")
- result = render(source, format="text")
- fallbacks = [d for d in result.diagnostics if d.rule == "render-locale-fallback"]
- assert [d.message for d in fallbacks] == [
- "No CLDR pattern for 'duration-day' in 'cs'; English used.",
- "No CLDR pattern for 'duration-month' in 'cs'; English used.",
- ]
- assert all(d.level == "warning" for d in fallbacks)
- assert "5 days" in result.output
-
-
-def test_normal_locales_emit_no_fallback_warning() -> None:
- for language in ("cs", "pt-BR", "ja", "en"):
- source = f"---\ntitle: T\nlanguage: {language}\n---\nX {{{{duration: 5, unit=D}}}}.\n"
- result = render(source, format="text")
- assert not [d for d in result.diagnostics if d.rule == "render-locale-fallback"], language
-
-
-# -- R4: an alphabetic symbol is spaced from the digits -----------------------
-
-@pytest.mark.parametrize(("tag", "currency", "expected"), [
- ("en", "CZK", "CZK\xa05.00"),
- ("en", "CHF", "CHF\xa05.00"),
- ("en", "USD", "$5.00"),
- ("en", "EUR", "€5.00"),
- ("pt-BR", "BRL", "R$\xa05,00"),
- ("en", "CAD", "CA$5.00"),
- ("cs", "CZK", "5,00\xa0Kč"),
-])
-def test_symbol_mode_spacing(tag: str, currency: str, expected: str) -> None:
- assert formatter(tag).money("5", currency) == expected
-
-
-def test_symbol_after_the_digits_is_spaced_too() -> None:
- # Whatever the locale puts after the amount, a letter never touches a digit.
- import re
-
- for tag in ("en", "de", "fr", "cs", "pl", "sv", "ja", "ru", "pt-PT", "es", "nl", "it"):
- for currency in ("CZK", "CHF", "USD", "EUR", "SEK", "PLN", "XAF"):
- text = formatter(tag).money("5", currency)
- assert not re.search(r"[^\W\d_]\d|\d[^\W\d_]", text), (tag, currency, text)
-
-
-def test_code_mode_still_spaced() -> None:
- assert formatter("en", money="code").money("5", "CZK") == "CZK\xa05.00"
- assert formatter("de", money="code").money("5", "CZK") == "5,00\xa0CZK"
-
-
-# -- money keeps every written fraction digit, padded to the minor units ------
-
-@pytest.mark.parametrize(("amount", "currency", "expected"), [
- ("1.1230", "EUR", "€1.1230"),
- ("1.500", "EUR", "€1.500"),
- ("1.50", "EUR", "€1.50"),
- ("5", "EUR", "€5.00"),
- ("5.5", "EUR", "€5.50"),
- ("5", "JPY", "¥5"),
- ("0.125", "JPY", "¥0.125"),
- ("5.0", "JPY", "¥5.0"),
- ("1234.5670", "USD", "$1,234.5670"),
-])
-def test_money_fraction_digits_symbol(amount: str, currency: str, expected: str) -> None:
- assert formatter("en-US").money(amount, currency) == expected
-
-
-def test_money_fraction_digits_code_and_name() -> None:
- code = formatter("en-US", money="code")
- name = formatter("en-US", money="name")
- assert code.money("1.500", "EUR") == "EUR\xa01.500"
- assert code.money("5", "EUR") == "EUR\xa05.00"
- assert code.money("0.125", "JPY") == "JPY\xa00.125"
- assert code.money("5", "JPY") == "JPY\xa05"
- assert name.money("1.500", "EUR") == "1.500 euros"
- assert name.money("5", "EUR") == "5.00 euros"
- assert name.money("0.125", "JPY") == "0.125 Japanese yen"
- assert name.money("5", "JPY") == "5 Japanese yen"
-
-
-def test_money_trailing_zeros_in_other_locales_and_long_amounts() -> None:
- assert formatter("cs-CZ").money("1.500", "CZK") == "1,500\xa0Kč"
- assert formatter("de", money="code").money("1.1230", "EUR") == "1,1230\xa0EUR"
- assert formatter("en-US").money("1" * 40 + ".1230", "EUR") == f"€{int('1' * 40):,}.1230"
diff --git a/tests/test_wp7_small.py b/tests/test_wp7_small.py
deleted file mode 100644
index 1df7b50..0000000
--- a/tests/test_wp7_small.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""Small fixes (WP7): a label filled with an empty ``{section}`` drops the field
-and its dot, so a preamble item reads "1", not ".1" (R5); the print stylesheet
-breaks before the attachments container, not inside it (W2)."""
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-from pathlib import Path
-
-from legaldown_render import render
-from legaldown_render.resolve.numbering import fill
-
-FRONT = "---\ntitle: T\n---\n"
-PREAMBLE_AND_SECTION = (
- "\nPreamble.\n\n- first pre\n- second pre\n\n# A {#a}\n\n- in a\n"
-)
-
-
-def text(body: str, **settings: object) -> str:
- return render(FRONT + body, format="text", **settings).output
-
-
-def html(body: str, **settings: object) -> str:
- return render(FRONT + body, format="html", **settings).output
-
-
-# -- R5: an empty section drops its dot (numbering.fill) -----------------------------------------
-
-def test_fill_drops_an_empty_section_with_one_following_dot() -> None:
- assert fill("{section}.{n}", n="1", section="") == "1"
- assert fill("{section}.{n}", n="1", section="2") == "2.1"
- # Only ONE dot goes with the field.
- assert fill("{section}..{n}", n="1", section="") == ".1"
-
-
-def test_fill_keeps_other_text_of_an_empty_section_as_written() -> None:
- # No dot right after the field: the field alone is removed, as before.
- assert fill("({section}){n}", n="a", section="") == "()a"
- assert fill("{section}", n="1", section="") == ""
-
-
-def test_fill_without_a_section_value_keeps_the_dot() -> None:
- # The rule applies only when the section value is given and empty.
- assert fill("{section}.{n}", n="1") == ".1"
-
-
-# -- R5: preamble list items agree with their designation ----------------------------------------
-
-def test_a_preamble_list_is_numbered_without_a_leading_dot_in_text() -> None:
- output = text(PREAMBLE_AND_SECTION, style="continental")
- assert "1 first pre" in output
- assert "2 second pre" in output
- assert not any(line.startswith(".") for line in output.splitlines())
-
-
-def test_a_preamble_list_is_numbered_without_a_leading_dot_in_html() -> None:
- output = html(PREAMBLE_AND_SECTION, style="continental")
- assert '1
first pre
' in output
- assert '2
second pre
' in output
- assert 'class="ld-label">.1<' not in output
-
-
-def test_a_list_in_a_section_keeps_its_section_prefix() -> None:
- output = text(PREAMBLE_AND_SECTION, style="continental")
- assert "1.1 in a" in output
- html_output = html(PREAMBLE_AND_SECTION, style="continental")
- assert '1.1
in a
' in html_output
-
-
-# -- W2: print breaks before the attachments container --------------------------------------------
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-ATTACHED = (
- "---\ntitle: T\nattachments:\n"
- " - id: one\n title: First attachment\n file: one.pdf\n"
- " - id: two\n title: Second attachment\n file: two.pdf\n"
- "---\n\n# A {#a}\n\nBody.\n"
-)
-
-
-def _cli(*args: str, stdin: str) -> subprocess.CompletedProcess[str]:
- env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONUTF8="1")
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
- capture_output=True, text=True, env=env, check=False)
-
-
-def test_a_standalone_page_break_render_has_the_attachment_break_rules() -> None:
- done = _cli("-", "--set", "attachments.separator=page-break", stdin=ATTACHED)
- assert done.returncode == 0, done.stderr
- assert "" in done.stdout
- assert 'class="ld-attachments ld-separator-page-break"' in done.stdout
- # The break goes before the container (heading included), then before each later attachment.
- assert ".ld-attachments.ld-separator-page-break { break-before: page; }" in done.stdout
- assert ".ld-separator-page-break .ld-attachment + .ld-attachment { break-before: page; }" in done.stdout
- assert ".ld-attachments-heading { break-after: avoid; }" in done.stdout
- # The old rule broke before every attachment, the first one included, inside the container.
- assert ".ld-separator-page-break .ld-attachment { break-before: page; }" not in done.stdout
-
-
-def test_without_a_page_break_separator_the_container_is_not_broken() -> None:
- done = _cli("-", "--set", "attachments.separator=none", stdin=ATTACHED)
- assert done.returncode == 0, done.stderr
- # The rules are scoped by the separator class, which no element carries here.
- assert 'class="ld-attachments ld-separator-none"' in done.stdout
- assert 'ld-separator-page-break"' not in done.stdout
diff --git a/tests/test_wp9_review_fixes.py b/tests/test_wp9_review_fixes.py
deleted file mode 100644
index 1a01418..0000000
--- a/tests/test_wp9_review_fixes.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""Review fixes after wave 1 (WP9): a ``{{ref:}}`` or placeholder inside a
-defined term as ``{{term:}}`` shows it, a fraction beyond Python's int/str digit
-limit renders, ``-o`` rejects only the extensions of formats the renderer does
-not write, in-process ``main()`` leaves the streams alone, and output has LF
-line endings."""
-from __future__ import annotations
-
-import io
-import os
-import subprocess
-import sys
-from pathlib import Path
-
-import pytest
-from conftest import DOCUMENTS
-
-from legaldown_render import render
-from legaldown_render.cli import main
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-FEATURES = str(DOCUMENTS / "features.lgd")
-FRONT = "---\ntitle: T\n---\n\n"
-
-
-def text(body: str, **settings: object) -> str:
- return render(FRONT + body, format="text", **settings).output
-
-
-# -- 1: {{term:}} shows what the definition shows -------------------------------
-
-
-def test_a_term_reference_keeps_a_ref_inside_the_term() -> None:
- output = text('# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n\n{{term: a}}\n')
- assert output.count("Fee per 1") == 2
-
-
-def test_a_term_reference_before_the_heading_keeps_the_ref() -> None:
- output = text('{{term: a}}\n\n# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n')
- assert output.count("Fee per 1") == 2
-
-
-def test_a_term_reference_before_its_definition_and_its_target() -> None:
- output = text('# First\n\nSee {{term: a}}.\n\n# H {#h}\n\n"Fee per {{ref: h}}" {{def: a}}\n')
- assert "See Fee per 2." in output
-
-
-def test_a_term_reference_keeps_a_ref_to_a_subsection() -> None:
- body = '# A\n\n"Fee per {{ref: s}}" {{def: a}}\n\n{{term: a}}\n\n## Sub {#s}\n\nx\n'
- output = text(body)
- assert output.count("Fee per 1.1") == 2
-
-
-def test_a_term_reference_in_a_textual_heading_and_in_the_body() -> None:
- # The none scheme reads a heading as text before every target is known; the
- # term text must not be fixed from that early pass.
- body = '# Fee {{term: a}}\n\n"Fee per {{ref: h}}" {{def: a}}\n\n# H {#h}\n\n{{term: a}}\n'
- output = render(FRONT + body, format="text", overrides={"numbering.scheme": "none"}).output
- assert output.rstrip().endswith("Fee per H")
- assert "[BROKEN REF" not in output
-
-
-def test_a_term_reference_shows_the_placeholder_as_the_definition_does() -> None:
- body = '# A\n\n"Fee {{placeholder: p}}" {{def: a}} {{term: a}} {{placeholder: p, type=money}}\n'
- result = render(FRONT + body, format="text")
- line = next(row for row in result.output.splitlines() if "INVALID PLACEHOLDER" in row)
- assert line.count("Fee [INVALID PLACEHOLDER: p]") == 2
- assert "[_____]" not in result.output
-
-
-def test_a_definition_that_defines_a_term_twice_uses_the_first() -> None:
- body = '# H {#h}\n\n"First {{ref: h}}" {{def: t}}\n\n"Second" {{def: t}}\n\n{{term: t}}\n'
- assert text(body).rstrip().endswith("First 1")
-
-
-def test_a_term_with_a_ref_adds_no_duplicate_diagnostics() -> None:
- # The term holds an image with a remote path: one report however often it is resolved.
- body = '# H {#h}\n\n"Fee  {{ref: h}}" {{def: a}}\n\n{{term: a}} {{term: a}}\n'
- diagnostics = render(FRONT + body, format="text").diagnostics
- keys = [(d.rule, d.message) for d in diagnostics]
- assert len(keys) == len(set(keys))
- assert sum(d.rule == "path-not-relative" for d in diagnostics) == 1
-
-
-def test_a_term_that_holds_a_definition_does_not_take_its_anchor() -> None:
- body = '# A\n\nSee {{term: outer}}.\n\n"Outer "Inner" {{def: inner}}" {{def: outer}}\n\n{{term: inner}}\n'
- html = render(FRONT + body, format="html").output
- assert html.count('id="def:inner"') == 1
- assert html.count('id="def:outer"') == 1
-
-
-@pytest.mark.parametrize("output_format", ["text", "html"])
-def test_cycles_through_refs_still_terminate(output_format: str) -> None:
- body = ('# H {#h}\n\n"A {{term: b}} {{ref: h}}" {{def: a}} x.\n\n"B {{term: a}}" {{def: b}} y.\n\n'
- '"C {{term: c}}" {{def: c}}\n\nSee {{term: a}}, {{term: b}}, {{term: c}}.\n')
- out = render(FRONT + body, format=output_format).output
- assert "{{" not in out
- # Which term shows what inside a cycle follows the order they are first needed in.
- assert "A b 1" in out and "C c" in out
-
-
-# -- 2: a fraction beyond Python's int/str digit limit ---------------------------
-
-LONG = "2" * 4400
-
-
-def test_a_duration_with_a_4400_digit_fraction_renders() -> None:
- output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=D}}}}", format="text").output
- assert f"1.{LONG} days" in output
-
-
-def test_a_money_amount_in_name_mode_with_a_4400_digit_fraction_renders() -> None:
- output = render(FRONT + f"# A\n\n{{{{money: 1.{LONG}, currency=EUR}}}}", format="text",
- overrides={"values.money": "name"}).output
- assert f"1.{LONG} euros" in output
-
-
-def test_a_4400_digit_fraction_uses_the_many_category_in_czech() -> None:
- # Wave 3 (WP11): the category comes from the first 30 fraction digits, so it is the
- # "many" of "2,5 roku", not "other" (" let").
- output = render(FRONT + f"# A\n\n{{{{duration: 1.{LONG}, unit=Y}}}}", format="text", locale="cs-CZ").output
- assert output.rstrip().endswith(" roku")
-
-
-# -- 4: only the program reconfigures the streams -------------------------------
-
-
-def _wrapper(encoding: str, **options: object) -> io.TextIOWrapper:
- return io.TextIOWrapper(io.BytesIO(), encoding=encoding, **options) # type: ignore[arg-type]
-
-
-def test_in_process_main_leaves_the_streams_alone(monkeypatch: pytest.MonkeyPatch) -> None:
- out, err = _wrapper("cp1252", newline="\r\n"), _wrapper("cp1252")
- monkeypatch.setattr(sys, "stdout", out)
- monkeypatch.setattr(sys, "stderr", err)
- assert main([FEATURES, "-f", "text", "-q"]) == 0
- assert main(["--list-styles"]) == 0
- assert (sys.stdout.encoding, sys.stdout.errors) == ("cp1252", "strict")
- assert (sys.stderr.encoding, sys.stderr.errors) == ("cp1252", "strict")
- out.flush()
- # Not even the newline translation of the caller's stream changed.
- assert b"\r\n" in out.buffer.getvalue() # type: ignore[attr-defined]
-
-
-def test_main_as_the_program_reconfigures_the_streams(monkeypatch: pytest.MonkeyPatch) -> None:
- out, err = _wrapper("cp1252", newline="\r\n"), _wrapper("cp1252")
- monkeypatch.setattr(sys, "stdout", out)
- monkeypatch.setattr(sys, "stderr", err)
- monkeypatch.setattr(sys, "argv", ["legaldown-render", "--list-styles"])
- assert main() == 0
- out.flush()
- assert (out.encoding, err.errors) == ("utf-8", "backslashreplace")
- data = out.buffer.getvalue() # type: ignore[attr-defined]
- assert data and b"\r" not in data and data.endswith(b"\n")
-
-
-def test_the_console_script_calls_main_without_argv() -> None:
- # The script entry point calls main() with no argument, so it is "the program".
- pyproject = (SRC.parent / "pyproject.toml").read_text(encoding="utf-8")
- assert 'legaldown-render = "legaldown_render.cli:main"' in pyproject
-
-
-# -- 5: LF line endings everywhere ------------------------------------------------
-
-
-def _run(args: list[str]) -> subprocess.CompletedProcess[bytes]:
- env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], capture_output=True, env=env,
- check=False)
-
-
-@pytest.mark.parametrize("fmt", ["html", "text"])
-def test_standard_output_has_lf_line_endings(fmt: str) -> None:
- done = _run([FEATURES, "-f", fmt, "-q"])
- assert done.returncode == 0, done.stderr
- assert b"\n" in done.stdout and b"\r" not in done.stdout
-
-
-@pytest.mark.parametrize("fmt", ["html", "text"])
-def test_an_output_file_has_lf_line_endings(tmp_path: Path, fmt: str) -> None:
- target = tmp_path / "out.bin"
- assert main([FEATURES, "-q", "-f", fmt, "-o", str(target)]) == 0
- data = target.read_bytes()
- assert b"\n" in data and b"\r" not in data
- assert data == render(Path(FEATURES).read_text(encoding="utf-8"), format=fmt).output.encode("utf-8")
-
-
-def test_an_output_file_is_written_without_newline_translation(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- # Windows' text mode would turn "\n" into CRLF unless the file is opened
- # with newline="\n": that is what the CLI asks for.
- calls: list[dict[str, object]] = []
- real = Path.write_text
-
- def spy(self: Path, data: str, *args: object, **kwargs: object) -> int:
- calls.append(kwargs)
- return real(self, data, *args, **kwargs) # type: ignore[arg-type]
-
- monkeypatch.setattr(Path, "write_text", spy)
- assert main([FEATURES, "-q", "-o", str(tmp_path / "out.txt")]) == 0
- assert calls == [{"encoding": "utf-8", "newline": "\n"}]
From e47f98f11493c0779b02512dff6d89bcf01b5b80 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 21:34:03 +0000
Subject: [PATCH 031/118] Bound the term-work tests by counted work, not
wall-clock time
The tests of the term cache, work budget, depth limit and length cap
asserted that a render finished within two seconds (or within three times
the time of a flat document), which can fail on a slow runner. They now
count the directives the resolver resolves, in the body and in the
text-only pass for headings, and bound that count: linear in the number of
terms for chains, quadratic for chains read round in a cycle, a fixed
ceiling for terms that all name each other, and a small multiple of the flat
document for a ring of terms with many references. Doubling a chain at most
doubles the work. A render that exceeds its bound is stopped, so a lost
protection fails at once instead of hanging. The same adversarial documents
are used, with the same output-size bounds.
---
tests/test_terms_budget.py | 128 ++++++++++++++++++++++++++-----------
1 file changed, 89 insertions(+), 39 deletions(-)
diff --git a/tests/test_terms_budget.py b/tests/test_terms_budget.py
index 13d34e9..2dd8c80 100644
--- a/tests/test_terms_budget.py
+++ b/tests/test_terms_budget.py
@@ -8,11 +8,18 @@
depth of nested terms, a reference shows its definition id and the Warning
``render-term-limit`` says so. A term text is also capped in length
(``render-term-too-long``).
+
+The tests of cost do not time the render, which depends on the machine. They
+count the directives the resolver resolves, in the text-only pass for headings
+as in the body (the ``resolver_work`` fixture), and bound that count, so a lost
+cache, budget or depth limit fails the same way everywhere. Each bound sits
+25 to 60 % above the work measured when the test was written, far below what
+the document costs without the protection.
"""
from __future__ import annotations
import random
-import time
+from collections.abc import Callable
import pytest
from conftest import FRONT, text
@@ -20,9 +27,40 @@
from legaldown_render import RenderResult, render
from legaldown_render.resolve.resolver import Resolver
+WorkCounter = Callable[..., tuple[RenderResult, int]]
+
LIMIT = ("Defined terms refer to each other too deeply or too often to resolve fully; "
"some {{term:}} references show their definition id.")
+#: The most directives a document of k terms that all name each other may cost:
+#: the work budget is 200 000 inline nodes, about half of them directives.
+TANGLE_DIRECTIVES = 130_000
+
+
+@pytest.fixture
+def resolver_work(monkeypatch: pytest.MonkeyPatch) -> WorkCounter:
+ """``work(body, limit, **settings)`` renders *body* as plain text and
+ returns the result with the number of directives the resolver resolved.
+ A render that resolves more than *limit* is stopped and fails, so that a
+ lost protection fails at once instead of running for hours."""
+ state = {"directives": 0, "limit": 0}
+ real = Resolver._directive
+
+ def counting(self: Resolver, directive: object): # type: ignore[no-untyped-def]
+ state["directives"] += 1
+ if state["directives"] > state["limit"]:
+ pytest.fail(f"more than {state['limit']} directives resolved: the work is not bounded", pytrace=False)
+ return real(self, directive) # type: ignore[arg-type]
+
+ monkeypatch.setattr(Resolver, "_directive", counting)
+
+ def work(body: str, limit: int, **settings: object) -> tuple[RenderResult, int]:
+ state.update(directives=0, limit=limit)
+ result = render(FRONT + body, format="text", **settings) # type: ignore[arg-type]
+ return result, state["directives"]
+
+ return work
+
def messages(result: RenderResult, rule: str) -> list[str]:
return [d.message for d in result.diagnostics if d.rule == rule]
@@ -36,6 +74,13 @@ def _chain(n: int, *, cycle: bool, heading: bool = False) -> str:
return f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
+def _chain_directives(n: int, *, cycle: bool) -> int:
+ """A bound on the directives a chain of *n* terms takes: about 6 n; read
+ round in a cycle each term has a text for each set of open terms, and the
+ work is about 2.5 n squared."""
+ return 4 * n * n + 30 if cycle else 8 * n + 30
+
+
def _line(n: int, *, heading: bool) -> str:
"""Terms t0..tn, each reading the next one once."""
defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
@@ -69,15 +114,24 @@ def _independent(n: int, *, heading: bool) -> str:
@pytest.mark.parametrize("heading", [False, True])
@pytest.mark.parametrize("scheme", ["decimal", "none"])
@pytest.mark.parametrize("cycle", [False, True])
-def test_a_branching_chain_of_terms_is_worked_out_once_per_term(cycle: bool, scheme: str, heading: bool) -> None:
+def test_a_branching_chain_of_terms_is_worked_out_once_per_term(
+ resolver_work: WorkCounter, cycle: bool, scheme: str, heading: bool) -> None:
# Each term reads the next twice: worked out afresh at each use, 20 terms is 2**20 resolutions.
# The text of t0 is itself 2**n words long (4**n with a cycle, which reads the chain round
# twice), so the chains cannot be longer. Under the none scheme a heading is read as text
# before the targets are known (§13.3).
- started = time.perf_counter()
- out = text(_chain(9 if cycle else 20, cycle=cycle, heading=heading), overrides={"numbering.scheme": scheme})
- assert time.perf_counter() - started < 2
- assert "{{" not in out
+ n = 9 if cycle else 20
+ result, _ = resolver_work(_chain(n, cycle=cycle, heading=heading), _chain_directives(n, cycle=cycle),
+ overrides={"numbering.scheme": scheme})
+ assert "{{" not in result.output
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+def test_doubling_a_chain_of_terms_doubles_the_work(resolver_work: WorkCounter, scheme: str) -> None:
+ directives = {n: resolver_work(_chain(n, cycle=False, heading=True), _chain_directives(n, cycle=False),
+ overrides={"numbering.scheme": scheme})[1] for n in (10, 20, 40)}
+ assert directives[20] <= 2.2 * directives[10]
+ assert directives[40] <= 2.2 * directives[20]
def test_a_chain_without_a_cycle_reads_as_each_term_does() -> None:
@@ -90,24 +144,25 @@ def test_a_chain_without_a_cycle_reads_as_each_term_does() -> None:
@pytest.mark.parametrize(("n", "cycle"), [(40, False), (14, True)])
@pytest.mark.parametrize("scheme", ["decimal", "none"])
-def test_a_branching_chain_of_terms_is_capped_and_reported(n: int, cycle: bool, scheme: str) -> None:
- started = time.perf_counter()
- result = render(FRONT + _chain(n, cycle=cycle, heading=True), format="text",
- overrides={"numbering.scheme": scheme})
- assert time.perf_counter() - started < 2
+def test_a_branching_chain_of_terms_is_capped_and_reported(
+ resolver_work: WorkCounter, n: int, cycle: bool, scheme: str) -> None:
+ result, _ = resolver_work(_chain(n, cycle=cycle, heading=True), _chain_directives(n, cycle=cycle),
+ overrides={"numbering.scheme": scheme})
assert len(result.output) < 100_000
- messages = [d.message for d in result.diagnostics if d.rule == "render-term-too-long"]
- assert messages
- assert len(messages) == len(set(messages)) # one per id
+ too_long = messages(result, "render-term-too-long")
+ assert too_long
+ assert len(too_long) == len(set(too_long)) # one per id
assert all("longer than 1000 characters" in message and "shows the definition id" in message
- for message in messages)
+ for message in too_long)
assert "{{" not in result.output
@pytest.mark.parametrize("scheme", ["decimal", "none"])
@pytest.mark.parametrize("heading", [False, True])
-def test_3000_independent_terms_show_their_text_and_no_warning(scheme: str, heading: bool) -> None:
- result = render(FRONT + _independent(3000, heading=heading), format="text", overrides={"numbering.scheme": scheme})
+def test_3000_independent_terms_show_their_text_and_no_warning(
+ resolver_work: WorkCounter, scheme: str, heading: bool) -> None:
+ result, directives = resolver_work(_independent(3000, heading=heading), 4 * 3000 + 100,
+ overrides={"numbering.scheme": scheme})
assert messages(result, "render-term-limit") == []
lines = result.output.splitlines()
assert "Term 2999 used Term 2999" in lines
@@ -115,6 +170,9 @@ def test_3000_independent_terms_show_their_text_and_no_warning(scheme: str, head
assert not any(line.endswith(" used t2999") for line in lines)
if heading and scheme == "none":
assert any(line.endswith("H2999 Term 2999") for line in lines)
+ _, half = resolver_work(_independent(1500, heading=heading), 4 * 1500 + 100,
+ overrides={"numbering.scheme": scheme})
+ assert directives <= 2.2 * half
# -- A cached text is reused only where it would read the same -----------------
@@ -163,10 +221,8 @@ def test_cached_term_texts_equal_the_texts_worked_out_afresh(monkeypatch: pytest
@pytest.mark.parametrize("k", [3, 7, 9, 12, 20, 40])
-def test_k_terms_that_each_name_all_k_render_quickly(k: int) -> None:
- started = time.perf_counter()
- result = render(FRONT + _tangle(k), format="text")
- assert time.perf_counter() - started < 2
+def test_k_terms_that_each_name_all_k_stay_within_the_work_budget(resolver_work: WorkCounter, k: int) -> None:
+ result, _ = resolver_work(_tangle(k), 150 if k == 3 else TANGLE_DIRECTIVES)
assert "{{" not in result.output
if k >= 9:
assert messages(result, "render-term-limit") == [LIMIT]
@@ -176,21 +232,17 @@ def test_a_small_cycle_is_resolved_in_full_without_the_warning() -> None:
assert messages(render(FRONT + _tangle(3), format="text"), "render-term-limit") == []
-def test_a_ring_of_terms_with_many_references_costs_a_small_multiple_of_the_document() -> None:
- document = FRONT + _ring(12, 2000)
- started = time.perf_counter()
- result = render(document, format="text", overrides={"numbering.scheme": "none"})
- cyclic = time.perf_counter() - started
+def test_a_ring_of_terms_with_many_references_costs_a_small_multiple_of_the_document(
+ resolver_work: WorkCounter) -> None:
+ document = _ring(12, 2000)
# The same document with the references pointing at a term that names nothing.
- flat = document.replace("{{term: t0}}", "{{term: leaf}}")
+ flat = document
for i in range(12):
flat = flat.replace(f"{{{{term: t{i}}}}}", "{{term: leaf}}")
flat += '\n"Leaf" {{def: leaf}}\n'
- started = time.perf_counter()
- render(flat, format="text", overrides={"numbering.scheme": "none"})
- acyclic = time.perf_counter() - started
+ _, acyclic = resolver_work(flat, 100_000, overrides={"numbering.scheme": "none"})
+ result, _ = resolver_work(document, 10 * acyclic, overrides={"numbering.scheme": "none"})
assert messages(result, "render-term-limit")
- assert cyclic < 3 * acyclic + 1
# -- A long chain of terms: the depth limit ------------------------------------
@@ -198,18 +250,16 @@ def test_a_ring_of_terms_with_many_references_costs_a_small_multiple_of_the_docu
@pytest.mark.parametrize("scheme", ["decimal", "none"])
@pytest.mark.parametrize("heading", [False, True])
-def test_a_chain_of_500_terms_does_not_exhaust_the_stack(scheme: str, heading: bool) -> None:
- n = 500
- defs = [f'"T{i} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)] + [f'"End" {{{{def: t{n}}}}}']
- title = "About {{term: t0}}" if heading else "A"
- body = f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n" + "\n\n".join(defs) + "\n"
- started = time.perf_counter()
- result = render(FRONT + body, format="text", overrides={"numbering.scheme": scheme})
- assert time.perf_counter() - started < 2
+def test_a_chain_of_500_terms_does_not_exhaust_the_stack(
+ resolver_work: WorkCounter, scheme: str, heading: bool) -> None:
+ result, directives = resolver_work(_line(500, heading=heading), 3 * 500 + 50,
+ overrides={"numbering.scheme": scheme})
assert messages(result, "render-term-limit") == [LIMIT]
used = next(line for line in result.output.splitlines() if line.startswith("Used: "))
assert used.startswith("Used: T0 T1 T2 ")
assert "{{" not in result.output
+ _, half = resolver_work(_line(250, heading=heading), 3 * 250 + 50, overrides={"numbering.scheme": scheme})
+ assert directives <= 2.2 * half
def test_a_chain_inside_the_depth_limit_is_resolved_in_full() -> None:
From a98cf245a77abd83ade9973e60b989ae2f45f931 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 21:34:19 +0000
Subject: [PATCH 032/118] List every test module in the architecture notes
The Testing table names each module under tests/ with what it checks,
replaces the entry for the removed links module, and notes the shared
helpers in conftest.py.
---
docs/architecture.md | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index f73c800..2c0016a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -300,11 +300,26 @@ line public (`Document.line_of`, U2).
## Testing
+Each module is named for its topic. Shared helpers (the minimal document header, the `text` and
+`html` renderers, and running the command as a program) are in `tests/conftest.py`.
+
| Suite | What it checks |
|---|---|
| `tests/test_golden.py` | The test documents under each built-in style, compared byte for byte with `tests/golden/`. Regenerate with `pytest --update-golden` and review the diff |
-| `tests/test_render.py` | Numbering schemes, designations, fallbacks, every failure marker, template view, labels by language, HTML safety, unique anchors, determinism, strict mode, locales |
-| `tests/test_wp3_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme allow-list, empty URLs and empty link text |
-| `tests/test_style.py`, `tests/test_values.py` | Style layering and validation; counters and locale formatting |
-| `tests/test_cli.py` | The command |
+| `tests/test_render.py` | Numbering schemes, designations, fallbacks, every failure marker, template view, labels by language, HTML safety, unique anchors, determinism, strict mode, locales; the renderer's reading of the validator's structure (lists, quotes, comments, markers) |
+| `tests/test_values.py` | Money, dates and durations in a locale: exact digits, plural of the value shown, currency display, CLDR fallback to English |
+| `tests/test_numbering.py` | Counter formats, label templates and the preamble's `{section}` |
+| `tests/test_build.py` | Building the tree: comment-only items and quotes, emphasis next to a directive, spacing where an inline comment was dropped |
+| `tests/test_terms.py` | What `{{term:}}` shows, references inside terms, terms that name themselves or each other, the cap on a term text |
+| `tests/test_terms_budget.py` | Reuse of worked-out term texts, the work budget and depth limit for terms that name each other. Bounds the work by counting resolved directives, never by timing |
+| `tests/test_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme allow-list, empty URLs and empty link text, and how a refusal is reported |
+| `tests/test_text_writer.py` | The plain-text layout: attachment separators, tables, quotes, list items and indent, blanked labels |
+| `tests/test_html.py` | The HTML writer's print stylesheet for attachments |
+| `tests/test_labels.py` | The built-in labels of parties, per language |
+| `tests/test_style.py` | Built-in styles, `extends`, dotted overrides, problem reporting, dumping a style, typing of command-line values |
+| `tests/test_style_validation.py` | Template field validation, `extends` cycles, self-contained dumps, `version` and `extends` not overridable, YAML errors in `--set`, the empty-blank fallback |
+| `tests/test_cli.py` | The command's options, exit codes and diagnostics |
+| `tests/test_cli_io.py` | Encodings of the streams, input that is not UTF-8, unwritable output, LF line endings, the extension rule of `-o` |
+| `tests/test_errors.py` | The exception hierarchy and pickling of errors |
+| `tests/test_escaping.py` | Control characters from a document or the command line never reach a diagnostic or standard error unescaped |
| `tests/conformance/` | Every example and fixture in the specification repository renders, every internal link has a target, and each invalid fixture renders its rule's failure marker. Needs `$LEGALDOWN_SPEC_DIR` or a sibling `../LegalDown` checkout |
From 3f08186595c90092b6fc295d76a892a7944cde81 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 21:37:10 +0000
Subject: [PATCH 033/118] Test that first computations of acyclic terms are
free of the work budget
800 independent terms, each holding 120 emphasis runs, together hold more
inline nodes than the budget allows for repeat computations. Each term is
worked out once, so no render-term-limit warning is reported and every
{{term:}} shows its real text. The test counts no time.
---
tests/test_terms_budget.py | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/tests/test_terms_budget.py b/tests/test_terms_budget.py
index 2dd8c80..1428763 100644
--- a/tests/test_terms_budget.py
+++ b/tests/test_terms_budget.py
@@ -25,7 +25,7 @@
from conftest import FRONT, text
from legaldown_render import RenderResult, render
-from legaldown_render.resolve.resolver import Resolver
+from legaldown_render.resolve.resolver import _MAX_TERM_WORK, Resolver
WorkCounter = Callable[..., tuple[RenderResult, int]]
@@ -174,6 +174,22 @@ def test_3000_independent_terms_show_their_text_and_no_warning(
overrides={"numbering.scheme": scheme})
assert directives <= 2.2 * half
+def test_first_computations_of_acyclic_terms_are_free_of_the_budget() -> None:
+ # Together the term texts hold more inline nodes than the work budget allows for repeat
+ # computations, but each term is worked out only once, so none of that work is charged.
+ terms, runs = 800, 120
+ nodes_per_text = 1 + 2 * runs + (runs - 1) # the text, each emphasis with its words, the spaces
+ assert terms * nodes_per_text > _MAX_TERM_WORK
+ defs = [f'"T{i} {" ".join(f"*e{j}*" for j in range(runs))}" {{{{def: t{i}}}}}\n\nUsed {{{{term: t{i}}}}}\n\n'
+ for i in range(terms)]
+ result = render(FRONT + "# A\n\n" + "".join(defs), format="text")
+ assert messages(result, "render-term-limit") == []
+ lines = set(result.output.splitlines())
+ assert {f"Used T{i} " + " ".join(f"e{j}" for j in range(runs)) for i in (0, terms // 2, terms - 1)} <= lines
+ assert sum(line.startswith("Used T") for line in result.output.splitlines()) == terms
+ assert not any(line.startswith("Used t") for line in lines)
+
+
# -- A cached text is reused only where it would read the same -----------------
def _oracle(self: Resolver, cache: object, definition_id: str, work, *, key: str | None = None) -> str: # noqa: ANN001
From 49c3fc4e6ec3bcd5e0501b116bc6f10c284fbfb2 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:49:21 +0000
Subject: [PATCH 034/118] Check files out with LF line endings
The goldens and test documents are compared byte for byte, and the CLI writes LF on every platform, so a Windows checkout must not convert them to CRLF.
---
.gitattributes | 1 +
1 file changed, 1 insertion(+)
create mode 100644 .gitattributes
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
From f71ba397f8e57dd4c66df6c618b7814521b84037 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:49:21 +0000
Subject: [PATCH 035/118] Make the tests independent of the platform's default
encoding and line endings
Every read_text/write_text in the style and CLI tests names utf-8 (Windows would use cp1252). run_cli_text now encodes stdin and decodes the streams itself instead of using subprocess text mode, which uses the locale's encoding and writes CRLF to the child's stdin on Windows. Goldens are written with newline="\n" and compared as bytes, so a CRLF checkout fails instead of passing through universal newlines. The -o tests that create files named with a trailing dot or space are skipped on Windows, where Win32 strips them and the test would not exercise the name. No test expectation changed.
---
tests/conftest.py | 21 ++++++++++++++-------
tests/test_cli.py | 2 +-
tests/test_cli_io.py | 13 ++++++++++---
tests/test_golden.py | 6 ++++--
tests/test_style.py | 14 +++++++-------
tests/test_style_validation.py | 30 +++++++++++++++---------------
6 files changed, 51 insertions(+), 35 deletions(-)
diff --git a/tests/conftest.py b/tests/conftest.py
index a77cca9..c01c435 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -30,11 +30,12 @@ def html(body: str, **settings: object) -> str:
def run_cli(*args: str, stdin: bytes | str | None = None, cwd: Path | None = None,
- narrow: bool = False) -> subprocess.CompletedProcess[bytes]:
+ narrow: bool = False, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[bytes]:
"""Run ``legaldown-render`` as a program on this checkout's sources.
- *narrow* gives it a Windows-like console, where neither stream is UTF-8."""
- env = dict(os.environ, PYTHONPATH=str(SRC))
+ *narrow* gives it a Windows-like console, where neither stream is UTF-8; *env* is
+ the environment it starts from (default: this process's)."""
+ env = dict(os.environ if env is None else env, PYTHONPATH=str(SRC))
if narrow:
env.update(PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
data = stdin.encode("utf-8") if isinstance(stdin, str) else stdin
@@ -43,10 +44,16 @@ def run_cli(*args: str, stdin: bytes | str | None = None, cwd: Path | None = Non
def run_cli_text(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
- """Like :func:`run_cli`, with UTF-8 text for the streams."""
- env = dict(os.environ, PYTHONPATH=str(SRC), PYTHONUTF8="1")
- return subprocess.run([sys.executable, "-m", "legaldown_render.cli", *args], input=stdin,
- capture_output=True, text=True, env=env, check=False)
+ """Like :func:`run_cli`, with UTF-8 text for the streams.
+
+ The streams are encoded and decoded here rather than by ``subprocess``'s text
+ mode, which would use the locale's encoding (cp1252 on Windows) and turn the
+ ``\\n`` of *stdin* into CRLF there."""
+ env = dict(os.environ, PYTHONUTF8="1")
+ done = run_cli(*args, stdin=stdin, env=env)
+ return subprocess.CompletedProcess(
+ done.args, done.returncode,
+ done.stdout.decode("utf-8").replace("\r\n", "\n"), done.stderr.decode("utf-8").replace("\r\n", "\n"))
def pytest_addoption(parser: pytest.Parser) -> None:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index c3565d7..36b2ac3 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -17,7 +17,7 @@
def test_render_to_file_infers_the_format(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
out = tmp_path / "out.txt"
assert main([FEATURES, "-o", str(out)]) == 0
- assert out.read_text().startswith("Services Agreement")
+ assert out.read_text(encoding="utf-8").startswith("Services Agreement")
err = capsys.readouterr().err
assert "[ref-broken]" in err
# A validator finding names its line, as the validator's CLI prints it.
diff --git a/tests/test_cli_io.py b/tests/test_cli_io.py
index 58da13c..a01d484 100644
--- a/tests/test_cli_io.py
+++ b/tests/test_cli_io.py
@@ -235,7 +235,13 @@ def test_unknown_output_extension_without_format_is_a_usage_error(
assert list(tmp_path.iterdir()) == []
-@pytest.mark.parametrize("name", ["x.docx", "x.pdf.", ".md", "x.docx "])
+# Win32 drops trailing dots and spaces from a file name, so "x.pdf." names the file x.pdf there
+# and the test would not exercise the name it says it does.
+_TRAILING = pytest.mark.skipif(sys.platform == "win32", reason="Windows strips trailing dots and spaces from names")
+
+
+@pytest.mark.parametrize("name", ["x.docx", pytest.param("x.pdf.", marks=_TRAILING), ".md",
+ pytest.param("x.docx ", marks=_TRAILING)])
def test_explicit_format_is_honoured_whatever_the_extension(tmp_path: Path, name: str) -> None:
target = tmp_path / name
assert main([FEATURES, "-q", "-f", "text", "-o", str(target)]) == 0
@@ -249,8 +255,9 @@ def test_output_without_an_extension_keeps_the_html_default(tmp_path: Path) -> N
assert target.read_text(encoding="utf-8").startswith("")
-@pytest.mark.parametrize("name", ["contract", "contract.v2", "report.2025-10-27", ".out", "out.", "notes.final",
- "x.json", "x.."])
+@pytest.mark.parametrize("name", ["contract", "contract.v2", "report.2025-10-27", ".out",
+ pytest.param("out.", marks=_TRAILING), "notes.final", "x.json",
+ pytest.param("x..", marks=_TRAILING)])
def test_any_other_name_keeps_the_html_default(tmp_path: Path, name: str) -> None:
target = tmp_path / name
assert main([FEATURES, "-q", "-o", str(target)]) == 0
diff --git a/tests/test_golden.py b/tests/test_golden.py
index 69a72f6..18fa394 100644
--- a/tests/test_golden.py
+++ b/tests/test_golden.py
@@ -25,7 +25,9 @@ def test_golden(document: str, style: str, output_format: str, update_golden: bo
extension = "txt" if output_format == "text" else output_format
golden = GOLDEN / f"{document}.{style}.{extension}"
if update_golden or not golden.exists():
- golden.write_text(output, encoding="utf-8")
+ # LF on every platform: the CLI writes LF, and .gitattributes checks the goldens out as LF.
+ golden.write_text(output, encoding="utf-8", newline="\n")
if not update_golden:
pytest.fail(f"{golden.name} did not exist and was written; review it and run again")
- assert output == golden.read_text(encoding="utf-8")
+ # Bytes, not read_text: universal newlines would hide a golden checked out with CRLF.
+ assert output == golden.read_bytes().decode("utf-8")
diff --git a/tests/test_style.py b/tests/test_style.py
index b3f0a09..7874bc2 100644
--- a/tests/test_style.py
+++ b/tests/test_style.py
@@ -42,7 +42,7 @@ def test_every_problem_is_reported_with_its_path() -> None:
def test_file_extends_a_builtin(tmp_path: Path) -> None:
path = tmp_path / "house.yaml"
- path.write_text("version: 1\nextends: continental\nlocale: cs-CZ\ndefinitions: {style: small-caps}\n")
+ path.write_text("version: 1\nextends: continental\nlocale: cs-CZ\ndefinitions: {style: small-caps}\n", encoding="utf-8")
style = load_style(path)
assert style.name == "house"
assert style.paragraphs.numbered is True # from continental
@@ -51,11 +51,11 @@ def test_file_extends_a_builtin(tmp_path: Path) -> None:
def test_relative_extends_and_cycles(tmp_path: Path) -> None:
- (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n")
- (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\n")
+ (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n", encoding="utf-8")
+ (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\n", encoding="utf-8")
assert load_style(tmp_path / "child.yaml").numbering.scheme == "none"
- (tmp_path / "a.yaml").write_text("version: 1\nextends: b.yaml\n")
- (tmp_path / "b.yaml").write_text("version: 1\nextends: a.yaml\n")
+ (tmp_path / "a.yaml").write_text("version: 1\nextends: b.yaml\n", encoding="utf-8")
+ (tmp_path / "b.yaml").write_text("version: 1\nextends: a.yaml\n", encoding="utf-8")
with pytest.raises(StyleError, match="circular"):
load_style(tmp_path / "a.yaml")
@@ -64,7 +64,7 @@ def test_unknown_builtin_and_bad_version(tmp_path: Path) -> None:
with pytest.raises(StyleError, match="no built-in style named 'fancy'"):
load_style("fancy")
path = tmp_path / "future.yaml"
- path.write_text("version: 2\n")
+ path.write_text("version: 2\n", encoding="utf-8")
with pytest.raises(StyleError, match="style format 1"):
load_style(path)
@@ -72,7 +72,7 @@ def test_unknown_builtin_and_bad_version(tmp_path: Path) -> None:
def test_dump_round_trips(tmp_path: Path) -> None:
style = load_style("continental", overrides={"locale": "de-DE"})
path = tmp_path / "dumped.yaml"
- path.write_text(dump_style(style))
+ path.write_text(dump_style(style), encoding="utf-8")
# The dump is flattened: the same style, without the record of what it extended.
assert load_style(path) == replace(style, extends=None)
diff --git a/tests/test_style_validation.py b/tests/test_style_validation.py
index 73069da..405806d 100644
--- a/tests/test_style_validation.py
+++ b/tests/test_style_validation.py
@@ -53,21 +53,21 @@ def test_a_blank_with_a_visible_character_is_kept() -> None:
def test_a_cycle_through_a_parent_directory_is_found(tmp_path: Path) -> None:
(tmp_path / "dir").mkdir()
- (tmp_path / "dir" / "a.yaml").write_text("version: 1\nextends: ../dir/a.yaml\n")
+ (tmp_path / "dir" / "a.yaml").write_text("version: 1\nextends: ../dir/a.yaml\n", encoding="utf-8")
with pytest.raises(StyleError, match="circular"):
load_style(tmp_path / "dir" / "a.yaml")
def test_a_cycle_through_two_spellings_of_one_file_is_found(tmp_path: Path) -> None:
(tmp_path / "x").mkdir()
- (tmp_path / "x" / "a.yaml").write_text("version: 1\nextends: ../x/b.yaml\n")
- (tmp_path / "x" / "b.yaml").write_text("version: 1\nextends: ./a.yaml\n")
+ (tmp_path / "x" / "a.yaml").write_text("version: 1\nextends: ../x/b.yaml\n", encoding="utf-8")
+ (tmp_path / "x" / "b.yaml").write_text("version: 1\nextends: ./a.yaml\n", encoding="utf-8")
with pytest.raises(StyleError, match="circular"):
load_style(tmp_path / "x" / "../x/a.yaml")
def test_a_cycle_through_a_symlink_is_found(tmp_path: Path) -> None:
- (tmp_path / "a.yaml").write_text("version: 1\nextends: link.yaml\n")
+ (tmp_path / "a.yaml").write_text("version: 1\nextends: link.yaml\n", encoding="utf-8")
try:
(tmp_path / "link.yaml").symlink_to(tmp_path / "a.yaml")
except OSError:
@@ -77,24 +77,24 @@ def test_a_cycle_through_a_symlink_is_found(tmp_path: Path) -> None:
def test_the_same_base_used_twice_is_not_a_cycle(tmp_path: Path) -> None:
- (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n")
- (tmp_path / "mid.yaml").write_text("version: 1\nextends: base.yaml\n")
- (tmp_path / "top.yaml").write_text("version: 1\nextends: mid.yaml\n")
+ (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n", encoding="utf-8")
+ (tmp_path / "mid.yaml").write_text("version: 1\nextends: base.yaml\n", encoding="utf-8")
+ (tmp_path / "top.yaml").write_text("version: 1\nextends: mid.yaml\n", encoding="utf-8")
assert load_style(tmp_path / "top.yaml").numbering.scheme == "none"
# -- A dump is self-contained ----------------------------------------------------
def test_a_dump_does_not_extend_anything(tmp_path: Path) -> None:
- (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n")
- (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\nlocale: cs-CZ\n")
+ (tmp_path / "base.yaml").write_text("version: 1\nnumbering: {scheme: none}\n", encoding="utf-8")
+ (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\nlocale: cs-CZ\n", encoding="utf-8")
style = load_style(tmp_path / "child.yaml")
text = dump_style(style)
assert "extends" not in text
elsewhere = tmp_path / "elsewhere" / "deep"
elsewhere.mkdir(parents=True)
dumped = elsewhere / "dumped.yaml"
- dumped.write_text(text)
+ dumped.write_text(text, encoding="utf-8")
reloaded = load_style(dumped)
assert reloaded.numbering.scheme == "none"
assert reloaded.locale == "cs-CZ"
@@ -105,21 +105,21 @@ def test_a_dump_does_not_extend_anything(tmp_path: Path) -> None:
def test_a_dumped_builtin_reloads_equal(name: str, tmp_path: Path) -> None:
style = load_style(name)
path = tmp_path / "dump.yaml"
- path.write_text(dump_style(style))
- assert "extends" not in path.read_text()
+ path.write_text(dump_style(style), encoding="utf-8")
+ assert "extends" not in path.read_text(encoding="utf-8")
# The dump is flattened: it is the same style, minus the record of what it extended.
assert load_style(path) == replace(style, extends=None)
def test_print_style_of_a_relative_extends_reloads_from_anywhere(tmp_path: Path) -> None:
- (tmp_path / "base.yaml").write_text("version: 1\nplaceholders: {blank: '[__]'}\n")
- (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\n")
+ (tmp_path / "base.yaml").write_text("version: 1\nplaceholders: {blank: '[__]'}\n", encoding="utf-8")
+ (tmp_path / "child.yaml").write_text("version: 1\nextends: base.yaml\n", encoding="utf-8")
done = run_cli_text("--print-style", "--style", str(tmp_path / "child.yaml"))
assert done.returncode == 0, done.stderr
assert "extends" not in done.stdout
elsewhere = tmp_path / "other"
elsewhere.mkdir()
- (elsewhere / "dump.yaml").write_text(done.stdout)
+ (elsewhere / "dump.yaml").write_text(done.stdout, encoding="utf-8")
assert load_style(elsewhere / "dump.yaml").placeholders.blank == "[__]"
From c3f4ff56bc124376ed94d2924c8e6cd9bced6ab5 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:49:21 +0000
Subject: [PATCH 036/118] Test that plain-text table cells with pipes and
backslashes read back unchanged
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cells such as 'a | b', 'a \| b', 'ends\' written by the text writer are re-parsed with the validator's reader and compared with the cell's text (§13.8).
---
tests/test_text_writer.py | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/tests/test_text_writer.py b/tests/test_text_writer.py
index 9ffef26..22e6292 100644
--- a/tests/test_text_writer.py
+++ b/tests/test_text_writer.py
@@ -3,6 +3,7 @@
or a quote), list indent, and the labels a style may blank."""
from __future__ import annotations
+import legaldown
import pytest
from legaldown_render import render
@@ -74,6 +75,17 @@ def test_a_pipe_in_a_cell_is_escaped() -> None:
assert "| a | b |\n|---|---|\n| x \\| y | z |" in output
+@pytest.mark.parametrize("cell", ["a | b", "a \\| b", "a \\\\| b", "a\\ b", "ends\\", "x\\|y"])
+def test_a_cell_with_pipes_and_backslashes_is_read_back_as_it_was(cell: str) -> None:
+ # The text writer's table is read by the validator's reader (the tool a
+ # LegalDown reader is expected to use), so a cell's plain text, pipes and
+ # backslashes included, must come back from it unchanged.
+ markup = cell.replace("\\", "\\\\").replace("|", "\\|") # the cell's text as GFM table markup
+ output = text(f"# A\n\n| H | K |\n|---|---|\n| {markup} | z |\n")
+ tables = [block for _, _, block in legaldown.parse(output).iter_blocks() if block.kind == "table"]
+ assert [table.rows for table in tables] == [[[cell, "z"]]]
+
+
def test_alignment_survives_in_the_separator_row() -> None:
output = text("# A\n\n| a | b | c | d |\n|:--|:-:|--:|---|\n| 1 | 2 | 3 | 4 |\n")
assert "| a | b | c | d |\n|:---|:---:|---:|---|\n| 1 | 2 | 3 | 4 |" in output
From f234f1f4f7a41cbf1a51eb2066e607dfb59e7527 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:49:21 +0000
Subject: [PATCH 037/118] Run the tests on Windows and gate publishing on CI
ci.yml gains a windows-latest (Python 3.12) test job, is callable (workflow_call), and names the specification release once (LEGALDOWN_SPEC_REF). Its concurrency group includes github.workflow, which is the caller's name in a called workflow, so a publish run and push CI on one ref stay apart; only pull-request runs cancel. publish.yml calls ci.yml and build needs it, so nothing is built or uploaded unless CI passes on the commit. PUBLISHING.md describes this instead of recommending it.
---
.github/PUBLISHING.md | 23 +++++++++++++++--------
.github/workflows/ci.yml | 27 ++++++++++++++++++++++-----
.github/workflows/publish.yml | 14 ++++++++++++--
3 files changed, 49 insertions(+), 15 deletions(-)
diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md
index e3fafa2..110cb07 100644
--- a/.github/PUBLISHING.md
+++ b/.github/PUBLISHING.md
@@ -54,9 +54,10 @@ version number can never be reused once published.
3. 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.
+The workflow first runs the CI workflow on the tagged commit. Only if that passes does it build the
+sdist and wheel, run `twine check --strict`, **verify the tag matches `__version__`** (a mismatch
+fails the run rather than publishing the wrong version), smoke-test the built wheel by installing it
+and invoking the CLI, and upload to PyPI.
## Dry run
@@ -89,11 +90,17 @@ markdown-it-py) come from PyPI.
overrides the installed validator with `main`, so it tests main regardless of the range). The
tests fail on any `legaldown.LegaldownDeprecationWarning`, so a release never calls what the
validator's next minor version removes.
-- **Recommendation: gate the upload on the tests.** The `publish` workflow builds and uploads
- without running the test suite, and `ci.yml` runs separately, so a release can reach PyPI from
- a commit whose tests fail. Add the lint, test, and conformance jobs (or one called workflow)
- as `needs:` of the `build` job in `publish.yml`, or require a passing CI run on the tagged
- commit before the Release is published. This repository's workflows are not changed here.
+- **The upload is gated on the tests.** `publish.yml` first calls [`ci.yml`](workflows/ci.yml)
+ (`on: workflow_call`) as the job `ci`, and `build` needs it, so the lint, test (Linux on Python
+ 3.11-3.13 and Windows on 3.12) and conformance jobs must pass on the commit being published
+ before anything is built, and `testpypi` and `pypi` need `build`. The `validator-main` job is
+ informational (`continue-on-error`) and never blocks a release. The caller grants the called
+ workflow `contents: read`; a called workflow cannot hold more permissions than its caller gives.
+ In the called workflow `github.workflow` is the caller's name, so its concurrency group
+ (`ci-${{ github.workflow }}-${{ github.ref }}`) is distinct from the push and pull-request runs
+ and from `publish.yml`'s own group.
+- The conformance job checks out the specification release named once, in `LEGALDOWN_SPEC_REF` at the
+ top of `ci.yml`. Change it there when the renderer moves to a later specification.
- The license is declared once, as the SPDX expression `license = "MIT"` in `pyproject.toml`
(PEP 639), so the deprecated `License :: OSI Approved :: MIT License` classifier is not listed.
- The package ships a `py.typed` marker, so type checkers use its annotations directly.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6567e7a..0aff2ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,13 +4,25 @@ on:
push:
branches: [main]
pull_request:
+ # Called by publish.yml, so a release is only built once these jobs pass.
+ workflow_call:
permissions:
contents: read
+env:
+ # The LegalDown specification release the conformance job renders; the one
+ # place to change it when the renderer moves to a later specification.
+ LEGALDOWN_SPEC_REF: v0.2
+
+# In a called workflow, github.workflow is the *caller's* name ("Publish"), not
+# "CI". That keeps this group apart from the push/pull-request CI runs on the
+# same ref, so a dispatched or release publish neither cancels nor is cancelled
+# by them, and it never collides with publish.yml's own group (two workflows in
+# one group would wait for each other forever).
concurrency:
- group: ci-${{ github.ref }}
- cancel-in-progress: true
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
@@ -26,12 +38,17 @@ jobs:
- run: ruff check .
test:
- name: Tests (Python ${{ matrix.python-version }})
- runs-on: ubuntu-latest
+ name: Tests (${{ matrix.os }}, Python ${{ matrix.python-version }})
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
+ os: [ubuntu-latest]
python-version: ["3.11", "3.12", "3.13"]
+ include:
+ # Windows: path handling, console encodings and line endings differ.
+ - os: windows-latest
+ python-version: "3.12"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -50,7 +67,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: ForLegalAI/LegalDown
- ref: v0.2
+ ref: ${{ env.LEGALDOWN_SPEC_REF }}
path: .legaldown-spec
- uses: actions/setup-python@v5
with:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 42f9a3d..9bee4c5 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -5,8 +5,11 @@ name: Publish
# 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
+# - publishing a GitHub Release → CI, build, verify, upload to PyPI
+# - manual dispatch → CI, build, verify, upload to TestPyPI
+#
+# The CI workflow (ci.yml) runs first on the commit being published, and nothing
+# is built or uploaded unless it passes.
on:
release:
@@ -28,8 +31,15 @@ concurrency:
cancel-in-progress: false
jobs:
+ ci:
+ name: CI
+ uses: ./.github/workflows/ci.yml
+ permissions:
+ contents: read
+
build:
name: Build and verify distribution
+ needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
From 308553622498d1c1d4c1e5f0d91d846d2e708996 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:54:16 +0000
Subject: [PATCH 038/118] Read the command's standard error in the console
encoding in the escaping tests
Only standard output is forced to UTF-8 with LF line endings. Standard error
keeps the console's encoding and the platform's line ending, so on Windows the
tests decoded cp1252 bytes as UTF-8 and counted the CR of each CRLF as a
control character. They now decode with the preferred encoding, as the program
does, and treat CRLF as one line end; a lone CR still fails.
---
tests/test_escaping.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/tests/test_escaping.py b/tests/test_escaping.py
index 696bdc0..151ce9b 100644
--- a/tests/test_escaping.py
+++ b/tests/test_escaping.py
@@ -5,6 +5,7 @@
line of its own."""
from __future__ import annotations
+import locale
from pathlib import Path
from conftest import FRONT, run_cli
@@ -44,8 +45,15 @@ def test_a_control_character_in_a_link_is_escaped_in_the_message() -> None:
# -- Standard error of the command -----------------------------------------------
+def _stderr(raw: bytes) -> str:
+ """Standard error as text. Only standard output is forced to UTF-8 with LF; standard
+ error keeps the console's encoding (backslash-escaping what it cannot show) and the
+ platform's line ending, so it is read the same way here and a CRLF is one line end."""
+ return raw.decode(locale.getpreferredencoding(False), errors="replace").replace("\r\n", "\n")
+
+
def _clean(errors: bytes) -> str:
- text = errors.decode("utf-8")
+ text = _stderr(errors)
assert not any((ord(c) < 0x20 and c != "\n") or 0x7F <= ord(c) < 0xA0 for c in text), text
return text
@@ -54,7 +62,7 @@ def test_the_cli_writes_no_control_character_from_a_document_to_standard_error(t
source = tmp_path / "evil.lgd"
source.write_text(FRONT + f"# A\n\n [y]()\n", encoding="utf-8")
done = run_cli(str(source), "-f", "text")
- err = done.stderr.decode("utf-8")
+ err = _stderr(done.stderr)
assert "path-not-relative" in err and "render-link-refused" in err
assert "\x1b" not in err and "\x07" not in err and "\u202e" not in err
assert "\r" not in err
From 1efc63a4b8f00c6547a5354ef57ffa3aca71fbcf Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:58:39 +0000
Subject: [PATCH 039/118] Record the source line of every block, list item and
heading in the render tree
Section, Paragraph, ListItem, Table, Quote, DraftingNote and CodeBlock carry
the first line of what they were built from (Document.line_of; an item's marker
line, by the pre-order number the builder already tracks). A block inside a
quote has the quote's line, and one inside an item the item's, since the
validator addresses nothing deeper. The field is not part of equality, so trees
compare as before. tree.iter_located_* walk the inlines with their line.
---
src/legaldown_render/build.py | 56 +++++++++++++++++++++++++----------
src/legaldown_render/tree.py | 47 +++++++++++++++++++++++++----
2 files changed, 82 insertions(+), 21 deletions(-)
diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py
index 2b9ecfc..5bc701a 100644
--- a/src/legaldown_render/build.py
+++ b/src/legaldown_render/build.py
@@ -197,6 +197,14 @@ def __init__(self, document: Document, result: ValidationResult) -> None:
# Each text is lexed once in a render.
self.lex = cache(lex)
self.depth = 0 # quotes and list items the builder is inside
+ # Where the builder is, for the lines of the nodes it builds (§16.9):
+ # the top-level block (section index or None for the preamble, block
+ # index) when it is at one that a list's items can be addressed in
+ # (``Document.line_of``), and the line of the innermost unit it is in.
+ # A block in a quote, or past the first in an item, is not addressable;
+ # it takes the line of the quote or the item.
+ self.at: tuple[int | None, int] | None = None
+ self.line: int | None = None
nonce = secrets.token_hex(8)
# A sentinel with its wrappers; only a complete one matches, so a
# wrapper character in the source is never taken.
@@ -368,6 +376,9 @@ def blocks(self, blocks: list[ModelBlock], section: int | None, *, markers: bool
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 []
+ if markers:
+ self.at = (section, index)
+ self.line = self.document.line_of(section, index)
built = self.block(block, placed, top_level=top_level)
if built is not None:
out.append(built)
@@ -380,14 +391,14 @@ def block(self, block: ModelBlock, placed: list[PlacedMarker], *, top_level: boo
inlines = self.text(_paragraph_source(block))
if not inlines and not identifier:
return None # only a comment
- return Paragraph(inlines, anchor_id=identifier, condition=condition, top_level=top_level)
+ return Paragraph(inlines, anchor_id=identifier, condition=condition, top_level=top_level, line=self.line)
case "unordered_list" | "ordered_list":
return self.list(block, placed)
case "quote":
return self.quote(block)
case "code":
code = code_content(block)
- return CodeBlock(code.text, code.info)
+ return CodeBlock(code.text, code.info, line=self.line)
case "table":
width = len(block.headers)
# Every row as wide as the header: short rows padded, extra
@@ -397,6 +408,7 @@ def block(self, block: ModelBlock, placed: list[PlacedMarker], *, top_level: boo
header=tuple(self.text(cell) for cell in block.headers),
rows=tuple(tuple(self.text(cell) for cell in row) for row in rows),
align=tuple(block.align),
+ line=self.line,
)
case "rule":
return Rule()
@@ -404,7 +416,7 @@ def block(self, block: ModelBlock, placed: list[PlacedMarker], *, top_level: boo
# A heading in a list item or a quote: not a section (§4.1),
# so it has no number and no anchor. It shows as a bold line.
inlines = self.text(block.text)
- return Paragraph((Strong(inlines),)) if inlines else None
+ return Paragraph((Strong(inlines),), line=self.line) if inlines else None
raise InternalError(f"unexpected block kind '{block.kind}' in the validator's model")
def list(self, block: ModelBlock, placed: list[PlacedMarker]) -> List | None:
@@ -430,28 +442,36 @@ def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numb
try:
items: list[ListItem] = []
for item in list_items(block):
- marker = markers.get(next(numbers))
+ number = next(numbers)
+ marker = markers.get(number)
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"):
- nested = self._list(child, markers, numbers)
- if nested is not None:
- blocks.append(nested)
- 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)
+ outer = self.line
+ # The item's marker line; in a quote an item is not addressable.
+ item_line = self.document.line_of(*self.at, number) if self.at else outer
+ self.line = item_line
+ try:
+ for child in children:
+ if child.kind in ("ordered_list", "unordered_list"):
+ nested = self._list(child, markers, numbers)
+ if nested is not None:
+ blocks.append(nested)
+ 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)
+ finally:
+ self.line = outer
# Only a comment: the item is removed. Defensive: an item a
# marker belongs to (an anchor or a condition) never vanishes.
if item.blocks and all(child.kind == "html" for child in item.blocks) \
and not identifier and not condition:
continue
- items.append(ListItem(blocks=tuple(blocks), anchor_id=identifier, condition=condition))
+ items.append(ListItem(blocks=tuple(blocks), anchor_id=identifier, condition=condition, line=item_line))
finally:
self.depth -= 1
return List(ordered=block.kind == "ordered_list", items=tuple(items)) if items else None
@@ -477,14 +497,17 @@ def quote(self, block: ModelBlock) -> Block | None:
# The quotes and list items the quote is in, before entering it.
read = drafting_note_blocks if drafting else quote_blocks
children = read(block, depth=self.depth)
+ line, at = self.line, self.at
self.depth += 1
+ self.at = None # what is in a quote has the quote's line
try:
blocks = self.blocks(children, None, markers=False)
finally:
self.depth -= 1
+ self.at = at
if drafting:
- return DraftingNote(blocks)
- return Quote(blocks) if blocks else None
+ return DraftingNote(blocks, line=line)
+ return Quote(blocks, line=line) if blocks else None
# -- document -------------------------------------------------------------
@@ -497,6 +520,7 @@ def tree(self) -> RenderTree:
identifier=self.result.index.sections[index].identifier,
condition=section.condition,
blocks=self.blocks(section.blocks, index),
+ line=document.line_of(index),
)
for index, section in enumerate(document.sections)
)
diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py
index 7e88575..a98051f 100644
--- a/src/legaldown_render/tree.py
+++ b/src/legaldown_render/tree.py
@@ -186,6 +186,12 @@ class Paragraph:
label: str | None = None
anchor: str | None = None
condition_label: str | None = None
+ #: Its first line in the document's file, counted from 1 (§16.9), which the
+ #: renderer's diagnostics name; None when the document has no source to
+ #: name a line of. Not part of equality: the same content is equal
+ #: wherever it stood. A block inside a list item or a quote has the item's
+ #: or the quote's line: the validator addresses nothing deeper.
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
@@ -196,6 +202,8 @@ class ListItem:
label: str | None = None
anchor: str | None = None
condition_label: str | None = None
+ #: The item's marker line (see Paragraph.line).
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
@@ -209,6 +217,8 @@ class List:
@dataclass(frozen=True, slots=True)
class Quote:
blocks: tuple[Block, ...]
+ #: Its first line (see Paragraph.line); the blocks inside it have it too.
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
@@ -217,12 +227,16 @@ class DraftingNote:
blocks: tuple[Block, ...]
label: str | None = None
+ #: Its first line (see Paragraph.line); the blocks inside it have it too.
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
class CodeBlock:
text: str
info: str = ""
+ #: Its first line (see Paragraph.line).
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
@@ -231,6 +245,8 @@ class Table:
rows: tuple[tuple[tuple[Inline, ...], ...], ...]
#: Per column: "left", "center", "right", or "".
align: tuple[str, ...]
+ #: Its first line (see Paragraph.line); every cell has it too.
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
@@ -263,6 +279,8 @@ class Section:
designation: str | None = None
anchor: str | None = None
condition_label: str | None = None
+ #: The heading's first line (see Paragraph.line).
+ line: int | None = field(default=None, compare=False)
@dataclass(frozen=True, slots=True)
@@ -355,22 +373,41 @@ def iter_inlines(inlines: tuple[Inline, ...]) -> Iterator[Inline]:
def iter_block_inlines(block: Block) -> Iterator[Inline]:
"""Every inline inside *block*, depth first."""
+ for inline, _ in iter_located_block_inlines(block):
+ yield inline
+
+
+def iter_located_block_inlines(block: Block, line: int | None = None) -> Iterator[tuple[Inline, int | None]]:
+ """Every inline inside *block*, depth first, with the line of the unit
+ holding it: that of the nearest enclosing node that has one, else *line*."""
+ line = getattr(block, "line", None) or line
match block:
case Paragraph(inlines=inlines):
- yield from iter_inlines(inlines)
+ yield from ((inline, line) for inline in iter_inlines(inlines))
case List(items=items):
for item in items:
for child in item.blocks:
- yield from iter_block_inlines(child)
+ yield from iter_located_block_inlines(child, item.line or line)
case Quote(blocks=blocks) | DraftingNote(blocks=blocks):
for child in blocks:
- yield from iter_block_inlines(child)
+ yield from iter_located_block_inlines(child, line)
case Table(header=header, rows=rows):
for cell in header:
- yield from iter_inlines(cell)
+ yield from ((inline, line) for inline in iter_inlines(cell))
for row in rows:
for cell in row:
- yield from iter_inlines(cell)
+ yield from ((inline, line) for inline in iter_inlines(cell))
+
+
+def iter_located_body_inlines(tree: RenderTree) -> Iterator[tuple[Inline, int | None]]:
+ """Every inline in the document's body (the preamble, then each section's
+ heading and blocks), with its line (:func:`iter_located_block_inlines`)."""
+ for block in tree.preamble:
+ yield from iter_located_block_inlines(block)
+ for section in tree.sections:
+ yield from ((inline, section.line) for inline in iter_inlines(section.title))
+ for block in section.blocks:
+ yield from iter_located_block_inlines(block, section.line)
def iter_tree_inlines(tree: RenderTree) -> Iterator[Inline]:
From e4bbc666bcbd8f2399d53e3d690c7ddba98cb5a4 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:58:39 +0000
Subject: [PATCH 040/118] Name the file and line of every diagnostic the
renderer reports
render-ref-ambiguous, ref-not-enumerated, render-not-processed,
render-link-refused, path-not-relative, render-term-limit, render-term-too-long
and render-locale-fallback now carry the line of the block, list item or
heading being resolved (Resolver.line), or of the frontmatter key they are
about (diagnostics.key_line, one helper over source_map.key that the file
references will reuse; None without a source map). A term's text is worked out
at its definition whichever reference asks first. Granularity is the block's:
a {{ref:}} or {{term:}} paragraph is lifted by the validator, so no directive
level without validator#93.
Diagnostic.file is RenderOptions.filename (new, default empty), set by
render_file and the command from the file's name, as the validator's
Document.filename is; the validator's diagnostics get it too.
A repeated finding (same rule, level and message) is reported once at its first
line, in the resolver and in api.render. The renderer's path-not-relative is
left out when the validator reported the same path at the same line or at none
(it used to match the quoted path alone), in diagnostics.covered_by.
tests/test_links.py: the mocked validator report now sits at the image's own
line (6, it was 5), since a report at another line is another finding.
Docs: CONFORMANCE.md line numbers and path-not-relative limitations,
architecture.md, README.md.
---
CONFORMANCE.md | 32 +-
README.md | 6 +-
docs/architecture.md | 30 +-
src/legaldown_render/api.py | 35 +-
src/legaldown_render/cli.py | 3 +-
src/legaldown_render/diagnostics.py | 52 +++
src/legaldown_render/resolve/resolver.py | 285 +++++++++-----
tests/test_diagnostic_lines.py | 458 +++++++++++++++++++++++
tests/test_links.py | 5 +-
9 files changed, 784 insertions(+), 122 deletions(-)
create mode 100644 src/legaldown_render/diagnostics.py
create mode 100644 tests/test_diagnostic_lines.py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index cc22c1b..54105ff 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -52,13 +52,33 @@ It is checked against the specification's own examples and fixtures corpus (`tes
- **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`,
- `render-link-refused`) do not
- yet ([validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)).
+- **Line numbers (§16.9).** Every diagnostic names its line, the validator's and the renderer's
+ own (`render-ref-ambiguous`, `ref-not-enumerated`, `render-not-processed`,
+ `render-link-refused`, `path-not-relative`, `render-term-limit`, `render-term-too-long`,
+ `render-locale-fallback`), and the CLI prints it as `file:line:`. The renderer's line is the
+ **first line of the block it is in**, as `Document.line_of` gives it, whatever line the
+ directive itself is on: a paragraph's, a table's header row, a quote's first `>`, a code
+ block's opening fence, a heading's (a section title); for a list item, the line of **its marker**
+ (nested items too), also for the blocks the item holds; a block inside a quote has the quote's
+ line, since the validator addresses nothing deeper. A `{{ref:}}` or `{{term:}}` paragraph is
+ lifted by the validator and written back, so the renderer cannot place the directive in it; the
+ validator's own diagnostics for a directive can therefore name a later line of a paragraph than
+ the renderer's do, until [validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93).
+ Findings about frontmatter are at the line of their key: the `language` key for
+ `render-locale-fallback` of the document's language, and the `title`, `subtitle`, `version`,
+ `effective_date`, `sides[].label` and `sides[].parties[]` key (a party's details, name and
+ signatories are all at the party's key) or `attachments[].title` for a directive in that value
+ (also where the body shows it, as `{{attach:}}` or `{{party:}}` do). A finding in a term's text
+ is at the term's definition, whichever reference asks for it first; `render-term-limit` is at the
+ definition being worked out when the limit was reached. The same finding (rule, level and
+ message) is reported once, at its first line: the same refused link, or the same non-relative
+ image path, on two lines is one diagnostic, at the first of them. A document with no source to
+ name a line of (built from a dict) reports no line. With `answers`, the lines are those of the
+ assembled document (§15.8), not of the template file.
- **`path-not-relative` for images.** The renderer reports the Core rule `path-not-relative`
- (§2.3, §16.2) for an image path, because the validator (0.4) does not check image paths; it
- leaves its own report out when the validator names the same path. The Error has no line number.
+ (§2.3, §16.2) for an image path, because the validator (0.4) does not check image paths. It
+ leaves its own report out when the validator names the same path at the same line (or no line),
+ and keeps it for a path the validator did not name, or the same path at another line.
The containment rule (`path-outside-root`) is not checked: no document root is configured.
- **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
diff --git a/README.md b/README.md
index d4e9262..b7154d6 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,9 @@ legaldown-render --print-style --style continental # every setting,
`--locale` sets how dates, money, and durations are formatted. The words the renderer generates
(such as "Represented by" or "Signatures") follow the document's `language` (§13.7), whatever the locale.
-Diagnostics go to stderr, one per line with its rule id. The exit code is 0 when the document
+Diagnostics go to stderr, one per line, as `file:line: level: [rule] message` (the line is that of
+the block or heading they are in, or of the frontmatter key they are about; see CONFORMANCE.md).
+The exit code is 0 when the document
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 the document or answers file cannot be
read (a document that is not UTF-8 included) or the output file cannot be written, 2 for an invalid
@@ -101,6 +103,8 @@ with open("contract.html", "w", encoding="utf-8", newline="\n") as output:
```
`render()` also accepts a `RenderOptions`. Pass `answers={...}` to render a template filled in.
+Each diagnostic has its `line` and its `file`: `render_file()` and the command name the file (its
+name, without the directory); for text given to `render()`, pass `filename=`.
Problems in the document never raise. Only an invalid style (`StyleError`), an unknown output
format (`OptionsError`), an unreadable document (`DocumentError`), strict mode, or a template that
cannot be assembled with its answers (`RenderRefused`) do. All of them are `RenderError`s, and
diff --git a/docs/architecture.md b/docs/architecture.md
index 2c0016a..81118df 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -83,6 +83,7 @@ never decides a structural or LegalDown question itself:
| Whether a quote is a drafting note, and its content | The validator's `is_drafting_note()` and `drafting_note_blocks()` |
| 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 line of a heading, a top-level block or a list item (§16.9) | `Document.line_of()` |
These are all the validator's public API: the model and the result from `legaldown`, and, for
assembly, `parse_template` (`Template.form().assemble()`), `AssemblyError`, and the answers
@@ -190,6 +191,7 @@ clamped to 1–5, as the validator numbers them.
src/legaldown_render/
├── __init__.py public API, __version__, SPEC_VERSION, CONFORMANCE_LEVEL
├── api.py render(), render_file(), RenderOptions, RenderResult
+├── diagnostics.py frontmatter key lines for diagnostics, and when a diagnostic repeats another
├── cli.py the legaldown-render command
├── errors.py RenderError, StyleError, OptionsError, DocumentError, RenderRefused, InternalError
├── tree.py render tree nodes (source and resolved inlines, blocks, document)
@@ -240,9 +242,30 @@ Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can ev
`ref-not-enumerated` and `path-not-relative` (an Error for an image path that is not relative, §2.3,
reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, `render-term-limit` (a Warning when defined terms refer to each other too deeply or too often: past 32 nested terms, or 200,000 inline nodes resolved inside repeat computations of a term text (only cycles cause them), a `{{term:}}` shows its id), and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
-diagnostics carry their line (§16.9), and the CLI prints it as `file:line:`. The renderer's own
-have none yet: they are about a directive within a block, and the validator makes only a block's
-line public (`Document.line_of`, U2).
+diagnostics carry their line (§16.9), and so do the renderer's own, and the CLI prints it as
+`file:line:`.
+
+**Lines.** The builder asks `Document.line_of` for the line of every section heading, top-level
+block and list item (an item's pre-order number, which the builder already tracks for markers) and
+stores it as `line` on `Section`, `Paragraph`, `ListItem`, `Table`, `Quote`, `DraftingNote` and
+`CodeBlock` (not part of equality, so trees compare as before). A block inside a quote has the
+quote's line; one inside a list item, the item's. The resolver tracks the line of what it is
+resolving (`Resolver.line`, set with `_at`) and every renderer diagnostic carries it, so the
+granularity is the block's (or the item's, or the heading's): a `{{ref:}}` or `{{term:}}` paragraph
+is lifted by the validator and written back, so the renderer cannot place a directive within a
+block without the validator's internal `Locator`, which would be a second bridge to its internals
+(ADR 0007); it waits for
+[validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93). A finding about
+frontmatter is at its key (`diagnostics.key_line`, which wraps the public `source_map.key` and
+returns None without a source map; one helper for every frontmatter reference). A term's text is
+worked out at the term's definition (`Resolver.term_lines`, recorded by `_survey`) whichever
+reference asks first. `Diagnostic.file` is `RenderOptions.filename`, which `render_file` and the CLI
+set to the file name as the validator does (`Document.filename`, without the directory).
+
+**Repeats.** `Resolver._warn` and `api.render` leave out a diagnostic whose rule, level and message
+a reported one has already, wherever it is (`diagnostics.repeats`), so a repeated finding is
+reported at its first line only. The renderer's `path-not-relative` is left out when the validator
+reported the same path at the same line, or at none (`diagnostics.covered_by`).
## Security
@@ -312,6 +335,7 @@ Each module is named for its topic. Shared helpers (the minimal document header,
| `tests/test_build.py` | Building the tree: comment-only items and quotes, emphasis next to a directive, spacing where an inline comment was dropped |
| `tests/test_terms.py` | What `{{term:}}` shows, references inside terms, terms that name themselves or each other, the cap on a term text |
| `tests/test_terms_budget.py` | Reuse of worked-out term texts, the work budget and depth limit for terms that name each other. Bounds the work by counting resolved directives, never by timing |
+| `tests/test_diagnostic_lines.py` | The line and file of every renderer diagnostic: in each kind of block (paragraph, list item, nested item, table, quote, heading, preamble) and at frontmatter keys, the repeats left out, the tree nodes' lines, and `file:line:` on the command line |
| `tests/test_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme allow-list, empty URLs and empty link text, and how a refusal is reported |
| `tests/test_text_writer.py` | The plain-text layout: attachment separators, tables, quotes, list items and indent, blanked labels |
| `tests/test_html.py` | The HTML writer's print stylesheet for attachments |
diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py
index 1ece903..9d14c66 100644
--- a/src/legaldown_render/api.py
+++ b/src/legaldown_render/api.py
@@ -13,7 +13,7 @@
from __future__ import annotations
from collections.abc import Mapping
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
@@ -21,6 +21,7 @@
from legaldown import AssemblyError, Diagnostic, Document, ValidationResult, parse, parse_template, validate
from .build import build_tree, normalize_source
+from .diagnostics import key_line, repeats
from .errors import DocumentError, OptionsError, RenderRefused, StyleError
from .resolve import Formatter, parse_locale, resolve_tree
from .style import Style, load_style
@@ -57,6 +58,14 @@ class RenderOptions:
answers: Mapping[str, Any] | None = None
#: HTML only: a complete page (True) or just the ```` (False).
standalone: bool = True
+ #: The document's file name, which every diagnostic of the result names as
+ #: its ``file`` (§16.9), the validator's and the renderer's alike, as
+ #: ``Document.filename`` does for the validator: the name without its
+ #: directory. ``render_file`` and the command line set it from the path
+ #: they read; for text given to :func:`render` it is empty unless set.
+ #: With ``answers``, the lines of the diagnostics are those of the
+ #: assembled document (§15.8), not of the template file.
+ filename: str = ""
@dataclass(frozen=True, slots=True)
@@ -105,15 +114,17 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
source = normalize_source(source)
assembled: list[Diagnostic] = []
if options.answers is not None:
- source, assembled = _assemble(source, options.answers)
- document = _parse(source)
+ source, assembled = _assemble(source, options.answers, options.filename)
+ document = _parse(source, options.filename)
result = validate(document, final=options.final)
diagnostics = assembled + list(result.diagnostics)
if options.strict and any(d.level == "error" for d in diagnostics):
raise RenderRefused(diagnostics)
tree, extra = _resolve(document, result, style)
- diagnostics += [d for d in extra if d not in diagnostics]
+ # The renderer's findings that the validator's or the assembly's already
+ # name (the same rule, level and message), wherever they are, are not repeated.
+ diagnostics += [d for d in extra if not repeats(d, diagnostics)]
if options.strict and any(d.level == "error" for d in diagnostics):
raise RenderRefused(diagnostics)
@@ -133,17 +144,22 @@ def render_file(path: str | Path, options: RenderOptions | None = None, /, **set
except UnicodeDecodeError as error:
raise DocumentError(f"The document cannot be read: {path} is not valid UTF-8 "
f"(byte 0x{data[error.start]:02x} at offset {error.start})") from None
+ if options is None:
+ options = RenderOptions(**settings)
+ settings = {}
+ if not options.filename and not settings:
+ options = replace(options, filename=Path(path).name)
return render(source, options, **settings)
-def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagnostic]]:
+def _assemble(source: str, answers: Mapping[str, Any], filename: str = "") -> 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(_parse(source)).diagnostics
+ findings = validate(_parse(source, filename)).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), "
@@ -161,9 +177,9 @@ def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagno
return result.output, list(result.diagnostics)
-def _parse(source: str) -> Document:
+def _parse(source: str, filename: str = "") -> Document:
try:
- return parse(source)
+ return parse(source, filename=filename)
except (ValueError, yaml.YAMLError) as error:
raise DocumentError(f"The document cannot be read: {error}") from error
@@ -185,6 +201,9 @@ def _resolve(document: Document, result: ValidationResult, style: Style) -> tupl
message=f"No formatting locale is set and the document language '{tree.language}' is not a "
f"known locale; values are formatted for 'en'. Set one with --locale or the style's "
f"'locale'.",
+ # Document-level: at the frontmatter's `language` key.
+ line=key_line(document, "language"),
+ file=document.filename,
))
locale = parse_locale("en")
assert locale is not None
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index 993e704..a24a419 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -192,7 +192,8 @@ def main(argv: list[str] | None = None) -> int:
return 1
options = RenderOptions(format=output_format, style=args.style, overrides=overrides,
- strict=args.strict, final=args.final, answers=answers, standalone=not args.fragment)
+ strict=args.strict, final=args.final, answers=answers, standalone=not args.fragment,
+ filename="" if args.input == "-" else Path(args.input).name)
try:
result = render(source, options)
except StyleError as error:
diff --git a/src/legaldown_render/diagnostics.py b/src/legaldown_render/diagnostics.py
new file mode 100644
index 0000000..916e002
--- /dev/null
+++ b/src/legaldown_render/diagnostics.py
@@ -0,0 +1,52 @@
+"""Where the renderer's own diagnostics point, and when one repeats another
+(§16.9: a diagnostic names its file and the line of what it reports).
+
+The validator's diagnostics carry a line, and the renderer's own do too, as
+far as the validator makes lines public: the first line of the block (or the
+list item, or the heading) the finding is in, ``Document.line_of``; and, for a
+finding about a frontmatter value, the line of its key (:func:`key_line`).
+Directives inside a block have no public line of their own (a ``{{ref:}}`` or
+``{{term:}}`` paragraph is lifted by the validator and written back, so even
+its text has moved), so a block's several findings share its line.
+"""
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Any
+
+from legaldown import Diagnostic, Document
+
+
+def key_line(document: Document, *path: Any) -> int | None:
+ """The line in the document's file of the frontmatter value at *path*
+ (``key_line(document, "attachments", 0, "file")``), or of the nearest key
+ above it that is written, as ``SourceMap.key`` says; None when the document
+ has no source to name a line of (it was built from a dict, or changed since
+ it was parsed) or has no frontmatter."""
+ source_map = document.source_map
+ if source_map is None or not source_map.fits(document):
+ return None
+ line = source_map.key(*path)
+ return line if isinstance(line, int) else None
+
+
+def repeats(found: Diagnostic, known: Iterable[Diagnostic]) -> bool:
+ """True if *known* holds *found*'s finding already: the same rule, level
+ and message, wherever it was. The same finding in two places is reported
+ once, at the first (a link to the same refused URL on two lines is one
+ ``render-link-refused``, at the line of the first)."""
+ return any(d.rule == found.rule and d.level == found.level and d.message == found.message for d in known)
+
+
+def covered_by(found: Diagnostic, path: str, validator: Iterable[Diagnostic]) -> bool:
+ """True if the validator already reported *found*, a ``path-not-relative``
+ about *path*: its diagnostic of the rule quotes the path (``''``) and
+ is at the same line, or has none. A path the validator did not name is
+ still the renderer's to report, even where the validator reported another
+ at the same line, and so is the same path where it stands elsewhere."""
+ quoted = f"'{path}'"
+ return any(
+ d.rule == found.rule and quoted in d.message
+ and (d.line is None or found.line is None or d.line == found.line)
+ for d in validator
+ )
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index 6954803..b94e67b 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -22,16 +22,23 @@
``{{term:}}`` may show, ``render-term-limit`` for terms that refer to each other too deeply or too often,
and ``path-not-relative`` for an image whose path is not relative (§2.3), which
the validator does not check yet.
+
+Every one of them names the file and the line it is at (§16.9): the first line
+of the block, list item or heading being resolved, or the line of a frontmatter
+key (``diagnostics.key_line``), tracked in :attr:`Resolver.line`. A finding in
+a term's text is at the term's definition, since that is where the text is
+worked out whatever reference asked for it first.
"""
from __future__ import annotations
import unicodedata
-from collections.abc import Callable
+from collections.abc import Callable, Iterator
+from contextlib import AbstractContextManager, contextmanager, nullcontext
from dataclasses import dataclass, replace
from typing import Any, NamedTuple
from urllib.parse import unquote
-from legaldown import Diagnostic, Document, ValidationResult
+from legaldown import Diagnostic, Document, Party, ValidationResult
from legaldown.grammar import (
ALWAYS,
DECISION_QUESTION_TYPES,
@@ -50,6 +57,7 @@
from legaldown.syntax import Directive
from ..build import plain_inlines
+from ..diagnostics import covered_by, key_line, repeats
from ..style import Style, effective_labels
from ..style.model import LevelFormat
from ..tree import (
@@ -83,6 +91,7 @@
TermRef,
Text,
Value,
+ iter_located_body_inlines,
iter_tree_inlines,
map_tree_inlines,
plain_text,
@@ -183,6 +192,12 @@ def __init__(
self.labels = effective_labels(style.labels, tree.language)
self.textual = style.numbering.scheme == "none"
self.diagnostics: list[Diagnostic] = []
+ # The line (§16.9) of what is being resolved, which a diagnostic
+ # names: see _at.
+ self.line: int | None = None
+ # A definition id -> the line of its first {{def:}} in the body (see
+ # _survey), where its term's text is worked out.
+ self.term_lines: dict[str, int | None] = {}
self.targets: dict[str, _Target] = {}
self.used_anchors: set[str] = set()
# For render-ref-ambiguous: every designation a numbered unit — a
@@ -234,20 +249,16 @@ def run(self) -> RenderTree:
# In document order, so that the first of several definitions of a
# term is the one that keeps its anchor.
preamble = tuple(self._resolve_block(block) for block in preamble)
- sections = tuple(
- replace(
- section,
- title=resolve(section.title),
- blocks=tuple(self._resolve_block(block) for block in section.blocks),
- condition_label=self._condition(section.condition),
- )
- for section in sections
- )
- attachments = tuple(replace(a, title=resolve(a.title)) for a in attachments)
+ sections = tuple(self._resolve_section(section) for section in sections)
+ attachments = tuple(self._resolve_attachment(index, a) for index, a in enumerate(attachments))
+ with self._at(key_line(self.document, "title")):
+ title = resolve(self.tree.title)
+ with self._at(key_line(self.document, "subtitle")):
+ subtitle = resolve(self.tree.subtitle)
resolved = replace(
self.tree,
- title=resolve(self.tree.title),
- subtitle=resolve(self.tree.subtitle),
+ title=title,
+ subtitle=subtitle,
locale=self.formatter.tag,
preamble=preamble,
sections=sections,
@@ -273,6 +284,19 @@ def run(self) -> RenderTree:
# definition may live in an attachment or an amended original.
return map_tree_inlines(resolved, self._settle_term_target)
+ def _resolve_section(self, section: Section) -> Section:
+ with self._at(section.line):
+ return replace(
+ section,
+ title=self._resolve_inlines(section.title),
+ blocks=tuple(self._resolve_block(block) for block in section.blocks),
+ condition_label=self._condition(section.condition),
+ )
+
+ def _resolve_attachment(self, index: int, attachment: AttachmentPart) -> AttachmentPart:
+ with self._at(key_line(self.document, "attachments", index, "title")):
+ return replace(attachment, title=self._resolve_inlines(attachment.title))
+
def _contents(
self, sections: tuple[Section, ...], attachments: tuple[AttachmentPart, ...],
) -> tuple[ContentsEntry, ...]:
@@ -321,6 +345,24 @@ def _survey(self) -> None:
if directive.name == "placeholder" and directive.positional and not directive.malformed:
types.setdefault(directive.positional, set()).add(self._placeholder_type(directive))
self.inconsistent_placeholders = {pid for pid, found in types.items() if len(found) > 1}
+ for inline, line in iter_located_body_inlines(self.tree):
+ if isinstance(inline, DefinitionSource):
+ self.term_lines.setdefault(self._definition_id(inline), line)
+
+ @contextmanager
+ def _at(self, line: int | None) -> Iterator[None]:
+ """Name *line* in the diagnostics reported meanwhile; None for a
+ finding with no line (a document built without a source)."""
+ outer, self.line = self.line, line
+ try:
+ yield
+ finally:
+ self.line = outer
+
+ def _here(self, line: int | None) -> AbstractContextManager[None]:
+ """:meth:`_at` for a unit that may have no line of its own, which
+ then has the line of the unit around it."""
+ return self._at(self.line if line is None else line)
# -- anchors --------------------------------------------------------------
@@ -365,7 +407,8 @@ def _number_sections(self) -> list[Section]:
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)
+ with self._at(section.line):
+ label, designation = None, self._title_text(section.title)
else:
designation = ""
for position, counter in enumerate(parts):
@@ -470,12 +513,14 @@ def _structure(self, block: Block, *, section: Section | None, depth: int, prese
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,
- quoted=True) for child in blocks))
+ return replace(block, blocks=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,
- quoted=True) for child in blocks),
- label=self.labels.drafting_note)
+ return replace(block, blocks=tuple(self._structure(child, section=section, depth=depth,
+ presence=presence, quoted=True)
+ for child in blocks),
+ label=self.labels.drafting_note)
case _:
return block
@@ -534,28 +579,33 @@ def _structure_list(self, block: List, *, section: Section | None, depth: int, p
# -- blocks ---------------------------------------------------------------
def _resolve_block(self, block: Block) -> Block:
+ with self._here(getattr(block, "line", None)):
+ return self._resolved_block(block)
+
+ def _resolved_block(self, block: Block) -> Block:
resolve = self._resolve_inlines
match block:
case Paragraph():
return replace(block, inlines=resolve(block.inlines), condition_label=self._condition(block.condition))
case List(items=items):
- return replace(block, items=tuple(
- replace(item, blocks=tuple(self._resolve_block(child) for child in item.blocks),
- condition_label=self._condition(item.condition))
- for item in items
- ))
+ return replace(block, items=tuple(self._resolve_item(item) for item in items))
case Quote(blocks=blocks):
- return Quote(tuple(self._resolve_block(child) for child in blocks))
+ return replace(block, blocks=tuple(self._resolve_block(child) for child in blocks))
case DraftingNote(blocks=blocks):
return replace(block, blocks=tuple(self._resolve_block(child) for child in blocks))
- case Table(header=header, rows=rows, align=align):
- return Table(tuple(resolve(cell) for cell in header),
- tuple(tuple(resolve(cell) for cell in row) for row in rows), align)
+ case Table(header=header, rows=rows):
+ return replace(block, header=tuple(resolve(cell) for cell in header),
+ rows=tuple(tuple(resolve(cell) for cell in row) for row in rows))
case CodeBlock():
return block
case _:
return block
+ def _resolve_item(self, item: ListItem) -> ListItem:
+ with self._here(item.line):
+ return replace(item, blocks=tuple(self._resolve_block(child) for child in item.blocks),
+ condition_label=self._condition(item.condition))
+
def _condition(self, condition: str) -> str | None:
if not condition:
return None
@@ -720,22 +770,26 @@ def _term_text(self, definition_id: str) -> str | None:
known = self._targets_known
def work() -> str:
- if not known:
- text = self._title_text(source.children)
- else:
- anchors = set(self.used_anchors)
- try:
- text = plain_text(self._resolve_inlines(source.children))
- finally:
- self.used_anchors = anchors
- if len(text) > _MAX_TERM_TEXT:
- self._warn(
- "render-term-too-long",
- f"The term of '{printable(definition_id)}' is longer than {_MAX_TERM_TEXT} characters once its "
- f"references are resolved; '{{{{term: {printable(definition_id)}}}}}' shows the definition id.",
- )
- return definition_id
- return text
+ # Worked out where the term is defined, whichever reference asks
+ # first, so what it reports is at the definition.
+ with self._here(self.term_lines.get(definition_id)):
+ if not known:
+ text = self._title_text(source.children)
+ else:
+ anchors = set(self.used_anchors)
+ try:
+ text = plain_text(self._resolve_inlines(source.children))
+ finally:
+ self.used_anchors = anchors
+ if len(text) > _MAX_TERM_TEXT:
+ self._warn(
+ "render-term-too-long",
+ f"The term of '{printable(definition_id)}' is longer than {_MAX_TERM_TEXT} characters once "
+ f"its references are resolved; '{{{{term: {printable(definition_id)}}}}}' shows the "
+ f"definition id.",
+ )
+ return definition_id
+ return text
return self._while_open(self.term_texts if known else self.term_texts_early, definition_id, work)
@@ -832,7 +886,17 @@ def _named(self, kind: str, name: str, lookup: dict[str, str], directive: Direct
return FailureMarker(f"[INVALID {kind.upper()}: {name}]")
if name not in lookup:
return FailureMarker(f"[UNKNOWN {kind.upper()}: {name}]")
- return Value(kind, directive.params.get("label") or self._plain_value(lookup[name]))
+ key = self._side_key(name) if kind == "side" else self._party_key(name)
+ return Value(kind, directive.params.get("label") or self._plain_value(lookup[name], *key))
+
+ def _side_key(self, name: str) -> tuple[Any, ...]:
+ """The frontmatter path of side *name*, or ``()``."""
+ return next((("sides", i) for i, side in enumerate(self.metadata.sides) if side.name == name), ())
+
+ def _party_key(self, name: str) -> tuple[Any, ...]:
+ """The frontmatter path of party *name*, or ``()``."""
+ return next((("sides", i, "parties", j) for i, side in enumerate(self.metadata.sides)
+ for j, party in enumerate(side.parties) if party.name == name), ())
def _duration(self, directive: Directive, value: str) -> Inline:
unit = directive.params.get("unit")
@@ -898,7 +962,9 @@ def _attach(self, directive: Directive, attachment_id: str) -> Inline:
else:
declared = next((a for a in self.metadata.attachments if a.id == attachment_id), None)
href = declared.file if declared and declared.file else None
- return Value("attach", directive.params.get("label") or self._plain_value(title), href)
+ key = next((("attachments", i, "title") for i, a in enumerate(self.metadata.attachments)
+ if a.id == attachment_id), ())
+ return Value("attach", directive.params.get("label") or self._plain_value(title, *key), href)
def _choose(self, directive: Directive, question_id: str) -> Inline:
invalid = FailureMarker(f"[INVALID CHOICE: {question_id}]" if question_id else "[INVALID CHOICE]")
@@ -931,12 +997,15 @@ def _def(self, directive: Directive, identifier: str) -> Inline | None:
# -- frontmatter ----------------------------------------------------------
- def _plain_value(self, text: str) -> str:
+ def _plain_value(self, text: str, *key: Any) -> str:
"""A frontmatter string with its directives (placeholders, §3.10)
- resolved, as plain text."""
+ resolved, as plain text. *key* is the path of the frontmatter value in
+ the document, when the text is shown elsewhere (a title or a name
+ in the body): what it reports is at that key, not where it is shown."""
if "{{" not in text:
return text
- return plain_text(self._resolve_inlines(plain_inlines(text)))
+ with self._at(key_line(self.document, *key)) if key else nullcontext():
+ return plain_text(self._resolve_inlines(plain_inlines(text)))
def _frontmatter(self, text: str) -> tuple[Inline, ...]:
return self._resolve_inlines(plain_inlines(text))
@@ -945,9 +1014,11 @@ def _header(self) -> tuple[tuple[str, tuple[Inline, ...]], ...]:
rows: list[tuple[str, tuple[Inline, ...]]] = []
metadata = self.metadata
if self.style.title_block.effective_date and metadata.effective_date:
- rows.append((self.labels.effective_date or "", self._date_or_text(metadata.effective_date)))
+ with self._at(key_line(self.document, "effective_date")):
+ rows.append((self.labels.effective_date or "", self._date_or_text(metadata.effective_date)))
if self.style.title_block.version and metadata.version:
- rows.append((self.labels.version or "", self._frontmatter(metadata.version)))
+ with self._at(key_line(self.document, "version")):
+ rows.append((self.labels.version or "", self._frontmatter(metadata.version)))
return tuple(rows)
def _date_or_text(self, value: str) -> tuple[Inline, ...]:
@@ -957,31 +1028,35 @@ def _date_or_text(self, value: str) -> tuple[Inline, ...]:
def _sides(self) -> tuple[SideInfo, ...]:
sides: list[SideInfo] = []
- for side in self.metadata.sides:
- parties: list[PartyInfo] = []
- for party in side.parties:
- details: list[tuple[str, tuple[Inline, ...]]] = []
- if party.identification_number:
- details.append((self.labels.identification_number or "", self._frontmatter(party.identification_number)))
- if party.date_of_birth:
- details.append((self.labels.date_of_birth or "", self._date_or_text(party.date_of_birth)))
- if party.address:
- details.append((self.labels.address or "", self._frontmatter(party.address)))
- for custom in party.custom_fields:
- details.append((custom.label, self._frontmatter(custom.value)))
- for rep in party.representatives:
- name, title = self._frontmatter(rep.name), self._frontmatter(rep.title)
- value = name + (Text(", "),) + title if name and title else name or title
- if value:
- details.append((self.labels.represented_by or "", value))
- parties.append(PartyInfo(
- name=self._frontmatter(party.legal_name or party.label or party.name),
- details=tuple(details),
- ))
+ for side_index, side in enumerate(self.metadata.sides):
+ parties = tuple(self._party_info(side_index, party_index, party)
+ for party_index, party in enumerate(side.parties))
label = self.result.index.side_lookup.get(side.name) or side.label or side.name
- sides.append(SideInfo(self._frontmatter(label), tuple(parties)))
+ with self._at(key_line(self.document, "sides", side_index, "label")):
+ sides.append(SideInfo(self._frontmatter(label), parties))
return tuple(sides)
+ def _party_info(self, side_index: int, party_index: int, party: Party) -> PartyInfo:
+ with self._at(key_line(self.document, "sides", side_index, "parties", party_index)):
+ details: list[tuple[str, tuple[Inline, ...]]] = []
+ if party.identification_number:
+ details.append((self.labels.identification_number or "", self._frontmatter(party.identification_number)))
+ if party.date_of_birth:
+ details.append((self.labels.date_of_birth or "", self._date_or_text(party.date_of_birth)))
+ if party.address:
+ details.append((self.labels.address or "", self._frontmatter(party.address)))
+ for custom in party.custom_fields:
+ details.append((custom.label, self._frontmatter(custom.value)))
+ for rep in party.representatives:
+ name, title = self._frontmatter(rep.name), self._frontmatter(rep.title)
+ value = name + (Text(", "),) + title if name and title else name or title
+ if value:
+ details.append((self.labels.represented_by or "", value))
+ return PartyInfo(
+ name=self._frontmatter(party.legal_name or party.label or party.name),
+ details=tuple(details),
+ )
+
def _attachments(self) -> tuple[AttachmentPart, ...]:
"""Attachment placeholders (§13.8), in declared order. At the
Rendering level the files are not read, so each shows its title."""
@@ -1005,24 +1080,34 @@ def _signatures(self) -> tuple[SignatureParty, ...]:
if not (self.style.signatures.enabled and self.metadata.include_signatures):
return ()
blocks: list[SignatureParty] = []
- for side in self.metadata.sides:
- label = self._frontmatter(self.result.index.side_lookup.get(side.name) or side.label or side.name)
- for party in side.parties:
- # Where signature blocks are generated, legal_name MUST appear (§3.6).
- blocks.append(SignatureParty(
- side=label,
- name=self._frontmatter(party.legal_name or party.label or party.name),
- signatories=tuple(
- (self._frontmatter(rep.name), self._frontmatter(rep.title)) for rep in party.representatives
- ),
- ))
+ for side_index, side in enumerate(self.metadata.sides):
+ with self._at(key_line(self.document, "sides", side_index, "label")):
+ label = self._frontmatter(self.result.index.side_lookup.get(side.name) or side.label or side.name)
+ for party_index, party in enumerate(side.parties):
+ with self._at(key_line(self.document, "sides", side_index, "parties", party_index)):
+ # Where signature blocks are generated, legal_name MUST appear (§3.6).
+ blocks.append(SignatureParty(
+ side=label,
+ name=self._frontmatter(party.legal_name or party.label or party.name),
+ signatories=tuple(
+ (self._frontmatter(rep.name), self._frontmatter(rep.title))
+ for rep in party.representatives
+ ),
+ ))
return tuple(blocks)
# -- diagnostics ----------------------------------------------------------
+ def _found(self, rule: str, level: str, message: str) -> Diagnostic:
+ """A diagnostic of the renderer's, at the line being resolved, in the
+ document's file (§16.9)."""
+ return Diagnostic(rule=rule, level=level, message=message, line=self.line, file=self.document.filename)
+
def _warn(self, rule: str, message: str) -> None:
- diagnostic = Diagnostic(rule=rule, level="warning", message=message)
- if diagnostic not in self.diagnostics:
+ """Report a Warning, unless the same one (rule and message) is
+ reported already: wherever it recurs, it stands at its first line."""
+ diagnostic = self._found(rule, "warning", message)
+ if not repeats(diagnostic, self.diagnostics):
self.diagnostics.append(diagnostic)
def _link_refused(self, href: str) -> None:
@@ -1040,25 +1125,23 @@ def _link_refused(self, href: str) -> None:
def _not_relative(self, path: str) -> None:
"""Report an image *path* that is not relative as the Core Error
``path-not-relative`` (§2.3, §16.2), unless the validator has
- reported it. The renderer has no line for an image, so a path the
- validator named is the one it has covered. markdown-it has
- percent-encoded the path ("C:\\x" reads "C:%5Cx"), so it is decoded
- to name the path as written."""
+ reported it (:func:`~legaldown_render.diagnostics.covered_by`: its
+ report of the path, at this line). markdown-it has percent-encoded
+ the path ("C:\\x" reads "C:%5Cx"), so it is decoded to name the path
+ as written."""
path = unquote(path)
+ shown = printable(path)
+ diagnostic = self._found(
+ "path-not-relative", "error",
+ f"Image path '{shown}' is not relative (§2.3): file references are relative paths with no "
+ f"URI scheme. Its alt text is shown instead.",
+ )
# Assumption: the validator quotes the path in its message as '',
# as written. Matching the quoted form keeps a path that is a prefix of
# another ("/a" and "/ab") from being counted as already reported.
- if any(d.rule == "path-not-relative" and f"'{path}'" in d.message for d in self.result.diagnostics):
+ if covered_by(diagnostic, path, self.result.diagnostics) or repeats(diagnostic, self.diagnostics):
return
- path = printable(path)
- diagnostic = Diagnostic(
- rule="path-not-relative",
- level="error",
- message=f"Image path '{path}' is not relative (§2.3): file references are relative paths with no "
- f"URI scheme. Its alt text is shown instead.",
- )
- if diagnostic not in self.diagnostics:
- self.diagnostics.append(diagnostic)
+ self.diagnostics.append(diagnostic)
_HANDLERS: dict[str, Callable[[Resolver, Directive, str], Inline | None]] = {
diff --git a/tests/test_diagnostic_lines.py b/tests/test_diagnostic_lines.py
new file mode 100644
index 0000000..ff7bb51
--- /dev/null
+++ b/tests/test_diagnostic_lines.py
@@ -0,0 +1,458 @@
+"""Where the renderer's own diagnostics point (§16.9): the file, and the line
+of the block, list item or heading they are in, or of the frontmatter key they
+are about; how a repeat is left out; how the command line prints them.
+
+The validator names the first line of a block for anything in it, and so do
+these: a paragraph's findings are all at its first line, a table's at its
+header row, a quote's at its first ``>``, a list item's at its marker. What
+the validator makes public of a directive's own line is not used (see
+``legaldown_render.diagnostics``)."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from conftest import run_cli_text
+
+import legaldown_render.api as api
+from legaldown_render import RenderOptions, render, render_file
+from legaldown_render.cli import main
+from legaldown_render.diagnostics import key_line
+
+HEAD = "---\ntitle: T\n---\n"
+
+
+def lines_of(result, rule: str) -> list[int | None]: # type: ignore[no-untyped-def]
+ return [d.line for d in result.diagnostics if d.rule == rule]
+
+
+def at(source: str, needle: str) -> int:
+ """The line (from 1) of the first line of *source* holding *needle*."""
+ return next(number for number, line in enumerate(source.split("\n"), 1) if needle in line)
+
+
+# One finding that the renderer reports, whatever the block it stands in: an
+# include is not processed (§17.5), a link's scheme is refused (§8.7), an image
+# path is not relative (§2.3).
+TRIGGERS = {
+ "render-not-processed": "{{include: part.lgd}}",
+ "render-link-refused": "[the link](ms-msdt:x)",
+ "path-not-relative": "",
+}
+
+#: Containers for a trigger: the text of a section with the trigger at MARK, and the word that
+#: marks the line its finding is expected at.
+CONTAINERS = {
+ "paragraph": ("# A\n\nFirst line FIRST\nsecond line\nthird MARK line\n", "FIRST"),
+ "heading": ("# Heading MARK FIRST\n\nText.\n", "FIRST"),
+ "list item": ("# A\n\n- one\n- two FIRST MARK\n- three\n", "FIRST"),
+ "item, second line": ("# A\n\n- one\n- two FIRST\n continued MARK\n- three\n", "FIRST"),
+ "nested item": ("# A\n\n- one\n - nested FIRST MARK\n - more\n- three\n", "FIRST"),
+ "nested item, third level": ("# A\n\n- a\n - b\n - c FIRST MARK\n- d\n", "FIRST"),
+ "item, second block": ("# A\n\n- one FIRST\n\n another MARK paragraph\n", "FIRST"),
+ "ordered item": ("# A\n\n1. one\n2. two FIRST MARK\n", "FIRST"),
+ "table": ("# A\n\n| a FIRST | b |\n|---|---|\n| x | y |\n| z | MARK |\n", "FIRST"),
+ "quote": ("# A\n\n> quoted FIRST\n> second MARK\n", "FIRST"),
+ "quote, second paragraph": ("# A\n\n> quoted FIRST\n>\n> second MARK\n", "FIRST"),
+ "list in a quote": ("# A\n\n> intro FIRST\n>\n> - item MARK\n> - item\n", "FIRST"),
+ "quote in a list item": ("# A\n\n- one\n- two FIRST\n\n > quoted MARK\n", "FIRST"),
+ "drafting note": ("# A\n\n> [!DRAFTING] FIRST\n> note MARK\n", "FIRST"),
+ "definition paragraph": ('# A\n\n"Thing" {{def: thing}} is FIRST MARK\n', "FIRST"),
+}
+
+
+@pytest.mark.parametrize("rule", sorted(TRIGGERS))
+@pytest.mark.parametrize("container", sorted(CONTAINERS))
+def test_a_finding_is_at_the_first_line_of_its_block(container: str, rule: str) -> None:
+ body, first = CONTAINERS[container]
+ source = HEAD + "\nBefore.\n\n" + body.replace("MARK", TRIGGERS[rule])
+ expected = at(source, first)
+ for fmt in ("html", "text"):
+ result = render(source, format=fmt)
+ assert lines_of(result, rule) == [expected], (container, fmt, result.diagnostics)
+
+
+def test_a_finding_in_the_preamble_is_at_its_block() -> None:
+ source = HEAD + "\nFirst preamble paragraph.\n\nSecond one\nhas a {{include: x.lgd}}.\n\n# A\n\nBody.\n"
+ assert lines_of(render(source), "render-not-processed") == [at(source, "Second one")]
+
+
+def test_a_finding_in_a_list_in_the_preamble_is_at_its_item() -> None:
+ source = HEAD + "\n- one\n- two {{include: x.lgd}}\n\n# A\n"
+ assert lines_of(render(source), "render-not-processed") == [at(source, "- two")]
+
+
+def test_a_finding_in_a_section_title_is_at_its_heading() -> None:
+ source = HEAD + "\nText.\n\n## Second {{include: x.lgd}}\n\nBody.\n"
+ assert lines_of(render(source), "render-not-processed") == [at(source, "## Second")]
+
+
+def test_a_finding_in_a_heading_under_the_none_scheme_is_reported_once_at_its_heading() -> None:
+ # The `none` scheme reads each heading as text before the references are known.
+ source = HEAD + "\n# One\n\n## Second {{include: x.lgd}}\n\nSee {{ref: second}}.\n"
+ result = render(source, overrides={"numbering.scheme": "none"})
+ assert lines_of(result, "render-not-processed") == [at(source, "## Second")]
+
+
+def test_a_heading_inside_a_list_item_has_its_items_line() -> None:
+ source = HEAD + "\n# A\n\n- one\n- ## two {{include: x.lgd}}\n"
+ assert lines_of(render(source), "render-not-processed") == [at(source, "- ## two")]
+
+
+def test_each_block_has_its_own_line() -> None:
+ source = (HEAD + "\n# A\n\nOne [x](ms-msdt:one).\n\n- item [y](ms-msdt:two)\n\n> quote [z](ms-msdt:three)\n\n"
+ "| h [w](ms-msdt:four) |\n|---|\n| c |\n\n# B\n\nLast [v](ms-msdt:five).\n")
+ expected = [at(source, f"ms-msdt:{word}") for word in ("one", "two", "three", "four", "five")]
+ assert lines_of(render(source), "render-link-refused") == expected
+
+
+def test_a_table_is_at_its_header_row_whichever_cell_holds_the_finding() -> None:
+ source = HEAD + "\n# A\n\n| a | b |\n|---|---|\n| x | y |\n| z | {{include: x.lgd}} |\n"
+ assert lines_of(render(source), "render-not-processed") == [at(source, "| a | b |")]
+
+
+# -- ref-not-enumerated, render-ref-ambiguous ------------------------------------------------
+
+
+def test_ref_not_enumerated_is_at_its_block() -> None:
+ source = (HEAD + "\n# A {#a}\n\n- first {#x}\n- second\n\nSee\nthe item {{ref: x}}.\n")
+ result = render(source, format="text", overrides={"enumeration.enabled": False})
+ assert lines_of(result, "ref-not-enumerated") == [at(source, "See")]
+
+
+def test_ref_not_enumerated_in_a_list_item_is_at_the_item() -> None:
+ source = (HEAD + "\n# A {#a}\n\n- first {#x}\n- second\n see {{ref: x}}\n- third\n")
+ result = render(source, format="text", overrides={"enumeration.enabled": False})
+ assert lines_of(result, "ref-not-enumerated") == [at(source, "- second")]
+
+
+def test_render_ref_ambiguous_is_at_its_block() -> None:
+ source = HEAD + "\n# A\n\n- a {#x}\n\nPara.\n\n- b {#y}\n\n> Quoted\n> See {{ref: x}}.\n"
+ assert lines_of(render(source, format="text"), "render-ref-ambiguous") == [at(source, "> Quoted")]
+
+
+def test_ref_findings_are_the_block_not_the_directive() -> None:
+ # A {{ref:}} paragraph is lifted by the validator and written back, so the
+ # renderer names its block's first line, not the line the directive is on.
+ source = HEAD + "\n# A\n\n- a {#x}\n\nPara.\n\n- b {#y}\n\nFirst line\nof a paragraph, See {{ref: x}}.\n"
+ assert lines_of(render(source, format="text"), "render-ref-ambiguous") == [at(source, "First line")]
+
+
+# -- render-term-limit, render-term-too-long ----------------------------------------------
+
+
+def _chain(n: int) -> str:
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)]
+ defs.append(f'"T{n}" {{{{def: t{n}}}}}')
+ return HEAD + "\n# About {{term: t0}}\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+def test_a_term_that_is_too_long_is_reported_at_its_definition(scheme: str) -> None:
+ # The text is worked out at the definition (for a heading under `none`, too),
+ # whichever reference asks for it first: t0 is used in the title and in the body.
+ source = _chain(14)
+ result = render(source, format="text", overrides={"numbering.scheme": scheme})
+ found = [d for d in result.diagnostics if d.rule == "render-term-too-long"]
+ assert found
+ for d in found:
+ identifier = d.message.split("'")[1]
+ assert d.line == at(source, "{{def: " + identifier + "}}"), d
+
+
+def _tangle(k: int) -> str:
+ defs = [f'"T{i} {" ".join(f"{{{{term: t{j}}}}}" for j in range(k))}" {{{{def: t{i}}}}}' for i in range(k)]
+ return HEAD + "\n# A\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+
+
+def test_the_term_limit_is_reported_at_a_line_of_a_definition() -> None:
+ source = _tangle(12)
+ result = render(source, format="text")
+ [line] = lines_of(result, "render-term-limit")
+ assert line is not None
+ assert source.split("\n")[line - 1].startswith('"T')
+
+
+# -- render-locale-fallback --------------------------------------------------------------
+
+
+def test_a_duration_with_no_pattern_in_the_locale_is_at_its_block(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown_render.resolve import values
+
+ real = values._unit_patterns
+ monkeypatch.setattr(values, "_unit_patterns",
+ lambda locale, unit, length: {} if locale.language == "cs" else real(locale, unit, length))
+ source = ("---\ntitle: T\nlanguage: cs\n---\n\n# Lhůty\n\nPrvní\n\n- druhá {{duration: 5, unit=D}}\n\n"
+ "Třetí {{duration: 6, unit=D}} a {{duration: 2, unit=MO}}.\n")
+ result = render(source, format="text")
+ # One per unit, at the first place it is used.
+ assert [(d.line, d.message.split("'")[1]) for d in result.diagnostics if d.rule == "render-locale-fallback"] \
+ == [(at(source, "druhá"), "duration-day"), (at(source, "Třetí"), "duration-month")]
+
+
+def test_an_unknown_document_language_is_at_the_language_key() -> None:
+ source = "---\ntitle: T\nlanguage: xx\nversion: '1'\n---\n\n# A\n"
+ assert lines_of(render(source), "render-locale-fallback") == [at(source, "language:")]
+
+
+def test_the_language_key_line_is_the_keys_not_the_frontmatters_first() -> None:
+ source = "---\ntitle: T\nparties: []\nlanguage: xx\n---\n\n# A\n"
+ assert lines_of(render(source), "render-locale-fallback") == [4]
+
+
+# -- Frontmatter keys ---------------------------------------------------------------------
+
+
+def test_a_finding_in_a_frontmatter_value_is_at_its_key() -> None:
+ source = ('---\ntitle: "T {{include: t.lgd}}"\nsubtitle: "Sub {{include: s.lgd}}"\n'
+ 'version: "v {{include: v.lgd}}"\neffective_date: "e {{include: e.lgd}}"\n---\n\n# A\n')
+ result = render(source, overrides={"title_block.version": True, "title_block.effective_date": True})
+ found = {d.message.split("{{include: ")[1][:5]: d.line for d in result.diagnostics
+ if d.rule == "render-not-processed"}
+ assert found == {"t.lgd": 2, "s.lgd": 3, "v.lgd": 4, "e.lgd": 5}
+
+
+def test_a_finding_in_an_attachment_title_is_at_its_title_key() -> None:
+ source = ("---\ntitle: T\nattachments:\n - id: first\n title: One\n - id: second\n"
+ " title: \"Two {{include: x.lgd}}\"\n---\n\n# A\n\nSee {{attach: first}} and {{attach: second}}.\n")
+ assert lines_of(render(source), "render-not-processed") == [at(source, "title: \"Two")]
+
+
+SIDES = """---
+title: T
+document_type: contract
+sides:
+ - name: seller
+ label: "Seller {{include: l.lgd}}"
+ parties:
+ - name: acme
+ legal_name: Acme
+ type: legal_entity
+ address: "Street {{include: a.lgd}}"
+ - name: buyer
+ label: Buyer
+ parties:
+ - name: bob
+ legal_name: Bob
+ type: natural_person
+ address: "Road {{include: b.lgd}}"
+---
+
+# A
+"""
+
+
+def test_a_finding_in_a_party_or_a_side_is_at_its_key() -> None:
+ result = render(SIDES)
+ found = {d.message.split("{{include: ")[1][:5]: d.line for d in result.diagnostics
+ if d.rule == "render-not-processed"}
+ # A party's detail is at the party; a side's label, at its label key.
+ assert found == {"l.lgd": at(SIDES, 'label: "Seller'), "a.lgd": at(SIDES, "name: acme"),
+ "b.lgd": at(SIDES, "name: bob")}
+
+
+def test_signature_blocks_are_not_a_second_report() -> None:
+ result = render(SIDES, overrides={"signatures.enabled": True})
+ assert len(lines_of(result, "render-not-processed")) == 3
+
+
+def test_key_line_is_general_and_none_without_a_source() -> None:
+ from legaldown import Document, parse
+
+ source = ("---\ntitle: T\namends:\n file: ../a.lgd\n title: X\nsupersedes: ../b.lgd\n"
+ "translations:\n cs: ../cs.lgd\nattachments:\n - id: a\n title: A\n file: ../c.lgd\n---\n\n# A\n")
+ document = parse(source)
+ assert key_line(document, "amends", "file") == at(source, "file: ../a.lgd")
+ assert key_line(document, "supersedes") == at(source, "supersedes:")
+ assert key_line(document, "translations", "cs") == at(source, "cs:")
+ assert key_line(document, "attachments", 0, "file") == at(source, "file: ../c.lgd")
+ # A key that is not written is the nearest one above it that is.
+ assert key_line(document, "amends", "date") == at(source, "amends:")
+ assert key_line(parse("# A\n"), "language") is None
+ assert key_line(Document(), "title") is None
+ document.sections.clear() # changed since it was parsed: no longer fits its source
+ assert key_line(document, "title") is None
+
+
+def test_a_document_without_a_source_has_no_lines() -> None:
+ # The tree built from a dict has no source map; its findings name no line.
+ from legaldown import Document
+
+ document = Document.from_dict({"metadata": {"title": "T"}, "sections": [
+ {"title": "A", "level": 1, "blocks": [{"kind": "paragraph", "text": "x {{include: p.lgd}}"}]}]})
+ from legaldown import validate
+
+ from legaldown_render.build import build_tree
+ from legaldown_render.resolve import Formatter, parse_locale, resolve_tree
+ from legaldown_render.style import load_style
+
+ result = validate(document)
+ tree = build_tree(document, result)
+ assert tree.sections[0].line is None
+ style = load_style(None)
+ _, found = resolve_tree(tree, document, result, style, Formatter(parse_locale("en"), style.values))
+ assert [d.line for d in found if d.rule == "render-not-processed"] == [None]
+
+
+# -- The nodes -----------------------------------------------------------------------------
+
+
+def test_the_tree_nodes_carry_their_lines() -> None:
+ source = HEAD + ("\nPreamble.\n\n# A\n\nPara.\n\n- one\n - nested\n- two\n\n> quote\n\n> [!DRAFTING] note\n\n"
+ "```\ncode\n```\n\n| a |\n|---|\n| b |\n")
+ tree = render(source).tree
+ assert tree.preamble[0].line == at(source, "Preamble")
+ section = tree.sections[0]
+ assert section.line == at(source, "# A")
+ paragraph, bullets, quote, note, code, table = section.blocks
+ assert [b.line for b in (paragraph, quote, note, code, table)] == [
+ at(source, "Para."), at(source, "> quote"), at(source, "[!DRAFTING]"), at(source, "```"), at(source, "| a |")]
+ assert [item.line for item in bullets.items] == [at(source, "- one"), at(source, "- two")]
+ assert bullets.items[0].blocks[1].items[0].line == at(source, "- nested")
+
+
+def test_a_line_is_not_part_of_equality() -> None:
+ from legaldown_render.tree import Paragraph, Table
+
+ assert Paragraph((), line=3) == Paragraph((), line=9)
+ assert Table((), (), (), line=1) == Table((), (), ())
+
+
+def test_lines_survive_resolving() -> None:
+ source = HEAD + "\n# A\n\n> quote {{ref: a}}\n\n| h |\n|---|\n| {{ref: a}} |\n\n> [!DRAFTING] note\n"
+ section = render(source).tree.sections[0]
+ assert [block.line for block in section.blocks] == [at(source, "> quote"), at(source, "| h |"),
+ at(source, "[!DRAFTING]")]
+ assert section.line == at(source, "# A")
+
+
+# -- Repeats -------------------------------------------------------------------------------
+
+
+def test_the_same_finding_on_two_lines_is_reported_once_at_the_first() -> None:
+ source = HEAD + "\n# A\n\nOne .\n\nTwo .\n\nThree [l](ms-msdt:x) [m](ms-msdt:x)\n\nFour [l](ms-msdt:x)\n"
+ result = render(source)
+ assert lines_of(result, "path-not-relative") == [at(source, "One")]
+ assert lines_of(result, "render-link-refused") == [at(source, "Three")]
+
+
+def test_different_findings_of_one_rule_each_have_their_line() -> None:
+ source = HEAD + "\n# A\n\nOne .\n\nTwo .\n"
+ assert lines_of(render(source), "path-not-relative") == [at(source, "One"), at(source, "Two")]
+
+
+def _with_validator_report(monkeypatch: pytest.MonkeyPatch, line: int | None, path: str = "https://x/p.png") -> None:
+ from legaldown import Diagnostic
+ from legaldown import validate as real_validate
+
+ def validate(document, **kwargs): # type: ignore[no-untyped-def]
+ result = real_validate(document, **kwargs)
+ result.diagnostics.append(Diagnostic(rule="path-not-relative", level="error", line=line,
+ message=f"Image path '{path}' is not relative (§2.3)."))
+ return result
+
+ monkeypatch.setattr(api, "validate", validate)
+
+
+def test_the_validators_report_of_a_path_at_its_line_covers_the_renderers(monkeypatch: pytest.MonkeyPatch) -> None:
+ source = HEAD + "\n# A\n\n \n"
+ _with_validator_report(monkeypatch, at(source, "![a]"))
+ errors = [d for d in render(source).diagnostics if d.rule == "path-not-relative"]
+ # The validator's for the path it named, ours for the other one at the same line.
+ assert [(d.line, "/other.png" in d.message) for d in errors] == [(at(source, "![a]"), False),
+ (at(source, "![a]"), True)]
+
+
+def test_a_report_with_no_line_covers_the_path_wherever_it_is(monkeypatch: pytest.MonkeyPatch) -> None:
+ source = HEAD + "\n# A\n\n\n"
+ _with_validator_report(monkeypatch, None)
+ assert len([d for d in render(source).diagnostics if d.rule == "path-not-relative"]) == 1
+
+
+def test_the_same_path_at_another_line_is_not_covered(monkeypatch: pytest.MonkeyPatch) -> None:
+ source = HEAD + "\n# A\n\n\n\n\nOther \n"
+ _with_validator_report(monkeypatch, at(source, "![a]"))
+ errors = [d for d in render(source).diagnostics if d.rule == "path-not-relative"]
+ # The validator's line, and ours at the first image that is not the line it named.
+ assert [d.line for d in errors] == [at(source, "![a]"), at(source, "Other")]
+
+
+# -- The file ------------------------------------------------------------------------------
+
+REFS = HEAD + "\n# A\n\nSee {{include: x.lgd}} and .\n\n
raw
\n"
+
+
+def test_findings_name_the_file_when_it_is_given() -> None:
+ result = render(REFS, filename="contract.lgd")
+ assert {d.rule for d in result.diagnostics} >= {"render-not-processed", "path-not-relative", "raw-html"}
+ assert {d.file for d in result.diagnostics} == {"contract.lgd"}
+ assert {d.file for d in render(REFS).diagnostics} == {""}
+
+
+def test_render_file_names_the_file(tmp_path: Path) -> None:
+ path = tmp_path / "deal.lgd"
+ path.write_text(REFS, encoding="utf-8")
+ found = {(d.rule, d.file) for d in render_file(path).diagnostics}
+ assert ("render-not-processed", "deal.lgd") in found
+ assert ("raw-html", "deal.lgd") in found # the validator's, too
+ # Options given: the file name is added to them; one already named stays.
+ assert {d.file for d in render_file(path, RenderOptions(format="text")).diagnostics} == {"deal.lgd"}
+ named = render_file(path, RenderOptions(filename="other.lgd"))
+ assert {d.file for d in named.diagnostics} == {"other.lgd"}
+ assert {d.file for d in render_file(path, format="text").diagnostics} == {"deal.lgd"}
+ with pytest.raises(TypeError):
+ render_file(path, RenderOptions(), format="text")
+
+
+def test_the_locale_fallback_names_the_file() -> None:
+ source = "---\ntitle: T\nlanguage: xx\n---\n\n# A\n"
+ [found] = [d for d in render(source, filename="x.lgd").diagnostics if d.rule == "render-locale-fallback"]
+ assert (found.file, found.line) == ("x.lgd", 3)
+
+
+def test_assembly_names_the_file_too() -> None:
+ template = ("---\ntitle: T\nquestions:\n q:\n type: text\n prompt: Q\n---\n\n# A\n\n"
+ "Hello {{placeholder: q}} {{include: x.lgd}}\n")
+ result = render(template, answers={"q": "world"}, filename="t.lgd")
+ assert {d.file for d in result.diagnostics} == {"t.lgd"}
+ # The lines are those of the assembled document (the answers are filled in).
+ [found] = [d for d in result.diagnostics if d.rule == "render-not-processed"]
+ assert found.line is not None
+
+
+# -- The command line ------------------------------------------------------------------------
+
+
+def test_the_command_line_prints_the_file_and_line(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "deal.lgd"
+ path.write_text(REFS, encoding="utf-8")
+ assert main([str(path), "-f", "text", "-o", str(tmp_path / "out.txt")]) == 0
+ lines = capsys.readouterr().err.splitlines()
+ wanted = at(REFS, "See")
+ assert f"{path}:{wanted}: warning: [render-not-processed] " in " ".join(lines)
+ assert any(line.startswith(f"{path}:{wanted}: error: [path-not-relative] ") for line in lines)
+ # The validator's own line is printed the same way.
+ assert any(line.startswith(f"{path}:{at(REFS, '
')}: warning: [raw-html] ") for line in lines)
+ # Each diagnostic once, with one location: no `file:line` twice over.
+ assert not any(f"{path}:{wanted}:" in line.split(": ", 1)[1] for line in lines)
+ assert len(lines) == len(set(lines))
+
+
+def test_the_command_line_prints_a_frontmatter_line(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "x.lgd"
+ path.write_text("---\ntitle: T\nlanguage: xx\n---\n\n# A\n", encoding="utf-8")
+ assert main([str(path), "-o", str(tmp_path / "out.html")]) == 0
+ assert f"{path}:3: info: [render-locale-fallback] " in capsys.readouterr().err
+
+
+def test_standard_input_prints_its_name_and_line() -> None:
+ done = run_cli_text("-", "-f", "text", stdin=REFS)
+ assert f":{at(REFS, 'See')}: warning: [render-not-processed] " in done.stderr
+
+
+def test_strict_prints_the_lines_it_refuses_with(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "x.lgd"
+ path.write_text(REFS, encoding="utf-8")
+ assert main([str(path), "--strict"]) == 1
+ err = capsys.readouterr().err
+ assert f"{path}:{at(REFS, 'See')}: error: [path-not-relative] " in err
diff --git a/tests/test_links.py b/tests/test_links.py
index d79107a..c5f4912 100644
--- a/tests/test_links.py
+++ b/tests/test_links.py
@@ -100,7 +100,8 @@ def test_the_validators_own_report_is_not_repeated(monkeypatch: pytest.MonkeyPat
def validate(document, **kwargs): # type: ignore[no-untyped-def]
result = real_validate(document, **kwargs)
- found = Diagnostic(rule="path-not-relative", level="error", line=5,
+ # Line 6 is the paragraph's: a report at another line is another finding.
+ found = Diagnostic(rule="path-not-relative", level="error", line=6,
message="Image path 'https://x/p.png' is not relative (§2.3).")
result.diagnostics.append(found)
return result
@@ -109,7 +110,7 @@ def validate(document, **kwargs): # type: ignore[no-untyped-def]
result = render(FRONT + "# A\n\n \n")
errors = [d for d in result.diagnostics if d.rule == "path-not-relative"]
assert len(errors) == 2 # the validator's, and ours for the path it did not name
- assert any(d.line == 5 for d in errors)
+ assert all(d.line == 6 for d in errors)
assert any("/other.png" in d.message for d in errors)
From 63683b876f2139e369fff0264367674a11148964 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:07:20 +0000
Subject: [PATCH 041/118] Read the lines of a document once instead of checking
its shape for every one
Document.line_of and SourceMap.key each compare the whole document's shape with
its source (SourceMap.fits) on every call, so asking for every block's line and
every party's key made a render quadratic in the document's size (the 3,000
terms test took 39 s instead of 2 s). The builder now takes Document.layout()
once and reads block, heading and list item lines from it (items in the
validator's pre-order), and the resolver keeps one diagnostics.KeyLines per
document, which checks the fit once. diagnostics.key_line stays for a single
lookup.
Tests: the builder's lines equal Document.line_of for every heading, block and
item of a rich document, and a render of 2,000 paragraphs and 500 party, side
and attachment references calls positions.document_shape at most three times.
---
src/legaldown_render/build.py | 49 +++++++++++--
src/legaldown_render/diagnostics.py | 33 ++++++---
src/legaldown_render/resolve/resolver.py | 26 ++++---
tests/test_diagnostic_lines.py | 93 ++++++++++++++++++++++++
4 files changed, 174 insertions(+), 27 deletions(-)
diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py
index 5bc701a..062668f 100644
--- a/src/legaldown_render/build.py
+++ b/src/legaldown_render/build.py
@@ -28,10 +28,11 @@
import re
import secrets
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator
from dataclasses import dataclass, replace
from functools import cache
from itertools import count
+from typing import Any
from urllib.parse import unquote
from legaldown import Block as ModelBlock
@@ -205,6 +206,14 @@ def __init__(self, document: Document, result: ValidationResult) -> None:
# it takes the line of the quote or the item.
self.at: tuple[int | None, int] | None = None
self.line: int | None = None
+ # The lines of the parts of the document, read once: ``Document.line_of``
+ # checks the whole document against its source on every call. None
+ # without a source, or when the document no longer fits it.
+ self.layout = document.layout()
+ # The lines of the items of the top-level list being built, in the
+ # pre-order the validator numbers them (see list()); None for a list
+ # whose items are not addressable.
+ self.item_lines: list[int] | None = None
nonce = secrets.token_hex(8)
# A sentinel with its wrappers; only a complete one matches, so a
# wrapper character in the source is never taken.
@@ -378,7 +387,7 @@ def blocks(self, blocks: list[ModelBlock], section: int | None, *, markers: bool
placed = self.placed.get((section, index), []) if markers else []
if markers:
self.at = (section, index)
- self.line = self.document.line_of(section, index)
+ self.line = self._block_line(section, index)
built = self.block(block, placed, top_level=top_level)
if built is not None:
out.append(built)
@@ -425,7 +434,37 @@ def list(self, block: ModelBlock, placed: list[PlacedMarker]) -> List | None:
number among all the list's items, as the validator numbers them.
None for a list whose every item is only a comment."""
markers = {marker.item: marker for marker in placed}
- return self._list(block, markers, count())
+ outer = self.item_lines
+ self.item_lines = self._item_lines(*self.at) if self.at else None
+ try:
+ return self._list(block, markers, count())
+ finally:
+ self.item_lines = outer
+
+ # -- lines (§16.9) --------------------------------------------------------
+
+ def _block_line(self, section: int | None, index: int) -> int | None:
+ """The first line of top-level block *index* of the preamble (*section*
+ None) or of a section: what ``Document.line_of`` gives."""
+ if self.layout is None:
+ return None
+ spans = self.layout.preamble if section is None else self.layout.sections[section].blocks
+ return spans[index].start
+
+ def _item_lines(self, section: int | None, index: int) -> list[int] | None:
+ """The marker lines of the items of top-level list *index*, nested items
+ included, in pre-order as ``Document.line_of`` numbers them."""
+ if self.layout is None:
+ return None
+ spans = self.layout.preamble if section is None else self.layout.sections[section].blocks
+
+ def walk(items: Iterable[Any]) -> Iterator[int]:
+ for item in items:
+ yield item.start
+ for inner in item.blocks:
+ yield from walk(inner.items)
+
+ return list(walk(spans[index].items))
def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numbers: Iterator[int]) -> List | None:
"""*block*'s items, numbered from *numbers* in pre-order, to find the
@@ -452,7 +491,7 @@ def _list(self, block: ModelBlock, markers: dict[int | None, PlacedMarker], numb
blocks: list[Block] = []
outer = self.line
# The item's marker line; in a quote an item is not addressable.
- item_line = self.document.line_of(*self.at, number) if self.at else outer
+ item_line = self.item_lines[number] if self.item_lines is not None else outer
self.line = item_line
try:
for child in children:
@@ -520,7 +559,7 @@ def tree(self) -> RenderTree:
identifier=self.result.index.sections[index].identifier,
condition=section.condition,
blocks=self.blocks(section.blocks, index),
- line=document.line_of(index),
+ line=None if self.layout is None else self.layout.sections[index].heading.start,
)
for index, section in enumerate(document.sections)
)
diff --git a/src/legaldown_render/diagnostics.py b/src/legaldown_render/diagnostics.py
index 916e002..95ce419 100644
--- a/src/legaldown_render/diagnostics.py
+++ b/src/legaldown_render/diagnostics.py
@@ -17,17 +17,30 @@
from legaldown import Diagnostic, Document
+class KeyLines:
+ """The lines of a document's frontmatter keys: ``KeyLines(document)(*path)``
+ is the line in the document's file of the value at *path*
+ (``("attachments", 0, "file")``), or of the nearest key above it that is
+ written, as ``SourceMap.key`` says; None when the document has no source to
+ name a line of (it was built from a dict, or changed since it was parsed)
+ or has no frontmatter.
+
+ Whether the document still fits its source map is worked out once, when
+ this is made (``SourceMap.fits`` reads the whole document), so a renderer
+ keeps one for the document it renders and asks it for every reference."""
+
+ def __init__(self, document: Document) -> None:
+ source_map = document.source_map
+ self._map = source_map if source_map is not None and source_map.fits(document) else None
+
+ def __call__(self, *path: Any) -> int | None:
+ line = self._map.key(*path) if self._map is not None else None
+ return line if isinstance(line, int) else None
+
+
def key_line(document: Document, *path: Any) -> int | None:
- """The line in the document's file of the frontmatter value at *path*
- (``key_line(document, "attachments", 0, "file")``), or of the nearest key
- above it that is written, as ``SourceMap.key`` says; None when the document
- has no source to name a line of (it was built from a dict, or changed since
- it was parsed) or has no frontmatter."""
- source_map = document.source_map
- if source_map is None or not source_map.fits(document):
- return None
- line = source_map.key(*path)
- return line if isinstance(line, int) else None
+ """``KeyLines(document)(*path)``, for a single lookup."""
+ return KeyLines(document)(*path)
def repeats(found: Diagnostic, known: Iterable[Diagnostic]) -> bool:
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index b94e67b..c8d1802 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -25,7 +25,7 @@
Every one of them names the file and the line it is at (§16.9): the first line
of the block, list item or heading being resolved, or the line of a frontmatter
-key (``diagnostics.key_line``), tracked in :attr:`Resolver.line`. A finding in
+key (``diagnostics.KeyLines``), tracked in :attr:`Resolver.line`. A finding in
a term's text is at the term's definition, since that is where the text is
worked out whatever reference asked for it first.
"""
@@ -57,7 +57,7 @@
from legaldown.syntax import Directive
from ..build import plain_inlines
-from ..diagnostics import covered_by, key_line, repeats
+from ..diagnostics import KeyLines, covered_by, repeats
from ..style import Style, effective_labels
from ..style.model import LevelFormat
from ..tree import (
@@ -195,6 +195,8 @@ def __init__(
# The line (§16.9) of what is being resolved, which a diagnostic
# names: see _at.
self.line: int | None = None
+ # The lines of frontmatter keys (once the document is checked against its source).
+ self.key_line = KeyLines(document)
# A definition id -> the line of its first {{def:}} in the body (see
# _survey), where its term's text is worked out.
self.term_lines: dict[str, int | None] = {}
@@ -251,9 +253,9 @@ def run(self) -> RenderTree:
preamble = tuple(self._resolve_block(block) for block in preamble)
sections = tuple(self._resolve_section(section) for section in sections)
attachments = tuple(self._resolve_attachment(index, a) for index, a in enumerate(attachments))
- with self._at(key_line(self.document, "title")):
+ with self._at(self.key_line("title")):
title = resolve(self.tree.title)
- with self._at(key_line(self.document, "subtitle")):
+ with self._at(self.key_line("subtitle")):
subtitle = resolve(self.tree.subtitle)
resolved = replace(
self.tree,
@@ -294,7 +296,7 @@ def _resolve_section(self, section: Section) -> Section:
)
def _resolve_attachment(self, index: int, attachment: AttachmentPart) -> AttachmentPart:
- with self._at(key_line(self.document, "attachments", index, "title")):
+ with self._at(self.key_line("attachments", index, "title")):
return replace(attachment, title=self._resolve_inlines(attachment.title))
def _contents(
@@ -1004,7 +1006,7 @@ def _plain_value(self, text: str, *key: Any) -> str:
in the body): what it reports is at that key, not where it is shown."""
if "{{" not in text:
return text
- with self._at(key_line(self.document, *key)) if key else nullcontext():
+ with self._at(self.key_line(*key)) if key else nullcontext():
return plain_text(self._resolve_inlines(plain_inlines(text)))
def _frontmatter(self, text: str) -> tuple[Inline, ...]:
@@ -1014,10 +1016,10 @@ def _header(self) -> tuple[tuple[str, tuple[Inline, ...]], ...]:
rows: list[tuple[str, tuple[Inline, ...]]] = []
metadata = self.metadata
if self.style.title_block.effective_date and metadata.effective_date:
- with self._at(key_line(self.document, "effective_date")):
+ with self._at(self.key_line("effective_date")):
rows.append((self.labels.effective_date or "", self._date_or_text(metadata.effective_date)))
if self.style.title_block.version and metadata.version:
- with self._at(key_line(self.document, "version")):
+ with self._at(self.key_line("version")):
rows.append((self.labels.version or "", self._frontmatter(metadata.version)))
return tuple(rows)
@@ -1032,12 +1034,12 @@ def _sides(self) -> tuple[SideInfo, ...]:
parties = tuple(self._party_info(side_index, party_index, party)
for party_index, party in enumerate(side.parties))
label = self.result.index.side_lookup.get(side.name) or side.label or side.name
- with self._at(key_line(self.document, "sides", side_index, "label")):
+ with self._at(self.key_line("sides", side_index, "label")):
sides.append(SideInfo(self._frontmatter(label), parties))
return tuple(sides)
def _party_info(self, side_index: int, party_index: int, party: Party) -> PartyInfo:
- with self._at(key_line(self.document, "sides", side_index, "parties", party_index)):
+ with self._at(self.key_line("sides", side_index, "parties", party_index)):
details: list[tuple[str, tuple[Inline, ...]]] = []
if party.identification_number:
details.append((self.labels.identification_number or "", self._frontmatter(party.identification_number)))
@@ -1081,10 +1083,10 @@ def _signatures(self) -> tuple[SignatureParty, ...]:
return ()
blocks: list[SignatureParty] = []
for side_index, side in enumerate(self.metadata.sides):
- with self._at(key_line(self.document, "sides", side_index, "label")):
+ with self._at(self.key_line("sides", side_index, "label")):
label = self._frontmatter(self.result.index.side_lookup.get(side.name) or side.label or side.name)
for party_index, party in enumerate(side.parties):
- with self._at(key_line(self.document, "sides", side_index, "parties", party_index)):
+ with self._at(self.key_line("sides", side_index, "parties", party_index)):
# Where signature blocks are generated, legal_name MUST appear (§3.6).
blocks.append(SignatureParty(
side=label,
diff --git a/tests/test_diagnostic_lines.py b/tests/test_diagnostic_lines.py
index ff7bb51..6c52077 100644
--- a/tests/test_diagnostic_lines.py
+++ b/tests/test_diagnostic_lines.py
@@ -456,3 +456,96 @@ def test_strict_prints_the_lines_it_refuses_with(tmp_path: Path, capsys: pytest.
assert main([str(path), "--strict"]) == 1
err = capsys.readouterr().err
assert f"{path}:{at(REFS, 'See')}: error: [path-not-relative] " in err
+
+
+# -- The cost of lines ---------------------------------------------------------------------
+
+RICH = HEAD + """
+Preamble one.
+
+- pre item
+ - pre nested
+
+# One {#one}
+
+Para.
+
+1. first
+ - nested a
+ - deeper
+ - nested b
+
+ second block of nested b
+2. second
+
+> quote
+>
+> - in a quote
+
+| a | b |
+|---|---|
+| x | y |
+
+```
+code
+```
+
+---
+
+## Two
+
+- x
+- y
+ 1. z
+"""
+
+
+def test_the_builders_lines_are_those_of_line_of() -> None:
+ from legaldown import parse, validate
+ from legaldown.models import LIST_KINDS
+
+ from legaldown_render.build import build_tree
+
+ document = parse(RICH)
+ tree = build_tree(document, validate(document))
+
+ def items(block): # type: ignore[no-untyped-def]
+ for item in block.items:
+ yield item
+ for child in item.blocks:
+ if hasattr(child, "items"):
+ yield from items(child)
+
+ for section_index, (blocks, built) in enumerate(
+ [(document.preamble, tree.preamble)] + [(s.blocks, t.blocks) for s, t in
+ zip(document.sections, tree.sections, strict=True)]):
+ owner = None if section_index == 0 else section_index - 1
+ if owner is not None:
+ assert tree.sections[owner].line == document.line_of(owner)
+ assert len(blocks) == len(built)
+ for index, (block, node) in enumerate(zip(blocks, built, strict=True)):
+ if block.kind in LIST_KINDS:
+ found = [item.line for item in items(node)]
+ assert found == [document.line_of(owner, index, n) for n in range(len(found))]
+ elif block.kind != "rule": # a rule has no line: nothing reports on it
+ assert node.line == document.line_of(owner, index), (owner, index, block.kind)
+
+
+def test_a_render_checks_the_document_against_its_source_a_constant_number_of_times(
+ monkeypatch: pytest.MonkeyPatch) -> None:
+ # Document.line_of and SourceMap.key each re-read the whole document's shape
+ # (positions.document_shape) to see it still fits its source, so asking for
+ # every block's line, or every party's key, one by one is quadratic.
+ import legaldown.positions as positions
+
+ calls = []
+ real = positions.document_shape
+ monkeypatch.setattr(positions, "document_shape", lambda document: (calls.append(1), real(document))[1])
+ front = ("---\ntitle: T\nsides:\n - name: a\n parties:\n - name: p\n legal_name: P\n"
+ " type: legal_entity\n - name: b\n parties:\n - name: q\n legal_name: Q\n"
+ " type: legal_entity\nattachments:\n - id: x\n title: X\n---\n")
+ body = "# A\n\n" + "\n\n".join(f"Paragraph {i}." for i in range(2000))
+ body += "\n\n- one\n- two\n - three\n\n" + " ".join("{{party: p}} {{side: b}} {{attach: x}}" for _ in range(500))
+ result = render(front + body + "\n", format="text")
+ assert result.output
+ assert len(calls) <= 3, len(calls)
From 15e4bd0f3cbab1445d347a0f5cfdbba882d0dbdb Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:18:17 +0000
Subject: [PATCH 042/118] =?UTF-8?q?Check=20every=20file=20reference=20agai?=
=?UTF-8?q?nst=20the=20document=20root=20(=C2=A72.3)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Include paths, attachments[].file, amends.file, supersedes.file, translations
values and image paths are now all checked, lexically (no resolving, no file
reads): path-not-relative for a URI scheme, drive or absolute path, and
path-outside-root when a document root is configured, reported at the block's
line or the frontmatter key's. Without a root nothing changes for ../ paths.
RenderOptions gains document_root and document_path (render_file and the
command set the path; text and standard input sit at the root; a document
outside the root is an OptionsError), and the command gains --root DIR.
Percent-encoding is decoded only for what is used as a URL (an image's src,
the link an {{attach:}} makes); a backslash is read as a separator. A failing
image shows its alt text, an {{attach:}} its text with no link; include
markers and attachment paths stay text. supersedes as a plain string is a
description, not a path (§3.2), so it is not checked. No existing test changed.
---
src/legaldown_render/api.py | 63 ++-
src/legaldown_render/cli.py | 14 +-
src/legaldown_render/resolve/resolver.py | 144 +++++--
src/legaldown_render/urls.py | 64 ++-
tests/test_file_references.py | 506 +++++++++++++++++++++++
5 files changed, 734 insertions(+), 57 deletions(-)
create mode 100644 tests/test_file_references.py
diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py
index 9d14c66..aec6883 100644
--- a/src/legaldown_render/api.py
+++ b/src/legaldown_render/api.py
@@ -12,9 +12,10 @@
"""
from __future__ import annotations
+import os
from collections.abc import Mapping
from dataclasses import dataclass, field, replace
-from pathlib import Path
+from pathlib import Path, PurePath
from typing import Any
import yaml
@@ -66,6 +67,23 @@ class RenderOptions:
#: With ``answers``, the lines of the diagnostics are those of the
#: assembled document (§15.8), not of the template file.
filename: str = ""
+ #: The document root (§2.3): a boundary directory that no file reference of
+ #: the document (an ``{{include:}}``, ``attachments[].file``,
+ #: ``amends.file``, ``supersedes.file``, a ``translations`` value, an image)
+ #: may leave. A path that does is reported as ``path-outside-root``, an
+ #: Error, and never used. None (the default) configures no root, as §2.3
+ #: defines none: ``../`` is then valid, only a path that is not relative is
+ #: reported. A renderer for documents it does not control SHOULD set one
+ #: (§2.3). The check is lexical: nothing is read or resolved, and a symbolic
+ #: link is not followed.
+ document_root: str | Path | None = None
+ #: Where the document is, to say where in the root it sits: the base of a
+ #: path is the document's directory. Used only with ``document_root``,
+ #: and without it the document is at the root, as text given to :func:`render`
+ #: is. ``render_file`` and the command line set it from the path they read,
+ #: and it names the file of the diagnostics when ``filename`` is empty. A
+ #: document outside the root is an :class:`OptionsError`.
+ document_path: str | Path | None = None
@dataclass(frozen=True, slots=True)
@@ -95,8 +113,8 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
Problems in the document never raise: they render as failure markers and
are listed in ``diagnostics``. Raises :class:`StyleError` for an invalid
- style or setting, :class:`OptionsError` for an unknown output format,
- :class:`DocumentError` for a document that cannot be
+ style or setting, :class:`OptionsError` for an unknown output format or a
+ document outside its document root, :class:`DocumentError` for a document that cannot be
read, and :class:`RenderRefused` in strict mode when the document has
errors, or when a template cannot be assembled with ``answers``.
"""
@@ -106,6 +124,9 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
raise TypeError("pass either a RenderOptions or keyword settings, not both")
if options.format not in FORMATS:
raise OptionsError(f"unknown output format '{options.format}' (known: {', '.join(FORMATS)})")
+ base = _document_base(options)
+ if options.document_path is not None and not options.filename:
+ options = replace(options, filename=PurePath(options.document_path).name)
overrides = dict(options.overrides)
if options.locale:
overrides["locale"] = options.locale
@@ -121,7 +142,7 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any
if options.strict and any(d.level == "error" for d in diagnostics):
raise RenderRefused(diagnostics)
- tree, extra = _resolve(document, result, style)
+ tree, extra = _resolve(document, result, style, base)
# The renderer's findings that the validator's or the assembly's already
# name (the same rule, level and message), wherever they are, are not repeated.
diagnostics += [d for d in extra if not repeats(d, diagnostics)]
@@ -149,9 +170,38 @@ def render_file(path: str | Path, options: RenderOptions | None = None, /, **set
settings = {}
if not options.filename and not settings:
options = replace(options, filename=Path(path).name)
+ if options.document_path is None and not settings:
+ options = replace(options, document_path=path)
return render(source, options, **settings)
+def _document_base(options: RenderOptions) -> str | None:
+ """The directory of the document relative to the document root, ``/``-separated
+ (``"."`` at the root), or None when no root is configured (§2.3). Lexical:
+ the paths are made absolute against the working directory and compared as
+ text, with no file system access. Raises OptionsError for an empty root or
+ path, and for a document that is not inside the root (or, on Windows, is on
+ another drive)."""
+ if options.document_root is None:
+ return None
+ if os.fspath(options.document_root) == "":
+ raise OptionsError("document_root is empty: name the directory file references may not leave")
+ if options.document_path is not None and os.fspath(options.document_path) == "":
+ raise OptionsError("document_path is empty: name the document's file, or leave it out")
+ root = os.path.abspath(options.document_root)
+ directory = root if options.document_path is None else os.path.dirname(os.path.abspath(options.document_path))
+ try:
+ relative = os.path.relpath(directory, root)
+ except ValueError: # Windows: another drive
+ raise OptionsError(f"the document {options.document_path} is outside the document root "
+ f"{options.document_root}: it is on another drive") from None
+ parts = PurePath(relative).parts
+ if parts and parts[0] == "..":
+ raise OptionsError(f"the document {options.document_path} is outside the document root "
+ f"{options.document_root}: the root must contain the document")
+ return "/".join(parts) or "."
+
+
def _assemble(source: str, answers: Mapping[str, Any], filename: str = "") -> tuple[str, list[Diagnostic]]:
"""*source* assembled with *answers* (§15.7), and the assembly's
Warnings (such as ``answer-unknown``). Raises RenderRefused when it
@@ -184,7 +234,8 @@ def _parse(source: str, filename: str = "") -> Document:
raise DocumentError(f"The document cannot be read: {error}") from error
-def _resolve(document: Document, result: ValidationResult, style: Style) -> tuple[RenderTree, list[Diagnostic]]:
+def _resolve(document: Document, result: ValidationResult, style: Style,
+ base: str | None) -> tuple[RenderTree, list[Diagnostic]]:
diagnostics: list[Diagnostic] = []
# Raw HTML is never emitted; the validator reports it (raw-html, §8.7).
tree = build_tree(document, result)
@@ -207,7 +258,7 @@ def _resolve(document: Document, result: ValidationResult, style: Style) -> tupl
))
locale = parse_locale("en")
assert locale is not None
- resolved, found = resolve_tree(tree, document, result, style, Formatter(locale, style.values))
+ resolved, found = resolve_tree(tree, document, result, style, Formatter(locale, style.values), base)
return resolved, diagnostics + found
diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py
index a24a419..7a9db4a 100644
--- a/src/legaldown_render/cli.py
+++ b/src/legaldown_render/cli.py
@@ -3,7 +3,7 @@
Exit codes: 0 — rendered (document errors, if any, are shown as markers and
listed on stderr); 1 — not rendered: refused under ``--strict``, or the
document cannot be read (including input that is not UTF-8) or the output cannot
-be written; 2 — a usage or style problem; 70 — an internal error (a bug to
+be written; 2 — a usage, style or document-root problem; 70 — an internal error (a bug to
report).
Documents and output are always UTF-8 (the HTML says ````),
@@ -22,7 +22,7 @@
from . import __version__
from .api import RenderOptions, render
-from .errors import DocumentError, InternalError, RenderRefused, StyleError
+from .errors import DocumentError, InternalError, OptionsError, RenderRefused, StyleError
from .style import builtin_styles, dump_style, load_style, parse_override
from .urls import printable
from .writers import FORMATS, file_extension, format_for_path
@@ -89,6 +89,11 @@ def _parser() -> argparse.ArgumentParser:
"written; one pair of matching outer quotes is removed (wrap the value in the "
"other kind of quote to keep quotation marks). Others read it as YAML, e.g. true or 2")
parser.add_argument("--locale", help="formatting locale, e.g. en-US or cs-CZ (default: the style's, else the document language)")
+ parser.add_argument("--root", metavar="DIR",
+ help="the document root (§2.3): a file reference (an include, an attachment, amends, "
+ "supersedes, a translation, an image) that leaves DIR is an error "
+ "(path-outside-root), and the file is not used. The document must be inside DIR "
+ "(standard input counts as at DIR itself). Without it, ../ is allowed")
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",
@@ -193,10 +198,11 @@ def main(argv: list[str] | None = None) -> int:
options = RenderOptions(format=output_format, style=args.style, overrides=overrides,
strict=args.strict, final=args.final, answers=answers, standalone=not args.fragment,
- filename="" if args.input == "-" else Path(args.input).name)
+ filename="" if args.input == "-" else Path(args.input).name,
+ document_root=args.root, document_path=None if args.input == "-" else args.input)
try:
result = render(source, options)
- except StyleError as error:
+ except (StyleError, OptionsError) as error:
_complain(f"legaldown-render: {error}")
return 2
except RenderRefused as error:
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index c8d1802..1783bed 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -20,8 +20,13 @@
``render-link-refused`` for a link whose URL scheme is not one links may use
(§8.7), ``render-term-too-long`` for a term whose text is longer than a
``{{term:}}`` may show, ``render-term-limit`` for terms that refer to each other too deeply or too often,
-and ``path-not-relative`` for an image whose path is not relative (§2.3), which
-the validator does not check yet.
+and the two file-reference rules of §2.3, which the validator does not check yet:
+``path-not-relative`` for a path with a URI scheme or an absolute one, and
+``path-outside-root`` for one that leaves the document root, when a root is
+configured. They apply to every file reference: an ``{{include:}}``,
+``attachments[].file``, ``amends.file``, ``supersedes.file``, a ``translations``
+value, and an image. The files are never read at this level; what the renderer
+does with a path that fails is in :meth:`Resolver._reference`.
Every one of them names the file and the line it is at (§16.9): the first line
of the block, list item or heading being resolved, or the line of a frontmatter
@@ -38,7 +43,7 @@
from typing import Any, NamedTuple
from urllib.parse import unquote
-from legaldown import Diagnostic, Document, Party, ValidationResult
+from legaldown import Amends, Diagnostic, Document, Party, ValidationResult
from legaldown.grammar import (
ALWAYS,
DECISION_QUESTION_TYPES,
@@ -97,13 +102,20 @@
plain_text,
unlinked,
)
-from ..urls import is_empty_path, is_link_target, is_relative_path, printable
+from ..urls import file_reference_problem, is_empty_path, is_link_target, printable
from .numbering import RENUMBERED, extend, fill, format_counter, heading_levels
from .values import DURATION_UNITS, Formatter
#: Definition anchors are prefixed with a character no identifier can hold,
#: so they can never collide with a section or item anchor (§5.6).
DEFINITION_ANCHOR_PREFIX = "def:"
+#: What the renderer does with a file reference that fails §2.3 (the rest of
+#: a message of ``path-not-relative`` or ``path-outside-root``). An attachment's
+#: path is shown as the placeholder's text and is not linked to, wherever it is
+#: reported from (the frontmatter, a ``{{attach:}}``), so that it is one message.
+_NEVER_READ = "It is never read."
+_ATTACHMENT_EFFECT = "It is shown as text, not linked to, and never read."
+
#: The attachments heading's anchor, with a colon for the same reason.
ATTACHMENTS_ANCHOR = "ld:attachments"
@@ -182,8 +194,12 @@ def __init__(
result: ValidationResult,
style: Style,
formatter: Formatter,
+ base: str | None = None,
) -> None:
self.tree = tree
+ # The directory of the document relative to the document root (§2.3),
+ # or None when there is no root: the containment rule is then not checked.
+ self.base = base
self.document = document
self.metadata = document.metadata
self.result = result
@@ -238,6 +254,7 @@ def __init__(
# -- entry point ----------------------------------------------------------
def run(self) -> RenderTree:
+ self._references()
self._survey()
attachments = self._attachments()
sections = self._number_sections()
@@ -651,12 +668,15 @@ def _resolve_inline(self, inline: Inline) -> Inline | tuple[Inline, ...] | None:
# No path, no picture: would request the page
# itself. §2.3 says nothing of an empty path, so no rule fires.
return alt
- if not is_relative_path(src):
- # §2.3: an image path is relative and has no URI scheme, so a
- # remote or absolute image is never fetched or embedded. Its
- # alt text stands in; the validator (0.4) does not check
- # image paths, so the renderer reports the Core rule.
- self._not_relative(src)
+ # markdown-it has percent-encoded the path ("C:\\x" reads
+ # "C:%5Cx"); it is read as the URL it is, and shown as written.
+ if self._reference("Image path", src, url=True, shown=unquote(src),
+ effect="Its alt text is shown instead."):
+ # §2.3: an image path is relative and within the document
+ # root, so a remote, absolute or outside image is never
+ # fetched or embedded. Its alt text stands in; the validator
+ # (0.4) does not check file references, so the renderer
+ # reports the Core rule.
return alt
return replace(inline, children=alt, title=self._resolve_inlines(_attribute_text(title)))
case _:
@@ -959,13 +979,20 @@ def _attach(self, directive: Directive, attachment_id: str) -> Inline:
if title is None:
return FailureMarker(f"[UNKNOWN ATTACHMENT: {attachment_id}]")
anchor = self.attachment_anchors.get(attachment_id)
+ index = next((i for i, a in enumerate(self.metadata.attachments) if a.id == attachment_id), None)
+ key = ("attachments", index, "title") if index is not None else ()
if anchor is not None:
href = "#" + anchor
else:
- declared = next((a for a in self.metadata.attachments if a.id == attachment_id), None)
- href = declared.file if declared and declared.file else None
- key = next((("attachments", i, "title") for i, a in enumerate(self.metadata.attachments)
- if a.id == attachment_id), ())
+ file = self.metadata.attachments[index].file if index is not None else ""
+ href = file or None
+ # A link to the file is a URL: its path is read as one (%2e%2e is
+ # "..") and must be relative and within the root (§2.3), or the
+ # attachment's title is text. A path with the same problem as
+ # written is the same finding, reported once (see _references).
+ if href and self._reference(self._attachment_label(attachment_id), href, url=True,
+ effect=_ATTACHMENT_EFFECT):
+ href = None
return Value("attach", directive.params.get("label") or self._plain_value(title, *key), href)
def _choose(self, directive: Directive, question_id: str) -> Inline:
@@ -986,6 +1013,9 @@ def _choose(self, directive: Directive, question_id: str) -> Inline:
return Choice(question_id, options, self.style.template_view.choice_separator)
def _include(self, directive: Directive, path: str) -> Inline:
+ # The path is a file reference (§2.3) whether or not the file is read:
+ # the marker shows it as text, never as something to fetch.
+ self._reference("Include path", path, effect="It is shown as text and never read.")
# Includes are a Full-level capability (§17.4): never dropped silently.
self._warn(
"render-not-processed",
@@ -1124,26 +1154,65 @@ def _link_refused(self, href: str) -> None:
f"sips, sms, urn, geo, news and relative links are; its text is shown.",
)
- def _not_relative(self, path: str) -> None:
- """Report an image *path* that is not relative as the Core Error
- ``path-not-relative`` (§2.3, §16.2), unless the validator has
- reported it (:func:`~legaldown_render.diagnostics.covered_by`: its
- report of the path, at this line). markdown-it has percent-encoded
- the path ("C:\\x" reads "C:%5Cx"), so it is decoded to name the path
- as written."""
- path = unquote(path)
- shown = printable(path)
- diagnostic = self._found(
- "path-not-relative", "error",
- f"Image path '{shown}' is not relative (§2.3): file references are relative paths with no "
- f"URI scheme. Its alt text is shown instead.",
- )
- # Assumption: the validator quotes the path in its message as '',
- # as written. Matching the quoted form keeps a path that is a prefix of
- # another ("/a" and "/ab") from being counted as already reported.
- if covered_by(diagnostic, path, self.result.diagnostics) or repeats(diagnostic, self.diagnostics):
- return
- self.diagnostics.append(diagnostic)
+ def _reference(self, label: str, path: str, *, effect: str, url: bool = False,
+ shown: str | None = None) -> bool:
+ """Check *path*, a file reference of the document (§2.3; *label* says
+ which, e.g. "Include path"): True if it is not relative or leaves the
+ document root, which is reported as the Core Error ``path-not-relative``
+ or ``path-outside-root`` (§16.2), with *effect* saying what the renderer
+ does instead (it never reads the file). *url* says the path is read as
+ a URL (see :func:`~legaldown_render.urls.file_reference_problem`);
+ *shown* is the path as the document wrote it, if that is not *path*.
+
+ A report is left out when the validator made it already
+ (:func:`~legaldown_render.diagnostics.covered_by`: its report of the
+ path, at this line) or this resolver did (the same message). The
+ validator quotes the path in its message as '', as written;
+ matching the quoted form keeps a path that is a prefix of another
+ ("/a" and "/ab") from being counted as already reported."""
+ problem = file_reference_problem(path, base=self.base, url=url)
+ if problem is None:
+ return False
+ shown = path if shown is None else shown
+ if problem == "not-relative":
+ rule, what = "path-not-relative", ("is not relative (§2.3): file references are relative paths "
+ "with no URI scheme.")
+ else:
+ rule, what = "path-outside-root", ("is outside the document root (§2.3): a file reference may "
+ "not leave the root.")
+ diagnostic = self._found(rule, "error", f"{label} '{printable(shown)}' {what} {effect}")
+ if not (covered_by(diagnostic, shown, self.result.diagnostics) or repeats(diagnostic, self.diagnostics)):
+ self.diagnostics.append(diagnostic)
+ return True
+
+ def _attachment_label(self, attachment_id: str) -> str:
+ return f"Attachment '{printable(attachment_id)}' file path"
+
+ def _references(self) -> None:
+ """Check the file references in the frontmatter (§2.3): each
+ ``attachments[].file``, ``amends.file``, ``supersedes.file``, and
+ ``translations`` value, at the line of its key. They are shown, never
+ read: an attachment's path is the placeholder's text, and the
+ amended and translated documents are not loaded below Full (§17.4).
+ ``supersedes`` as a plain string describes the superseded document
+ (§3.2) and is not a path."""
+ metadata = self.metadata
+ references: list[tuple[str, str, tuple[Any, ...], str]] = []
+ for index, attachment in enumerate(metadata.attachments):
+ references.append((self._attachment_label(attachment.id), attachment.file,
+ ("attachments", index, "file"), _ATTACHMENT_EFFECT))
+ if metadata.amends is not None:
+ references.append(("Amended document path", metadata.amends.file, ("amends", "file"), _NEVER_READ))
+ if isinstance(metadata.supersedes, Amends):
+ references.append(("Superseded document path", metadata.supersedes.file, ("supersedes", "file"),
+ _NEVER_READ))
+ for language, path in metadata.translations.items():
+ references.append((f"Translation '{printable(language)}' path", path, ("translations", language),
+ _NEVER_READ))
+ for label, path, key, effect in references:
+ if path:
+ with self._at(self.key_line(*key)):
+ self._reference(label, path, effect=effect)
_HANDLERS: dict[str, Callable[[Resolver, Directive, str], Inline | None]] = {
@@ -1246,9 +1315,12 @@ def resolve_tree(
result: ValidationResult,
style: Style,
formatter: Formatter,
+ base: str | None = None,
) -> tuple[RenderTree, list[Diagnostic]]:
- """The resolved tree, and the diagnostics only rendering can produce."""
- resolver = Resolver(tree, document, result, style, formatter)
+ """The resolved tree, and the diagnostics only rendering can produce.
+ *base* is the document's directory relative to the document root (§2.3),
+ or None when no root is configured."""
+ resolver = Resolver(tree, document, result, style, formatter, base)
resolved = resolver.run()
return resolved, resolver.diagnostics
diff --git a/src/legaldown_render/urls.py b/src/legaldown_render/urls.py
index 6b89a8f..a6f7c32 100644
--- a/src/legaldown_render/urls.py
+++ b/src/legaldown_render/urls.py
@@ -1,6 +1,6 @@
"""Reading URLs the way the renderer needs to: which schemes a link may use
-(§8.7), whether an image path is a relative one (§2.3), and how to quote one in
-a message.
+(§8.7), whether a file reference is a relative path within the document root
+(§2.3), and how to quote one in a message.
The checks are syntactic, like the validator's (§2.3). They are shared by the
resolver, which decides what an image is, and by both writers, which decide
@@ -93,13 +93,55 @@ def is_empty_path(path: str) -> bool:
return not _as_read(unquote(path))
+def file_reference_problem(path: str, *, base: str | None = None, url: bool = False) -> str | None:
+ """What is wrong with *path* as a file reference (§2.3), or None:
+
+ * ``"not-relative"`` (``path-not-relative``): it has a URI scheme
+ (``https://``, ``file://``, ``data:``), which includes a Windows drive
+ path (``C:\\x``, ``C:/x``, a scheme as far as a parser can tell), or
+ starts with ``/`` or ``\\`` (so also ``//host``);
+ * ``"outside-root"`` (``path-outside-root``): there is a document root
+ (*base* is not None) and the path leaves it.
+
+ *base* is the directory of the document relative to the root, ``/``-separated
+ (``"."`` for a document at the root, ``"contracts/2025"``), or None where no
+ root is configured: §2.3 defines no default root, so ``../shared/a.lgd`` is
+ then valid. Both checks are lexical, as §2.3 says: nothing is resolved or
+ read, so a symbolic link is not followed, and a path that does not exist is
+ as good as any. An empty path names nothing and has no problem.
+
+ How the path is read: a backslash is a separator (the renderer's reading,
+ so that a path that goes up on Windows is not missed; it matters only with a
+ root, a leading one is already not relative), and so are ``.. `` and ``. ``
+ with trailing spaces, which Windows reads as ``..`` and ``.``. What a
+ browser ignores before the scheme (tabs, newlines, control characters and
+ spaces at either end) is ignored. *url* says the path is consumed as a URL
+ (an image's source, a link's target), where percent-encoding counts as the
+ character it encodes (``%2e%2e`` is ``..``, ``%2f`` a separator), once; a
+ path from frontmatter or an ``{{include:}}`` is a file name and is read as
+ written, in which ``%2e%2e`` is a directory with that name."""
+ read = _as_read(unquote(path) if url else path)
+ if not read:
+ return None
+ if _SCHEME_RE.match(read) or read[0] in "/\\":
+ return "not-relative"
+ if base is None:
+ return None
+ depth = 0 if base == "." else len([part for part in base.split("/") if part])
+ for part in read.replace("\\", "/").split("/"):
+ part = part.rstrip(" ")
+ if part == "..":
+ depth -= 1
+ if depth < 0:
+ return "outside-root"
+ elif part not in ("", "."):
+ depth += 1
+ return None
+
+
def is_relative_path(path: str) -> bool:
- """True if *path*, an image's, is a relative path (§2.3). Not relative: a
- path with a URI scheme (``https://``, ``file://``, ``data:``), which
- includes a Windows drive path (``C:\\x``, ``C:/x``, a scheme as far as a
- parser can tell), and one that starts with ``/`` or ``\\`` (so also
- ``//host``). A leading ``../`` is relative: §2.3 defines no default
- document root. A percent-encoded character counts as the character, since
- a path may be encoded by the time it is read."""
- read = _as_read(unquote(path))
- return not read or (_SCHEME_RE.match(read) is None and read[0] not in "/\\")
+ """True if *path*, an image's, is a relative path (§2.3): see
+ :func:`file_reference_problem`, which also reads a path as a URL. A leading
+ ``../`` is relative here: it is the containment rule, with a document root
+ configured, that keeps a path within it."""
+ return file_reference_problem(path, url=True) != "not-relative"
diff --git a/tests/test_file_references.py b/tests/test_file_references.py
new file mode 100644
index 0000000..0af5ba8
--- /dev/null
+++ b/tests/test_file_references.py
@@ -0,0 +1,506 @@
+"""File references (§2.3): every path a document uses to name another file (an
+``{{include:}}``, ``attachments[].file``, ``amends.file``, ``supersedes.file``, a
+``translations`` value, an image) must be relative (``path-not-relative``) and,
+when a document root is configured, stay within it (``path-outside-root``); the
+checks are lexical; what the renderer does with a path that fails; the options
+and the ``--root`` setting that configure the root; the lines of the findings."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+from conftest import run_cli_text
+
+import legaldown_render.api as api
+from legaldown_render import OptionsError, RenderOptions, RenderRefused, render, render_file
+from legaldown_render.cli import main
+from legaldown_render.urls import file_reference_problem
+
+NOT_RELATIVE = "path-not-relative"
+OUTSIDE = "path-outside-root"
+RULES = (NOT_RELATIVE, OUTSIDE)
+
+# -- The check itself ------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(("path", "base", "url", "expected"), [
+ ("a/b.lgd", None, False, None),
+ ("", None, False, None),
+ ("../x.lgd", None, False, None),
+ ("../x.lgd", ".", False, "outside-root"),
+ ("../x.lgd", "sub", False, None),
+ ("../../x.lgd", "sub", False, "outside-root"),
+ ("a/../x.lgd", ".", False, None),
+ ("a/../../x.lgd", ".", False, "outside-root"),
+ ("./a/./b", ".", False, None),
+ ("a//b", ".", False, None),
+ ("..", ".", False, "outside-root"),
+ ("..", "sub", False, None),
+ ("a/b/..", ".", False, None),
+ ("/etc/passwd", ".", False, "not-relative"),
+ ("/etc/passwd", None, False, "not-relative"),
+ ("//host/share", None, False, "not-relative"),
+ ("\\x", None, False, "not-relative"),
+ ("\\\\host\\x", None, False, "not-relative"),
+ ("https://x.example/a", None, False, "not-relative"),
+ ("HTTPS://x.example/a", None, False, "not-relative"),
+ ("file:///etc/passwd", None, False, "not-relative"),
+ ("data:text/plain,x", None, False, "not-relative"),
+ ("C:\\x", None, False, "not-relative"),
+ ("C:/x", None, False, "not-relative"),
+ ("c:x", None, False, "not-relative"),
+ ("ht\ttps://x", None, False, "not-relative"),
+ (" /etc/passwd", None, False, "not-relative"),
+ ("a/b:c", None, False, None),
+ # A backslash is read as a separator.
+ ("a\\b", ".", False, None),
+ ("..\\x", ".", False, "outside-root"),
+ ("..\\x", "sub", False, None),
+ ("a\\..\\..\\x", ".", False, "outside-root"),
+ # Encoding: decoded for a URL, as written for a path.
+ ("%2e%2e/x", ".", False, None),
+ ("%2e%2e/x", ".", True, "outside-root"),
+ ("..%2fx", ".", True, "outside-root"),
+ ("..%5cx", ".", True, "outside-root"),
+ ("%2fetc", None, True, "not-relative"),
+ ("%2fetc", None, False, None),
+ ("%2e%2e/x", "sub", True, None),
+ # One decoding only: %252e is a name, %2e after it.
+ ("%252e%252e/x", ".", True, None),
+ # Spaces after the name are the Windows reading of the same segment.
+ (".. /x", ".", False, "outside-root"),
+])
+def test_the_check(path: str, base: str | None, url: bool, expected: str | None) -> None:
+ assert file_reference_problem(path, base=base, url=url) == expected
+
+
+# -- Each kind of reference ------------------------------------------------------------
+
+
+def _frontmatter(extra: str) -> str:
+ return f"---\ntitle: T\n{extra}---\n\n# A\n\nBody.\n"
+
+
+def _kind_source(kind: str, path: str) -> tuple[str, str]:
+ """A document with *path* in a reference of *kind*, and the text of the
+ line the finding is expected at."""
+ quoted = json.dumps(path)
+ if kind == "include":
+ return _frontmatter("").replace("Body.", f"Before {{{{include: {path}}}}} after."), "Before"
+ if kind == "image":
+ # Markdown reads two backslashes as one.
+ return _frontmatter("").replace("Body.", f"Before , chr(92) * 2)}>) after."), "Before"
+ if kind == "attachment":
+ return _frontmatter(f"attachments:\n - id: annex\n title: Annex\n file: {quoted}\n"), " file:"
+ if kind == "amends":
+ return _frontmatter(f"amends:\n title: Original\n file: {quoted}\n"), " file:"
+ if kind == "supersedes":
+ return _frontmatter(f"supersedes:\n title: Old\n file: {quoted}\n"), " file:"
+ if kind == "translation":
+ return _frontmatter(f"translations:\n cs: {quoted}\n"), " cs:"
+ raise AssertionError(kind)
+
+
+KINDS = ["include", "image", "attachment", "amends", "supersedes", "translation"]
+#: The kinds whose path is read as a URL.
+URL_KINDS = {"image"}
+
+
+def line_of(source: str, needle: str) -> int:
+ return next(n for n, line in enumerate(source.split("\n"), 1) if needle in line)
+
+
+def expected_line(kind: str, path: str) -> int:
+ source, marker = _kind_source(kind, path)
+ return line_of(source, marker)
+
+
+def findings(result) -> list[tuple[str, int | None]]: # type: ignore[no-untyped-def]
+ return [(d.rule, d.line) for d in result.diagnostics if d.rule in RULES]
+
+
+def reported(kind: str, path: str, tmp_path: Path, where: str | None, **settings: object) -> list[tuple[str, int | None]]:
+ """The file-reference findings for *path* in *kind*, with the document root
+ *tmp_path* (``where`` is the document's directory in it, ``""`` for the
+ root itself) or none."""
+ source, _ = _kind_source(kind, path)
+ if where is not None:
+ settings.update(document_root=tmp_path, document_path=tmp_path / where / "doc.lgd")
+ return findings(render(source, **settings))
+
+
+# (path, no root, document at the root, document in the directory "sub")
+COMMON = [
+ ("annex/a.lgd", None, None, None),
+ ("a.lgd", None, None, None),
+ ("../a.lgd", None, OUTSIDE, None),
+ ("a/../a.lgd", None, None, None),
+ ("a/../../a.lgd", None, OUTSIDE, None),
+ ("../../a.lgd", None, OUTSIDE, OUTSIDE),
+ ("../sub/a.lgd", None, OUTSIDE, None),
+ ("/etc/passwd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("//host/share/a.lgd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("https://x.example/a.lgd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("file:///etc/passwd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("C:\\x\\a.lgd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("C:/x/a.lgd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("\\\\host\\share\\a.lgd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ ("\\a.lgd", NOT_RELATIVE, NOT_RELATIVE, NOT_RELATIVE),
+ # A backslash is a separator.
+ ("a\\b.lgd", None, None, None),
+ ("..\\a.lgd", None, OUTSIDE, None),
+ ("..\\..\\a.lgd", None, OUTSIDE, OUTSIDE),
+]
+
+
+@pytest.mark.parametrize("kind", KINDS)
+@pytest.mark.parametrize(("path", "no_root", "at_root", "in_sub"), COMMON, ids=[c[0] for c in COMMON])
+def test_each_kind_of_reference(kind: str, path: str, no_root: str | None, at_root: str | None,
+ in_sub: str | None, tmp_path: Path) -> None:
+ line = expected_line(kind, path)
+ for where, expected in ((None, no_root), ("", at_root), ("sub", in_sub)):
+ found = reported(kind, path, tmp_path, where)
+ assert found == ([(expected, line)] if expected else []), (kind, path, where, found)
+
+
+@pytest.mark.parametrize("kind", KINDS)
+def test_encoded_traversal_is_read_as_a_url_only_where_it_is_one(kind: str, tmp_path: Path) -> None:
+ # A directory may be called "%2e%2e"; an image's path is a URL, in which it is "..".
+ path = "%2e%2e/%2e%2e/a.lgd"
+ expected = [(OUTSIDE, expected_line(kind, path))] if kind in URL_KINDS else []
+ assert reported(kind, path, tmp_path, "") == expected
+ assert reported(kind, path, tmp_path, None) == []
+ # Encoded separators too.
+ encoded = "..%2f..%5ca.lgd"
+ assert reported(kind, encoded, tmp_path, "") == ([(OUTSIDE, expected_line(kind, encoded))]
+ if kind in URL_KINDS else [])
+
+
+@pytest.mark.parametrize("kind", ["include", "image"])
+def test_a_disguised_scheme_is_a_scheme(kind: str, tmp_path: Path) -> None:
+ for path in ("ht\ttps://x.example/a", "%68ttps://x.example/a" if kind in URL_KINDS else "https:%2f%2fx.example"):
+ assert [rule for rule, _ in reported(kind, path, tmp_path, None)] == [NOT_RELATIVE], path
+
+
+# -- What happens to the path ------------------------------------------------------------
+
+
+def test_an_image_outside_the_root_is_its_alt_text(tmp_path: Path) -> None:
+ source = _frontmatter("").replace("Body.", "Before  after.")
+ for fmt in ("html", "text"):
+ result = render(source, format=fmt, document_root=tmp_path, document_path=tmp_path / "doc.lgd")
+ assert " None:
+ source = _frontmatter("").replace("Body.", "{{include: ../../outside/part.lgd}}")
+ for fmt in ("html", "text"):
+ result = render(source, format=fmt, document_root=tmp_path)
+ assert "[NOT PROCESSED: include ../../outside/part.lgd]" in result.output
+ assert {d.rule for d in result.diagnostics} >= {"render-not-processed", OUTSIDE}
+
+
+def test_attachments_keep_their_path_as_text_and_never_link_to_a_bad_one(tmp_path: Path) -> None:
+ front = ("attachments:\n - id: annex\n title: Annex A\n file: ../../secret/annex.lgd\n"
+ " - id: fine\n title: Annex B\n file: annex/b.lgd\n")
+ source = _frontmatter(front).replace("Body.", "See {{attach: annex}} and {{attach: fine}}.")
+ for mode, overrides in (("placeholder", {}), ("omit", {"attachments.render": "omit"})):
+ result = render(source, document_root=tmp_path, overrides=overrides)
+ assert (OUTSIDE, line_of(source, " file: ../../")) in findings(result), mode
+ assert 'href="../../secret' not in result.output
+ assert "See Annex A and" in render(source, document_root=tmp_path, overrides=overrides, format="text").output
+ if mode == "placeholder":
+ # The placeholder shows the path, as text.
+ assert "../../secret/annex.lgd" in result.output
+ omitted = render(source, document_root=tmp_path, overrides={"attachments.render": "omit"})
+ assert "secret" not in omitted.output
+ # A path that is fine is a link; with no root, so is the one that leaves the directory.
+ assert 'Annex B' in omitted.output
+ assert 'href="../../secret/annex.lgd"' in render(source, overrides={"attachments.render": "omit"}).output
+
+
+def test_the_attachment_link_is_read_as_a_url(tmp_path: Path) -> None:
+ # As written the path names a directory called "%2e%2e"; as the link's URL it leaves the root.
+ source = _frontmatter("attachments:\n - id: annex\n title: Annex\n file: '%2e%2e/%2e%2e/a.lgd'\n"
+ ).replace("Body.", "See {{attach: annex}}.")
+ result = render(source, document_root=tmp_path, overrides={"attachments.render": "omit"})
+ assert "href=" not in result.output
+ assert [rule for rule, _ in findings(result)] == [OUTSIDE]
+ assert findings(render(source, document_root=tmp_path)) == []
+ assert findings(render(source, overrides={"attachments.render": "omit"})) == []
+
+
+def test_the_same_attachment_path_is_one_finding(tmp_path: Path) -> None:
+ source = _frontmatter("attachments:\n - id: annex\n title: Annex\n file: /etc/passwd\n"
+ ).replace("Body.", "See {{attach: annex}}.")
+ result = render(source, overrides={"attachments.render": "omit"})
+ assert findings(result) == [(NOT_RELATIVE, line_of(source, " file:"))]
+ assert "href=" not in result.output
+
+
+def test_a_markdown_link_is_not_a_file_reference(tmp_path: Path) -> None:
+ # §8.7 links follow the link allow-list; §2.3 is about references to files.
+ source = _frontmatter("").replace("Body.", "See [the file](/etc/passwd) and [up](../../a.html).")
+ result = render(source, document_root=tmp_path)
+ assert findings(result) == []
+ assert 'the file' in result.output and 'up' in result.output
+
+
+def test_the_same_path_in_two_places_is_one_finding_at_the_first(tmp_path: Path) -> None:
+ source = _frontmatter("").replace("Body.", "One {{include: /x.lgd}}.\n\nTwo {{include: /x.lgd}}.\n\nThree {{include: /y.lgd}}.")
+ assert findings(render(source)) == [(NOT_RELATIVE, line_of(source, "One")), (NOT_RELATIVE, line_of(source, "Three"))]
+
+
+def test_a_path_that_breaks_both_rules_is_not_relative() -> None:
+ assert [rule for rule, _ in findings(render(_frontmatter("").replace("Body.", "{{include: /../x}}"),
+ document_root="."))] == [NOT_RELATIVE]
+
+
+def test_the_validators_report_of_an_include_is_not_repeated(monkeypatch: pytest.MonkeyPatch) -> None:
+ from legaldown import Diagnostic
+ from legaldown import validate as real_validate
+
+ source = _frontmatter("").replace("Body.", "Before {{include: https://x.example/p.lgd}}")
+ line = line_of(source, "Before")
+
+ def validate(document, **kwargs): # type: ignore[no-untyped-def]
+ result = real_validate(document, **kwargs)
+ result.diagnostics.append(Diagnostic(rule=NOT_RELATIVE, level="error", line=line,
+ message="Include path 'https://x.example/p.lgd' is not relative."))
+ return result
+
+ monkeypatch.setattr(api, "validate", validate)
+ assert findings(render(source)) == [(NOT_RELATIVE, line)]
+ assert len([d for d in render(source).diagnostics if d.rule == NOT_RELATIVE]) == 1
+
+
+def test_a_finding_names_its_file(tmp_path: Path) -> None:
+ source, _ = _kind_source("amends", "/etc/passwd")
+ [found] = [d for d in render(source, filename="deal.lgd").diagnostics if d.rule == NOT_RELATIVE]
+ assert found.file == "deal.lgd"
+ assert found.level == "error" and "'/etc/passwd'" in found.message
+
+
+# -- Strict ---------------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("kind", KINDS)
+def test_strict_refuses_a_path_outside_the_root(kind: str, tmp_path: Path) -> None:
+ source, _ = _kind_source(kind, "../../a.lgd")
+ with pytest.raises(RenderRefused) as refused:
+ render(source, strict=True, document_root=tmp_path)
+ assert OUTSIDE in {d.rule for d in refused.value.diagnostics}
+
+
+@pytest.mark.parametrize("kind", [k for k in KINDS if k != "include"])
+def test_strict_without_a_root_accepts_a_path_that_leaves_the_directory(kind: str) -> None:
+ # §2.3 defines no default root. (An include is a warning of its own, not an error.)
+ source, _ = _kind_source(kind, "../../a.lgd")
+ assert render(source, strict=True).ok
+
+
+@pytest.mark.parametrize("kind", KINDS)
+def test_strict_refuses_an_absolute_path(kind: str) -> None:
+ source, _ = _kind_source(kind, "/etc/passwd")
+ with pytest.raises(RenderRefused) as refused:
+ render(source, strict=True)
+ assert NOT_RELATIVE in {d.rule for d in refused.value.diagnostics}
+
+
+# -- The document root --------------------------------------------------------------------
+
+
+def test_the_document_sits_at_the_root_when_it_has_no_path(tmp_path: Path) -> None:
+ source, _ = _kind_source("amends", "../a.lgd")
+ assert [rule for rule, _ in findings(render(source, document_root=tmp_path))] == [OUTSIDE]
+ assert [rule for rule, _ in findings(render(source, document_root=str(tmp_path)))] == [OUTSIDE]
+ assert findings(render(source)) == []
+
+
+def test_a_document_in_a_subdirectory_may_go_up_to_the_root(tmp_path: Path) -> None:
+ source, _ = _kind_source("amends", "../shared/a.lgd")
+ deep = tmp_path / "a" / "b" / "doc.lgd"
+ assert findings(render(source, document_root=tmp_path, document_path=deep)) == []
+ source, _ = _kind_source("amends", "../../../a.lgd")
+ assert [rule for rule, _ in findings(render(source, document_root=tmp_path, document_path=deep))] == [OUTSIDE]
+
+
+def test_the_root_may_be_given_with_dots_and_the_path_relative(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ (tmp_path / "docs" / "sub").mkdir(parents=True)
+ monkeypatch.chdir(tmp_path)
+ source, _ = _kind_source("amends", "../a.lgd")
+ assert findings(render(source, document_root="docs", document_path="docs/sub/doc.lgd")) == []
+ assert findings(render(source, document_root="docs/sub/..", document_path="./docs/sub/doc.lgd")) == []
+ assert [r for r, _ in findings(render(source, document_root="docs", document_path="docs/doc.lgd"))] == [OUTSIDE]
+
+
+def test_a_symbolic_link_is_not_followed(tmp_path: Path) -> None:
+ # The check is lexical: it neither resolves links nor reads anything.
+ outside = tmp_path / "outside"
+ root = tmp_path / "root"
+ outside.mkdir()
+ root.mkdir()
+ try:
+ (root / "link").symlink_to(outside, target_is_directory=True)
+ except (OSError, NotImplementedError):
+ pytest.skip("symbolic links are not available")
+ source, _ = _kind_source("amends", "link/a.lgd")
+ assert findings(render(source, document_root=root)) == []
+
+
+def test_a_root_that_does_not_exist_is_fine(tmp_path: Path) -> None:
+ source, _ = _kind_source("amends", "../a.lgd")
+ assert [r for r, _ in findings(render(source, document_root=tmp_path / "nope"))] == [OUTSIDE]
+
+
+def test_a_document_outside_the_root_is_refused(tmp_path: Path) -> None:
+ root = tmp_path / "root"
+ with pytest.raises(OptionsError, match="outside the document root"):
+ render("# A\n", document_root=root, document_path=tmp_path / "doc.lgd")
+ with pytest.raises(OptionsError):
+ render("# A\n", document_root=root, document_path=root / ".." / "doc.lgd")
+ # A directory whose name starts with dots is not the parent.
+ render("# A\n", document_root=root, document_path=root / "..x" / "doc.lgd")
+ # Both are checked even when the document has nothing to check.
+ with pytest.raises(OptionsError):
+ render("# A\n", document_root=root, document_path=tmp_path / "other" / "doc.lgd", format="text")
+
+
+def test_an_empty_root_or_path_is_refused() -> None:
+ with pytest.raises(OptionsError, match="document_root"):
+ render("# A\n", document_root="")
+ with pytest.raises(OptionsError, match="document_path"):
+ render("# A\n", document_root=".", document_path="")
+
+
+@pytest.mark.skipif(os.name != "nt", reason="drive letters are a Windows matter")
+def test_a_document_on_another_drive_is_refused() -> None: # pragma: no cover - Windows only
+ with pytest.raises(OptionsError, match="drive"):
+ render("# A\n", document_root="C:\\docs", document_path="D:\\docs\\doc.lgd")
+
+
+def test_a_path_without_a_root_is_not_checked_for_the_document(tmp_path: Path) -> None:
+ # document_path alone configures no root.
+ source, _ = _kind_source("amends", "../../../a.lgd")
+ assert findings(render(source, document_path=tmp_path / "doc.lgd")) == []
+
+
+def test_the_document_path_names_the_file_unless_one_is_given(tmp_path: Path) -> None:
+ source, _ = _kind_source("amends", "/x")
+ result = render(source, document_path=tmp_path / "deal.lgd")
+ assert {d.file for d in result.diagnostics} == {"deal.lgd"}
+ named = render(source, document_path=tmp_path / "deal.lgd", filename="other.lgd")
+ assert {d.file for d in named.diagnostics} == {"other.lgd"}
+
+
+def test_options_default_to_no_root() -> None:
+ options = RenderOptions()
+ assert options.document_root is None and options.document_path is None
+
+
+# -- render_file -----------------------------------------------------------------------
+
+
+def test_render_file_sets_the_document_path(tmp_path: Path) -> None:
+ sub = tmp_path / "sub"
+ sub.mkdir()
+ path = sub / "deal.lgd"
+ # Up one directory is the root; up two escapes it.
+ source, _ = _kind_source("amends", "../a.lgd")
+ path.write_text(source, encoding="utf-8")
+ assert findings(render_file(path, document_root=tmp_path)) == []
+ assert findings(render_file(path, RenderOptions(document_root=tmp_path))) == []
+ assert [r for r, _ in findings(render_file(path, document_root=sub))] == [OUTSIDE]
+ # Without a root: no check.
+ path.write_text(_kind_source("amends", "../../../a.lgd")[0], encoding="utf-8")
+ assert findings(render_file(path)) == []
+ escaping = findings(render_file(path, document_root=tmp_path))
+ assert [r for r, _ in escaping] == [OUTSIDE]
+ assert {d.file for d in render_file(path, document_root=tmp_path).diagnostics} == {"deal.lgd"}
+
+
+def test_render_file_refuses_a_document_outside_the_root(tmp_path: Path) -> None:
+ (tmp_path / "root").mkdir()
+ path = tmp_path / "deal.lgd"
+ path.write_text("# A\n", encoding="utf-8")
+ with pytest.raises(OptionsError):
+ render_file(path, document_root=tmp_path / "root")
+
+
+def test_render_file_keeps_a_document_path_that_was_given(tmp_path: Path) -> None:
+ (tmp_path / "sub").mkdir()
+ path = tmp_path / "deal.lgd"
+ path.write_text(_kind_source("amends", "../a.lgd")[0], encoding="utf-8")
+ # The document is said to be in sub/, so ../ stays in the root.
+ assert findings(render_file(path, document_root=tmp_path, document_path=tmp_path / "sub" / "x.lgd")) == []
+
+
+# -- The command line --------------------------------------------------------------------
+
+
+def _write(tmp_path: Path, body_kind: str, path: str, name: str = "deal.lgd", directory: str = "") -> Path:
+ target = tmp_path / directory / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(_kind_source(body_kind, path)[0], encoding="utf-8")
+ return target
+
+
+def test_root_reports_a_path_that_escapes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ document = _write(tmp_path, "include", "../../outside/part.lgd")
+ source, _ = _kind_source("include", "../../outside/part.lgd")
+ assert main([str(document), "-f", "text", "--root", str(tmp_path)]) == 0
+ err = capsys.readouterr().err
+ assert f"{document}:{line_of(source, 'Before')}: error: [path-outside-root] " in err
+ assert main([str(document), "-f", "text"]) == 0
+ assert "path-outside-root" not in capsys.readouterr().err
+
+
+def test_root_with_strict_refuses(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ document = _write(tmp_path, "amends", "../../a.lgd")
+ assert main([str(document), "--strict", "--root", str(tmp_path)]) == 1
+ assert "[path-outside-root]" in capsys.readouterr().err
+ assert main([str(document), "--strict"]) == 0
+
+
+def test_root_is_the_boundary_for_a_document_in_a_subdirectory(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ document = _write(tmp_path, "amends", "../shared/a.lgd", directory="contracts")
+ assert main([str(document), "-q", "--root", str(tmp_path)]) == 0
+ assert main([str(document), "--root", str(tmp_path / "contracts")]) == 0
+ assert "[path-outside-root]" in capsys.readouterr().err
+
+
+def test_root_refuses_a_document_outside_it(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ (tmp_path / "root").mkdir()
+ document = _write(tmp_path, "amends", "a.lgd")
+ assert main([str(document), "--root", str(tmp_path / "root")]) == 2
+ assert "outside the document root" in capsys.readouterr().err
+
+
+def test_root_with_standard_input_puts_the_document_at_the_root(tmp_path: Path) -> None:
+ source, _ = _kind_source("amends", "../a.lgd")
+ done = run_cli_text("-", "-f", "text", "--root", str(tmp_path), stdin=source)
+ assert done.returncode == 0
+ assert f":{line_of(source, ' file:')}: error: [path-outside-root] " in done.stderr
+ assert "path-outside-root" not in run_cli_text("-", "-f", "text", stdin=source).stderr
+
+
+def test_not_relative_is_reported_without_a_root(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ document = _write(tmp_path, "translation", "https://x.example/cs.lgd")
+ assert main([str(document), "-f", "text"]) == 0
+ assert f"{document}:{line_of(document.read_text(encoding='utf-8'), ' cs:')}: error: [path-not-relative] " \
+ in capsys.readouterr().err
+
+
+def test_root_is_in_the_help() -> None:
+ done = run_cli_text("--help")
+ assert "--root" in done.stdout and "DIR" in done.stdout
From 852308111da2f3a95009faec6edf2dcdf6e5b999 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:18:17 +0000
Subject: [PATCH 043/118] Run the spec's file-reference fixtures with their
document root
The conformance tests honour requires_config.document_root and check that
path-not-relative and path-outside-root are reported at the expected lines;
without a root the outside-root fixture renders with no path error.
---
tests/conformance/test_spec_corpus.py | 34 ++++++++++++++++++++++++++-
1 file changed, 33 insertions(+), 1 deletion(-)
diff --git a/tests/conformance/test_spec_corpus.py b/tests/conformance/test_spec_corpus.py
index 93dfb79..32b94dd 100644
--- a/tests/conformance/test_spec_corpus.py
+++ b/tests/conformance/test_spec_corpus.py
@@ -12,7 +12,7 @@
import pytest
from conftest import spec_root
-from legaldown_render import DocumentError, render
+from legaldown_render import DocumentError, render, render_file
pytestmark = pytest.mark.conformance
@@ -82,6 +82,38 @@ def test_invalid_fixtures_render_their_markers(rule: str) -> None:
assert MARKERS[rule] in output, f"{case.name} does not render {MARKERS[rule]!r}"
+#: The file-reference rules (§2.3, §16.2): the renderer reports them itself, since the validator does not.
+FILE_REFERENCE_RULES = ("path-not-relative", "path-outside-root")
+
+
+@pytest.mark.parametrize("rule", FILE_REFERENCE_RULES)
+def test_file_reference_fixtures_report_their_rule_at_its_line(rule: str) -> None:
+ """A case may ask for a configuration (``requires_config``). The document
+ root is the one setting this renderer can make; a case that needs more is
+ skipped."""
+ cases = _entries(SPEC / "fixtures" / "invalid" / rule)
+ assert cases, f"no fixture for {rule}"
+ for case in cases:
+ expected = json.loads(case.with_suffix(".expected.json").read_text(encoding="utf-8"))
+ config = expected.get("requires_config", {})
+ if set(config) - {"document_root"}:
+ pytest.skip(f"{case.name} needs {sorted(config)}")
+ settings = {"document_root": case.parent / config["document_root"]} if "document_root" in config else {}
+ result = render_file(case, **settings)
+ found = {(d.rule, d.line) for d in result.diagnostics}
+ for diagnostic in expected["diagnostics"]:
+ assert (diagnostic["rule"], diagnostic["line"]) in found, f"{case.name}: {sorted(found)}"
+ assert not result.ok
+
+
+def test_the_outside_root_fixture_is_valid_without_a_root() -> None:
+ """§2.3: no default root, so the containment check does not run."""
+ for case in _entries(SPEC / "fixtures" / "invalid" / "path-outside-root"):
+ result = render_file(case)
+ assert "path-outside-root" not in {d.rule for d in result.diagnostics}
+ assert not [d for d in result.diagnostics if d.rule.startswith("path-")], case.name
+
+
def test_raw_html_fixture_emits_no_html() -> None:
for case in _entries(SPEC / "fixtures" / "invalid" / "raw-html"):
result = render(case.read_text(encoding="utf-8"), standalone=False)
From c09041c0985e71fdfb35520a0ec574e8128503a5 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:18:17 +0000
Subject: [PATCH 044/118] Document the file-reference checks and the document
root
CONFORMANCE (the images and file-reference row, the include row, the
containment sentence that said it was not checked), architecture (security,
diagnostics, test table) and README (--root, document_root, document_path).
---
CONFORMANCE.md | 38 ++++++++++++++++++++++++++++++--------
README.md | 18 ++++++++++++++++--
docs/architecture.md | 44 ++++++++++++++++++++++++++++++++------------
3 files changed, 78 insertions(+), 22 deletions(-)
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index 54105ff..9be547a 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -17,7 +17,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
|---|---|
| Section numbering generated at render time, configurable per job (§13.1) | ✅ `decimal`, `legal-outline`, `mixed`, `none`, and custom level formats; per job with `--set numbering.scheme=…` |
| Resolution of every §11.1 directive except `{{include:}}`, with every failure marker (§6.3, §7.3, §13.3–§13.5, §15.8) | ✅ |
-| `{{include:}}` below Full (§17.5) | ✅ Renders `[NOT PROCESSED: include path]` with a Warning (`render-not-processed`) |
+| `{{include:}}` below Full (§17.5) | ✅ Renders `[NOT PROCESSED: include path]` with a Warning (`render-not-processed`); the path is checked as a file reference (§2.3) |
| Party and side display rules (§3.6) | ✅ |
| At least one output format (§13.6) | ✅ HTML (recommended) and plain text (optional) |
| 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 |
@@ -40,7 +40,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
| `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 (see "Text after a comment", below) |
| Automatic term recognition (§7.4, MAY) | ❌ Not supported |
| Links (§8.7) | ⚠️ Partial, a documented deviation for safety with untrusted documents: links with schemes outside the allow-list render as text with a `render-link-refused` Warning. A link is a hyperlink in HTML and shows `text ` in plain text when its URL is relative, a fragment, or has one of the schemes `http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sftp`, `sip`, `sips`, `sms`, `urn`, `geo`, `news` (an allow-list, judged as a browser reads the URL). Any other scheme (`javascript:`, `file:`, `data:`, protocol handlers such as `ms-msdt:`, `vscode:`, `search-ms:`, and pseudo-schemes such as `C:\x` or `localhost:8080`), or an empty URL, is not a link: its text is shown alone, and the Warning `render-link-refused` is reported for a non-empty URL. A link with no text shows its URL as written; the text writer omits `` for an autolink |
-| Images and file-reference paths (§8.7, §2.3) | ✅ A relative path (`../` included, as §2.3 defines no default document root) renders `` in HTML and its alt text in plain text. A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is never fetched or embedded: the alt text is shown and the Error `path-not-relative` is reported, which `--strict` refuses. An empty path shows the alt text with no diagnostic |
+| Images and file-reference paths (§8.7, §2.3) | ✅ Every file reference (`{{include:}}`, `attachments[].file`, `amends.file`, `supersedes.file`, a `translations` value, an image) must be relative, and within the document root when one is configured (`--root DIR`, `document_root=`; see "File references", below). A path with a URI scheme (`data:` too), an absolute one (`/`, `\`, `//host`), or a Windows drive path is the Error `path-not-relative`; a path that leaves the root is the Error `path-outside-root`; `--strict` refuses both. Such an image shows its alt text and is never fetched or embedded; an `{{attach:}}` shows its text with no link; an include marker and an attachment's path stay as text, and no file is ever read. A relative path (`../` included, as §2.3 defines no default root) renders `` in HTML and its alt text in plain text. An empty image path shows the alt text with no diagnostic |
| Plain-text layout (§13.6, optional) | ✅ `attachments.separator` (§13.8): `none` prints no `---`; `rule` and `page-break` both print one before the attachments heading and between consecutive attachments, so the heading stays with the first (text has no pages). One quote stays one quote (blank lines keep the `>`), and the blocks of a list item are set apart by blank lines and indented under the item's label, whatever its condition label; an item that opens with a nested list, table, code block or quote has its label on a line of its own, so that block stays valid. A pipe in a table cell is backslash-escaped, and the separator row marks column alignment (`:---`, `:---:`, `---:`). A label a style blanks drops its `label: ` prefix in the title block, party details and signature blocks (a signature row with neither label nor value is omitted); HTML keeps an empty `
` so its grid layout and the `
` stay valid |
| Extended tables through raw HTML (§9.2, MAY) | ❌ Not supported; the HTML table is dropped with the Warning |
| Visual blank for a placeholder (§13.5 rule 6) | ✅ `placeholders.blank`; a blank that is empty, or only whitespace or invisible format characters (U+200B, U+FEFF), renders `[TBD: id]` |
@@ -54,7 +54,7 @@ It is checked against the specification's own examples and fixtures corpus (`tes
A template with Errors is refused too, since §15.7.2 defines no output for one.
- **Line numbers (§16.9).** Every diagnostic names its line, the validator's and the renderer's
own (`render-ref-ambiguous`, `ref-not-enumerated`, `render-not-processed`,
- `render-link-refused`, `path-not-relative`, `render-term-limit`, `render-term-too-long`,
+ `render-link-refused`, `path-not-relative`, `path-outside-root`, `render-term-limit`, `render-term-too-long`,
`render-locale-fallback`), and the CLI prints it as `file:line:`. The renderer's line is the
**first line of the block it is in**, as `Document.line_of` gives it, whatever line the
directive itself is on: a paragraph's, a table's header row, a quote's first `>`, a code
@@ -75,11 +75,33 @@ It is checked against the specification's own examples and fixtures corpus (`tes
image path, on two lines is one diagnostic, at the first of them. A document with no source to
name a line of (built from a dict) reports no line. With `answers`, the lines are those of the
assembled document (§15.8), not of the template file.
-- **`path-not-relative` for images.** The renderer reports the Core rule `path-not-relative`
- (§2.3, §16.2) for an image path, because the validator (0.4) does not check image paths. It
- leaves its own report out when the validator names the same path at the same line (or no line),
- and keeps it for a path the validator did not name, or the same path at another line.
- The containment rule (`path-outside-root`) is not checked: no document root is configured.
+- **File references (§2.3).** The renderer reports `path-not-relative` and `path-outside-root`
+ (§2.3, §16.2) for every file reference itself, because the validator (0.4) checks neither rule.
+ It leaves its own report out when the validator names the same path at the same line (or no
+ line), and keeps it for a path the validator did not name, or the same path at another line.
+ A path with both problems (`/../x`) is `path-not-relative`. The reports are at the line of the
+ block holding the `{{include:}}` or image, or of the frontmatter key (`attachments[].file`,
+ `amends.file`, `supersedes.file`, `translations.`), like the rest.
+ The checks are lexical, as §2.3 says: no path is resolved against the file system, no symbolic
+ link is followed, and no file is read or its existence checked (that is Full, §17.4).
+ The containment rule runs only where a root is set, as §2.3 says (it defines no default root,
+ and a renderer for documents it does not control SHOULD set one): `--root DIR`, or
+ `RenderOptions.document_root`. The root is the boundary, and the base of a path is the directory
+ of the document relative to it, so `../shared/a.lgd` is within the root from `/contracts/x.lgd`
+ and outside it from `/x.lgd`. `render_file` and the command line say where the document is
+ (`RenderOptions.document_path`); text given to `render()`, or standard input, is at the root. A
+ document outside the root, or on another Windows drive, is an `OptionsError` (exit 2).
+ How a path is read: a backslash is a separator, so `..\..\x` leaves the root as `../../x` does
+ (the renderer's reading; a leading `\` is not relative anyway); `..` and `.` followed by spaces
+ are `..` and `.` (Windows reads them so); tabs and newlines inside a scheme, and spaces and
+ control characters at either end, are ignored as a browser ignores them. Percent-encoding is
+ decoded once, only for what is used as a URL, an image's source and the link `{{attach:}}` would
+ make to an attachment's file: there `%2e%2e/` is `../`. A path in the frontmatter or an
+ `{{include:}}` is a file name, read as written, where `%2e%2e` is a directory with that name.
+ `supersedes` as a plain string describes the superseded document (§3.2) and is not a path.
+ Markdown links (`[text](/etc/passwd)`, §8.7) are not file references and stay governed by the
+ link allow-list (`render-link-refused`): they are links the reader may follow, not files the
+ renderer would read.
- **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
in which the enclosing lists and quotes plus one text's inline formatting (emphasis, links) nest
diff --git a/README.md b/README.md
index b7154d6..3078948 100644
--- a/README.md
+++ b/README.md
@@ -61,11 +61,20 @@ legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html
legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumeration.enabled=false
legaldown-render contract.lgd --set contents.enabled=true --set contents.depth=3 # table of contents
legaldown-render contract.lgd --strict # refuse if the document has errors
+legaldown-render contract.lgd --root . --strict # file references may not leave this directory
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
```
+`--root DIR` sets the document root (§2.3): a file reference that leaves it (an `{{include:}}`
+path, an attachment's, `amends`, `supersedes`, a translation's, an image's) is the Error
+`path-outside-root`, and the file is not used. Paths that are not relative (a URI scheme, an absolute
+path, a Windows drive) are the Error `path-not-relative` with or without a root. Nothing is ever
+read or fetched, and the check is lexical, so symbolic links are not followed. The document must be
+inside DIR (exit 2 otherwise); standard input counts as at DIR itself. Set a root when you render documents
+you do not control: without one, `../` is allowed (§2.3 defines no default).
+
`--locale` sets how dates, money, and durations are formatted. The words the renderer generates
(such as "Represented by" or "Signatures") follow the document's `language` (§13.7), whatever the locale.
@@ -75,7 +84,7 @@ The exit code is 0 when the document
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 the document or answers file cannot be
read (a document that is not UTF-8 included) or the output file cannot be written, 2 for an invalid
-style, setting, or usage, and 70 for an internal error (a bug to report).
+style, setting, or usage (a document outside `--root` too), and 70 for an internal error (a bug to report).
Documents are read as UTF-8, and output is UTF-8 with LF line endings, on standard output and in
`-o` files, whatever the console's encoding and the platform's newline. The command reconfigures
@@ -105,8 +114,13 @@ with open("contract.html", "w", encoding="utf-8", newline="\n") as output:
`render()` also accepts a `RenderOptions`. Pass `answers={...}` to render a template filled in.
Each diagnostic has its `line` and its `file`: `render_file()` and the command name the file (its
name, without the directory); for text given to `render()`, pass `filename=`.
+`document_root=` is the `--root` of the command line, and `document_path=` says where the document
+is in it (`render_file()` sets it from its path; text given to `render()` is at the root); a
+document outside the root raises `OptionsError`. For example, a hosting service that renders
+uploaded text:
+`render(text, document_root="/srv/docs/tenant-1")`.
Problems in the document never raise. Only an invalid style (`StyleError`), an unknown output
-format (`OptionsError`), an unreadable document (`DocumentError`), strict mode, or a template that
+format or a document outside its root (`OptionsError`), an unreadable document (`DocumentError`), strict mode, or a template that
cannot be assembled with its answers (`RenderRefused`) do. All of them are `RenderError`s, and
`StyleError` and `OptionsError` are also `ValueError`s. They can be pickled, so they cross process
boundaries (`multiprocessing`, `concurrent.futures`) intact. `overrides=` takes dotted settings and
diff --git a/docs/architecture.md b/docs/architecture.md
index 81118df..e401ef7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -233,14 +233,15 @@ turns into an error.
raises `RenderRefused` instead, carrying the diagnostics.
- **Configuration problems** raise before rendering, with every problem named: an invalid style,
setting, or locale raises `StyleError` (this includes a `{field}` that a text template does not
- accept or lacks, a `--set` value that is not valid YAML, and an `extends` cycle); an unknown output format raises `OptionsError`. Both are
+ accept or lacks, a `--set` value that is not valid YAML, and an `extends` cycle); an unknown output format, or a document that is not inside the configured document root, raises `OptionsError`. Both are
`RenderError`s (and `ValueError`s, as they always were). Every renderer error pickles, so it
survives `multiprocessing`.
- **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 `path-not-relative` (an Error for an image path that is not relative, §2.3,
-reported here because validator 0.4 does not check image paths), specification rule ids, plus renderer-specific ids prefixed `render-`:
+`ref-not-enumerated`, `path-not-relative` and `path-outside-root` (Errors for a file reference that is
+not relative, or that leaves the document root, §2.3; reported here because validator 0.4 checks
+neither), specification rule ids, plus renderer-specific ids prefixed `render-`:
`render-not-processed`, `render-link-refused` (a Warning for a link whose URL scheme links may not use; its text is shown), `render-locale-fallback` (an Info when no formatting locale is known, a Warning when a duration unit has no CLDR pattern in the locale and English is used), `render-ref-ambiguous`, `render-term-limit` (a Warning when defined terms refer to each other too deeply or too often: past 32 nested terms, or 200,000 inline nodes resolved inside repeat computations of a term text (only cycles cause them), a `{{term:}}` shows its id), and `render-term-too-long` (a Warning for a defined term whose text, with its references resolved, is longer than 1,000 characters: `{{term:}}` then shows the definition id). The validator's
diagnostics carry their line (§16.9), and so do the renderer's own, and the CLI prints it as
`file:line:`.
@@ -264,8 +265,8 @@ set to the file name as the validator does (`Document.filename`, without the dir
**Repeats.** `Resolver._warn` and `api.render` leave out a diagnostic whose rule, level and message
a reported one has already, wherever it is (`diagnostics.repeats`), so a repeated finding is
-reported at its first line only. The renderer's `path-not-relative` is left out when the validator
-reported the same path at the same line, or at none (`diagnostics.covered_by`).
+reported at its first line only. The renderer's `path-not-relative` and `path-outside-root` are left
+out when the validator reported the same path at the same line, or at none (`diagnostics.covered_by`).
## Security
@@ -297,13 +298,31 @@ reported the same path at the same line, or at none (`diagnostics.covered_by`).
API returns are not all escaped: only a path or URL quoted by the renderer's own rules
(`path-not-relative`, `render-link-refused`) is, at its source; a caller that shows other
messages must do the same.
-- An image path must be relative with no URI scheme (§2.3), so a remote resource is never fetched
- or embedded. Resolution, not a writer, enforces it: an image whose path has a scheme (`https:`,
- `file:`, `data:`, a Windows drive such as `C:\x`) or starts with `/` or `\` (`//host`) is
- replaced by its alt text and reported as the Core Error `path-not-relative`, which `--strict`
- refuses. An image with an empty path shows its alt text too, with no diagnostic (§2.3 has no
- rule for it). `../` stays allowed: §2.3 defines no default document root. The writers' image check
- is a second line.
+- Every file reference (§2.3) must be relative with no URI scheme, and within the document root
+ when one is configured, so a remote resource is never fetched or embedded and no path leads
+ outside the tree the document belongs to. The references are `{{include:}}` paths,
+ `attachments[].file`, `amends.file`, `supersedes.file` (the object form; a plain string
+ describes the document and is no path), `translations` values, and image paths. Resolution, not a
+ writer, enforces it (`Resolver._reference`, one place for every kind, over
+ `urls.file_reference_problem`): an image whose path has a scheme (`https:`, `file:`, `data:`, a
+ Windows drive such as `C:\x`) or starts with `/` or `\` (`//host`), or leaves the root, is replaced by
+ its alt text; an `{{attach:}}` whose file fails is its text with no link; an include marker and
+ an attachment placeholder's path stay as text. Each is reported as the Core Error
+ `path-not-relative` or `path-outside-root`, which `--strict` refuses. An image with an empty path
+ shows its alt text too, with no diagnostic (§2.3 has no rule for it). The checks are lexical,
+ as §2.3 says: no `resolve()`, no symbolic link followed, no file read or tested for existence.
+ The root is `RenderOptions.document_root` (`--root DIR`); with none, `../` is allowed, §2.3
+ defining no default root (a service rendering documents it does not control SHOULD set one). A
+ path's base is the document's directory relative to the root (`api._document_base`: the lexical
+ `relpath` of the directory of `document_path` to the root, `/`-separated; the document at the
+ root when it has no path, as text given to `render()` and standard input are); a document
+ outside the root, or on another Windows drive, is an `OptionsError`, since the boundary would
+ mean nothing. A backslash is read as a separator (the renderer's reading, so a path that goes up
+ on Windows is not missed), `..`/`.` with trailing spaces are `..`/`.`, and percent-encoding is
+ decoded once, only for what is used as a URL (an image's `src`, the link an `{{attach:}}`
+ makes): `%2e%2e` is `..` there, and a directory name in a frontmatter or include path. The
+ writers' image check is a second line. Markdown links are not file references (§8.7): they stay
+ governed by the allow-list above, since the renderer reads nothing at their targets.
- At Rendering level, the only files read are the document, the style template, and the answers
file the CLI reads with `--answers` (the Python API takes answers as a mapping). Attachment
files and includes are never opened.
@@ -337,6 +356,7 @@ Each module is named for its topic. Shared helpers (the minimal document header,
| `tests/test_terms_budget.py` | Reuse of worked-out term texts, the work budget and depth limit for terms that name each other. Bounds the work by counting resolved directives, never by timing |
| `tests/test_diagnostic_lines.py` | The line and file of every renderer diagnostic: in each kind of block (paragraph, list item, nested item, table, quote, heading, preamble) and at frontmatter keys, the repeats left out, the tree nodes' lines, and `file:line:` on the command line |
| `tests/test_links.py` | Which images are shown and which links are links: non-relative image paths, the link scheme allow-list, empty URLs and empty link text, and how a refusal is reported |
+| `tests/test_file_references.py` | File references (§2.3): the lexical check (relative, `../`, scheme, drive, backslash, encoding), each kind of reference with and without a root and from a subdirectory, what is shown instead, the options and `--root`, strict, and the lines |
| `tests/test_text_writer.py` | The plain-text layout: attachment separators, tables, quotes, list items and indent, blanked labels |
| `tests/test_html.py` | The HTML writer's print stylesheet for attachments |
| `tests/test_labels.py` | The built-in labels of parties, per language |
From 29edc2e9fb16b985b585e74243b11813b8bff78d Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:24:58 +0000
Subject: [PATCH 045/118] =?UTF-8?q?Designate=20sections=20under=20the=20no?=
=?UTF-8?q?ne=20scheme=20by=20their=20heading=20as=20rendered=20(=C2=A713.?=
=?UTF-8?q?3)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A reference to a section under the none scheme shows its heading text. The text
was a text-only reading that left every {{ref:}} out, so a heading with a
reference was designated without it. Each section is now designated by the
plain text of its heading with the references and terms in it resolved.
The designations are worked out once each by a depth-first walk in document
order (Resolver._designate), after the sections, items and paragraphs are
numbered on their text-only designations. A reference to an item or paragraph
under another heading is composed again on that heading's designation, from
the pieces the target records (_Target.section_index and parts). Where any
designation differs, the structure is numbered again on the final ones; the
anchors are the same, since they depend on identifiers and order alone, and
render-ref-ambiguous reads the final designations. Documents whose headings
and the terms they use hold no {{ref:}} skip all of this.
A heading that refers back to itself (render-designation-cycle), or reads
through more than 32 headings, is longer than 1000 characters once resolved,
or is nested too deeply (render-designation-limit) is designated by its
text-only reading. A reference shows at most its first 1000 characters.
The walk reports nothing itself and gives out no anchor: its terms are kept in
a cache of their own, and what it could not do is reported at the heading.
A term met inside an open term, or while designations are worked out, at 60
nested inline levels now shows its label or id with render-term-limit. Terms
read inside terms stack the 100 levels the builder allows in one block, and a
chain of them exhausted the Python stack (a RecursionError) in decimal and
none documents alike; one block's own nesting never reaches the limit.
Tests: test_none_designations.py is new. The none-scheme test that pinned the
old behaviour (test_none_scheme_designation_skips_nested_references) is renamed
and expects the reference to be shown. The term-cache equivalence test now also
draws headings that refer to later headings and to terms.
---
src/legaldown_render/resolve/resolver.py | 380 +++++++++++++++++----
tests/test_none_designations.py | 416 +++++++++++++++++++++++
tests/test_render.py | 6 +-
tests/test_terms_budget.py | 42 ++-
4 files changed, 779 insertions(+), 65 deletions(-)
create mode 100644 tests/test_none_designations.py
diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py
index c8d1802..91740b1 100644
--- a/src/legaldown_render/resolve/resolver.py
+++ b/src/legaldown_render/resolve/resolver.py
@@ -151,6 +151,42 @@
#: one term, so that a pathological tangle of cycles cannot fill memory.
_TERM_TEXTS_PER_TERM = 32
+#: The most inline levels (emphasis, links, a term's text, a heading read for a
+#: reference) resolved inside one another where one unit is read inside another,
+#: which is where the Python stack can run out: the builder allows 100 levels in
+#: one block (``build.MAX_NESTING``), but terms read inside terms, and headings
+#: read through the references in other headings, stack one block's levels on
+#: another's. A ``{{term:}}`` met inside an open term, or while designations are
+#: worked out, that is nested this deep shows its label or id
+#: (``render-term-limit``); a heading this deep stops reading others
+#: (``render-designation-limit``). A single block's nesting, up to the
+#: builder's limit, never reaches it.
+_MAX_NEST = 60
+
+#: Under the ``none`` scheme (§13.3) a section is designated by its heading with
+#: the references in it resolved. The most headings read inside one another (a
+#: heading that refers to a heading that refers to...), so that a long chain
+#: does not exhaust the stack; and the longest designation: a heading that
+#: reads longer once its references are resolved (``# H1 {{ref: h2}} {{ref:
+#: h2}}`` down a chain would otherwise grow as 2**n) is designated by its text
+#: without them, and a reference never shows more than this many characters.
+_MAX_DESIGNATION_DEPTH = 32
+_MAX_DESIGNATION_TEXT = 1000
+
+_DESIGNATION_LIMIT = (
+ f"Headings and the terms in them refer to each other too deeply or too often (more than "
+ f"{_MAX_DESIGNATION_DEPTH} headings, or terms or formatting nested too far), or a heading reads longer than "
+ f"{_MAX_DESIGNATION_TEXT} characters once its references are resolved; under the none scheme such a heading is "
+ f"designated by its text as written, without the references in it, and a reference shows at most its first "
+ f"{_MAX_DESIGNATION_TEXT} characters."
+)
+
+#: What a reference can be resolved from: before the targets are known, only the
+#: headings' own text; then, under the ``none`` scheme, the designations of the
+#: headings that refer to each other being worked out; then everything (see
+#: ``Resolver.run``).
+_EARLY, _DESIGNATING, _FINAL = range(3)
+
class _TermText(NamedTuple):
"""A term's text, the terms it named, and those it was cut short at
@@ -172,6 +208,12 @@ class _Target:
enumerated: bool = True
#: The numbered unit the anchor is on, for render-ref-ambiguous.
slot: _Slot | None = None
+ #: The section the anchor is in (a section's own index), None in the preamble.
+ section_index: int | None = None
+ #: For an item or paragraph: the (``ref`` template, counter text) of each
+ #: level below the section, so that its designation can be put together
+ #: again on another section designation (see ``_compose``).
+ parts: tuple[tuple[str, str], ...] = ()
class Resolver:
@@ -216,6 +258,8 @@ def __init__(
# The same for the text-only pass before the targets are known, and
# for the validator's term text where a definition is not in the body.
self.term_texts_early: dict[str, list[_TermText]] = {}
+ # And for the text-only pass that works out designations (see _designate).
+ self.term_texts_designating: dict[str, list[_TermText]] = {}
self.term_texts_plain: dict[str, list[_TermText]] = {}
# The definitions whose term text is being worked out, so that a
# {{term:}} inside a term, directly or through other terms, cannot
@@ -229,9 +273,24 @@ def __init__(
# Inline nodes resolved inside repeat computations (see _while_open).
self._term_work = 0
self._repeating = 0
- # False until every target is registered and the placeholder types are
- # known (run() sets it just before the inlines are resolved).
- self._targets_known = False
+ # What a reference can be resolved from (see run()).
+ self._stage = _EARLY
+ # Inline levels being resolved inside one another (see _MAX_NEST).
+ self._nest = 0
+ # Under the none scheme, while designations are worked out (see
+ # _designate): each section's designation (None until known), its
+ # text-only reading, its title, the sections open inside one another,
+ # the sections a reference came back to, and whether a limit was hit
+ # (and in which section first).
+ self._memo: list[str | None] = []
+ self._provisional: list[str] = []
+ self._titles: list[tuple[Inline, ...]] = []
+ self._stack: list[int] = []
+ self._cycles: list[int] = []
+ self._limited = False
+ self._limited_at: int | None = None
+ # The section being structured, for the targets it holds.
+ self._section_index: int | None = None
self.attachment_anchors: dict[str, str | None] = {}
self.section_presences: list[Presence] = []
@@ -240,13 +299,21 @@ def __init__(
def run(self) -> RenderTree:
self._survey()
attachments = self._attachments()
- sections = self._number_sections()
- preamble = tuple(self._structure(block, section=None, depth=0, presence=ALWAYS) for block in self.tree.preamble)
- sections = tuple(
- replace(section, blocks=self._structure_section(section, presence))
- for section, presence in zip(sections, self.section_presences, strict=True)
- )
- self._targets_known = True
+ anchors = set(self.used_anchors)
+ sections, preamble = self._structure_all(None)
+ if self.textual and self._reads_references(sections):
+ # §13.3: a section is designated by its heading as rendered, which
+ # may hold references to sections, items and paragraphs, all
+ # numbered by now under their provisional (text-only)
+ # designations. Work the final ones out, and where any differs
+ # number everything again on them: the anchors come out the same,
+ # since which unit gets which depends on identifiers and order alone.
+ designations = self._designate(sections)
+ if designations != [section.designation for section in sections]:
+ self.used_anchors = anchors
+ self.targets, self.designation_slots, self.section_presences = {}, {}, []
+ sections, preamble = self._structure_all(designations)
+ self._stage = _FINAL
resolve = self._resolve_inlines
# In document order, so that the first of several definitions of a
# term is the one that keeps its anchor.
@@ -286,6 +353,133 @@ def run(self) -> RenderTree:
# definition may live in an attachment or an amended original.
return map_tree_inlines(resolved, self._settle_term_target)
+ def _structure_all(self, designations: list[str] | None) -> tuple[tuple[Section, ...], tuple[Block, ...]]:
+ """The preamble and the sections, numbered and labelled; under the
+ ``none`` scheme with the section *designations* worked out by
+ :meth:`_designate`, or else the headings' text alone."""
+ sections = self._number_sections(designations)
+ self._section_index = None
+ preamble = tuple(self._structure(block, section=None, depth=0, presence=ALWAYS) for block in self.tree.preamble)
+ out: list[Section] = []
+ for index, (section, presence) in enumerate(zip(sections, self.section_presences, strict=True)):
+ self._section_index = index
+ out.append(replace(section, blocks=self._structure_section(section, presence)))
+ self._section_index = None
+ return tuple(out), preamble
+
+ # -- designations under the none scheme (§13.3) ------------------------------
+
+ def _reads_references(self, sections: tuple[Section, ...]) -> bool:
+ """True if a section's designation may depend on a ``{{ref:}}``: one
+ stands in a heading or in the text of a term the headings use,
+ directly or through other terms, or in the validator's own text of
+ such a term (read where the definition is not in the body). Without
+ one, the text-only reading of a heading is its designation."""
+ lookup = self.result.index.definition_lookup
+ seen: set[str] = set()
+ pending = [section.title for section in sections]
+ while pending:
+ for inline in _walk(pending.pop()):
+ if not isinstance(inline, DirectiveSource):
+ continue
+ directive = inline.directive
+ if directive.name == "ref":
+ return True
+ if directive.name == "term" and directive.positional and directive.positional not in seen:
+ seen.add(directive.positional)
+ source = self.term_sources.get(directive.positional)
+ if source is not None:
+ pending.append(source.children)
+ if "{{" in (lookup.get(directive.positional) or ""):
+ return True
+ return False
+
+ def _designate(self, sections: tuple[Section, ...]) -> list[str]:
+ """The designation of every section under the ``none`` scheme: its
+ heading as rendered, plain, with the references and terms in it
+ resolved (§13.3). Each is worked out once, by a depth-first walk in
+ document order that reads the designation of a referenced heading
+ where the reference stands (:meth:`_designation`).
+
+ The walk reports nothing and gives out no anchor itself: the heading
+ is read again in its place, once every designation is known, and a
+ diagnostic about it is made there, once. The terms it works out are
+ kept apart from those of that later pass, which the designations
+ decide. What the walk could not do in full is reported here, at the
+ heading it concerns."""
+ self._provisional = [section.designation or "" for section in sections]
+ self._titles = [section.title for section in sections]
+ self._memo = [None] * len(sections)
+ self._stack, self._cycles = [], []
+ self._limited, self._limited_at = False, None
+ mark, work = len(self.diagnostics), self._term_work
+ self._stage = _DESIGNATING
+ try:
+ for index in range(len(sections)):
+ self._designation(index)
+ finally:
+ self._stage = _EARLY
+ del self.diagnostics[mark:]
+ self._term_work = work
+ self.term_texts_designating = {}
+ for index in sorted(set(self._cycles)):
+ with self._at(sections[index].line):
+ self._warn(
+ "render-designation-cycle",
+ f"The heading '{printable(sections[index].identifier or self._provisional[index])}' refers to "
+ f"itself, directly or through other headings or terms; under the none scheme the reference "
+ f"that closes the circle shows the heading without its references.",
+ )
+ if self._limited:
+ with self._at(sections[self._limited_at].line if self._limited_at is not None else self.line):
+ self._warn("render-designation-limit", _DESIGNATION_LIMIT)
+ return [memo or "" for memo in self._memo]
+
+ def _designation(self, index: int) -> str:
+ """The designation of section *index*, worked out the first time it is
+ asked for. A section that is open already (a reference that comes back
+ to a heading it is read from) reads as its text-only reading, and is
+ reported by ``render-designation-cycle``; so does one asked for past
+ :data:`_MAX_DESIGNATION_DEPTH` open sections or :data:`_MAX_NEST` levels
+ (``render-designation-limit``), which is worked out when it is next
+ reached by a shorter way, or in its turn. Neither is kept. A designation
+ that reads longer than :data:`_MAX_DESIGNATION_TEXT` characters once its
+ references are resolved is its text-only reading too, which is kept."""
+ memo = self._memo[index]
+ if memo is not None:
+ return memo
+ provisional = self._provisional[index]
+ if index in self._stack:
+ self._cycles.append(index)
+ return provisional
+ if len(self._stack) >= _MAX_DESIGNATION_DEPTH or self._nest >= _MAX_NEST:
+ self._limit_hit()
+ return provisional
+ self._stack.append(index)
+ try:
+ text = self._title_text(self._titles[index])
+ finally:
+ self._stack.pop()
+ if len(text) > _MAX_DESIGNATION_TEXT and text != provisional:
+ self._limit_hit(index)
+ text = provisional
+ self._memo[index] = text
+ return text
+
+ def _limit_hit(self, index: int | None = None) -> None:
+ """Note, while designations are worked out, that a limit was reached
+ in section *index*, else in the one being read."""
+ self._limited = True
+ if self._limited_at is None:
+ self._limited_at = index if index is not None else self._stack[-1] if self._stack else None
+
+ def _designation_of(self, target: _Target) -> str:
+ """What a reference to *target* reads as while designations are worked
+ out: the designation of its section, with its item or paragraph pieces."""
+ if target.section_index is None:
+ return target.designation
+ return _compose(target.parts, self._designation(target.section_index))
+
def _resolve_section(self, section: Section) -> Section:
with self._at(section.line):
return replace(
@@ -385,7 +579,7 @@ def _register(self, identifier: str, target: _Target) -> None:
# -- structure: numbering, labels, anchors ---------------------------------
- def _number_sections(self) -> list[Section]:
+ def _number_sections(self, designations: list[str] | None = None) -> list[Section]:
"""Number the sections, and record each one's presence (§15.3).
The numbers are the validator's own (``result.index.sections``),
@@ -395,11 +589,15 @@ def _number_sections(self) -> list[Section]:
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.
+
+ Under the ``none`` scheme a section's designation is given in
+ *designations* once they are worked out (:meth:`_designate`); until
+ then it is the heading's text-only reading.
"""
levels = heading_levels(self.style.numbering)
presences: dict[int, Presence] = {} # level -> presence of the open section
out: list[Section] = []
- for section, indexed in zip(self.tree.sections, self.result.index.sections, strict=True):
+ for index, (section, indexed) in enumerate(zip(self.tree.sections, self.result.index.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)
@@ -409,8 +607,12 @@ def _number_sections(self) -> list[Section]:
self.section_presences.append(presences[level])
parts = [int(part) for part in indexed.number.split(".")]
if self.textual:
- with self._at(section.line):
- label, designation = None, self._title_text(section.title)
+ label = None
+ if designations is not None:
+ designation = designations[index]
+ else:
+ with self._at(section.line):
+ designation = self._title_text(section.title)
else:
designation = ""
for position, counter in enumerate(parts):
@@ -423,7 +625,7 @@ def _number_sections(self) -> list[Section]:
# 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))
+ self._register(section.identifier, _Target(designation, anchor, slot=slot, section_index=index))
out.append(replace(section, level=min(len(parts), 5), label=label, designation=designation,
anchor=anchor))
return out
@@ -444,25 +646,32 @@ def _exclusive(self, first: Presence, second: Presence) -> bool:
def _title_text(self, title: tuple[Inline, ...]) -> str:
"""A heading's text, as the ``none`` scheme designates it (§13.3).
- A text-only pass: it gives out no anchors, and it leaves references
- out wherever they are, since their targets are not all known yet.
- Other directives show their display text; a defined term its term.
+ A text-only pass: it gives out no anchors. Before the designations
+ are known it leaves references out wherever they are, since their
+ targets are not all known yet, which makes it the provisional
+ designation; while they are worked out (:meth:`_designate`) a reference
+ shows the designation of its target. Other directives show their
+ display text; a defined term its term.
"""
def text(inlines: tuple[Inline, ...]) -> str:
parts: list[str] = []
- for inline in inlines:
- if self._repeating:
- self._term_work += 1
- match inline:
- case DirectiveSource(directive=directive):
- if directive.name != "ref":
- resolved = self._directive(directive)
- parts.append(plain_text((resolved,)) if resolved is not None else "")
- case DefinitionSource(children=children) | Emphasis(children=children) \
- | Strong(children=children) | Link(children=children) | Image(children=children):
- parts.append(text(children))
- case _:
- parts.append(plain_text((inline,)))
+ self._nest += 1
+ try:
+ for inline in inlines:
+ if self._repeating:
+ self._term_work += 1
+ match inline:
+ case DirectiveSource(directive=directive):
+ if directive.name != "ref" or self._stage == _DESIGNATING:
+ resolved = self._directive(directive)
+ parts.append(plain_text((resolved,)) if resolved is not None else "")
+ case DefinitionSource(children=children) | Emphasis(children=children) \
+ | Strong(children=children) | Link(children=children) | Image(children=children):
+ parts.append(text(children))
+ case _:
+ parts.append(plain_text((inline,)))
+ finally:
+ self._nest -= 1
return "".join(parts)
return " ".join(text(title).split())
@@ -496,10 +705,11 @@ def _number_paragraph(self, block: Paragraph, section: Section, counter: _Counte
designation = extend(base, fill(fmt.ref, n=n, section=base), textual=self.textual)
label = fill(fmt.label, n=n, section=base, path=designation)
slot = _Slot(counter, number, presence, "paragraph", block.anchor_id)
- target = _Target(designation, None, slot=slot)
+ target = _Target(designation, None, slot=slot, section_index=self._section_index,
+ parts=((fmt.ref, n),))
self._record(designation, slot)
else:
- label, target = None, _Target(base, None, enumerated=False)
+ label, target = None, _Target(base, None, enumerated=False, section_index=self._section_index)
anchor = self._anchor(block.anchor_id)
if block.anchor_id:
self._register(block.anchor_id, replace(target, anchor=anchor))
@@ -528,7 +738,7 @@ def _structure(self, block: Block, *, section: Section | None, depth: int, prese
def _structure_list(self, block: List, *, section: Section | None, depth: int, parent: str,
presence: Presence, counters: dict[tuple[str, str] | None, _Counter] | None = None,
- quoted: bool = False) -> List:
+ quoted: bool = False, parts: tuple[tuple[str, str], ...] = ()) -> 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
@@ -557,20 +767,22 @@ def _structure_list(self, block: List, *, section: Section | None, depth: int, p
n = format_counter(index, fmt.counter)
designation = extend(parent, fill(fmt.ref, n=n, section=base), textual=self.textual)
label = fill(fmt.label, n=n, section=base, path=designation)
- target = _Target(designation, None)
+ item_parts = (*parts, (fmt.ref, n))
+ target = _Target(designation, None, section_index=self._section_index, parts=item_parts)
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)
+ designation, label, item_parts = parent, None, parts
+ target = _Target(base, None, enumerated=False, section_index=self._section_index)
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,
- counters=siblings, quoted=quoted)
+ counters=siblings, quoted=quoted, parts=item_parts)
if isinstance(child, List)
else self._structure(child, section=section, depth=depth + 1, presence=inner, quoted=quoted)
for child in item.blocks
@@ -617,12 +829,16 @@ def _condition(self, condition: str) -> str | None:
def _resolve_inlines(self, inlines: tuple[Inline, ...]) -> tuple[Inline, ...]:
out: list[Inline] = []
- for inline in inlines:
- resolved = self._resolve_inline(inline)
- if isinstance(resolved, tuple):
- out.extend(resolved)
- elif resolved is not None:
- out.append(resolved)
+ self._nest += 1
+ try:
+ for inline in inlines:
+ resolved = self._resolve_inline(inline)
+ if isinstance(resolved, tuple):
+ out.extend(resolved)
+ elif resolved is not None:
+ out.append(resolved)
+ finally:
+ self._nest -= 1
return tuple(out)
def _resolve_inline(self, inline: Inline) -> Inline | tuple[Inline, ...] | None:
@@ -677,6 +893,11 @@ def _ref(self, directive: Directive, target_id: str) -> Inline:
target = self.targets.get(target_id)
if target is None:
return FailureMarker(f"[BROKEN REF: {target_id}]")
+ if self._stage == _DESIGNATING:
+ # Only the text matters: what is wrong with the reference is
+ # reported where the heading is read again, not for a designation
+ # that is still being worked out.
+ return CrossRef(self._reference_text(self._designation_of(target)), "")
if not target.enumerated:
self._warn(
"ref-not-enumerated",
@@ -685,8 +906,16 @@ def _ref(self, directive: Directive, target_id: str) -> Inline:
)
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 "")
+ return CrossRef(self._reference_text(target.designation), target.anchor or "")
+
+ def _reference_text(self, designation: str) -> str:
+ """What a reference shows for *designation*: at most its first
+ :data:`_MAX_DESIGNATION_TEXT` characters, so that a heading that is long
+ as written cannot be pasted unbounded by thousands of references."""
+ if len(designation) > _MAX_DESIGNATION_TEXT:
+ self._warn("render-designation-limit", _DESIGNATION_LIMIT)
+ designation = designation[:_MAX_DESIGNATION_TEXT].rstrip() + "\u2026"
+ return self.style.references.format.replace("{designation}", designation)
def _record(self, designation: str, slot: _Slot) -> None:
"""Note that a numbered unit reads as *designation* (render-ref-ambiguous)."""
@@ -757,8 +986,9 @@ def _term_text(self, definition_id: str) -> str | None:
Worked out the first time it is needed and kept, which is once every
target is registered and the placeholder types are known. Before that
(a heading read as text by the ``none`` scheme, §13.3) a text-only pass
- stands in, which leaves references out; it is kept apart from the
- text of the later pass. Resolving the term gives out no anchor: a
+ stands in, which leaves references out, and while designations are
+ worked out one that reads them (:meth:`_designate`); each is kept apart
+ from the text of the later pass. Resolving the term gives out no anchor: a
definition inside a term keeps its place in document order. The
definition is open meanwhile, so a ``{{term:}}`` inside it, directly or
through others, shows its label or id and goes no further (see
@@ -769,13 +999,13 @@ def _term_text(self, definition_id: str) -> str | None:
source = self.term_sources.get(definition_id)
if source is None:
return ""
- known = self._targets_known
+ stage = self._stage
def work() -> str:
# Worked out where the term is defined, whichever reference asks
# first, so what it reports is at the definition.
with self._here(self.term_lines.get(definition_id)):
- if not known:
+ if stage != _FINAL:
text = self._title_text(source.children)
else:
anchors = set(self.used_anchors)
@@ -793,12 +1023,13 @@ def work() -> str:
return definition_id
return text
- return self._while_open(self.term_texts if known else self.term_texts_early, definition_id, work)
+ cache = (self.term_texts_early, self.term_texts_designating, self.term_texts)[stage]
+ return self._while_open(cache, definition_id, work)
def _plain_open(self, definition_id: str, term: str) -> str | None:
"""The validator's *term* text of a definition as plain text, with
the definition marked open while its directives resolve."""
- key = f"{definition_id}\0{int(self._targets_known)}"
+ key = f"{definition_id}\0{self._stage}"
return self._while_open(self.term_texts_plain, definition_id, lambda: self._plain_value(term), key=key)
def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, work: Callable[[], str],
@@ -822,10 +1053,13 @@ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, wor
is limited. A text worked out when the cache already holds one for
this term (for other open terms) is a repeat, and the inline nodes
resolved inside a repeat, nested ones included, are counted; first
- texts are free. Past :data:`_MAX_TERM_WORK` such nodes, or with
- :data:`_MAX_TERM_DEPTH` terms open at once, the result is ``None`` (the
- reference shows its label or id), and the Warning ``render-term-limit``
- says so, once.
+ texts are free. Past :data:`_MAX_TERM_WORK` such nodes, with
+ :data:`_MAX_TERM_DEPTH` terms open at once, or inside an open term (or
+ while designations are worked out) at :data:`_MAX_NEST` levels, the
+ result is ``None`` (the reference shows its label or id), and the
+ Warning ``render-term-limit`` says so, once. While designations are
+ worked out nothing is reported there: the walk notes it
+ (:meth:`_limit_hit`), and ``render-designation-limit`` says so.
"""
key = definition_id if key is None else key
around = set(self._terms_open)
@@ -835,15 +1069,19 @@ def _while_open(self, cache: dict[str, list[_TermText]], definition_id: str, wor
self._cut |= kept.cut
return kept.text
repeat = bool(cache.get(key))
- if len(self._terms_open) >= _MAX_TERM_DEPTH or (repeat and self._term_work >= _MAX_TERM_WORK):
+ too_nested = self._nest >= _MAX_NEST and (self._terms_open or self._stage == _DESIGNATING)
+ if len(self._terms_open) >= _MAX_TERM_DEPTH or too_nested or (repeat and self._term_work >= _MAX_TERM_WORK):
# Terms that refer to each other this deeply or this often are
# not resolved further: like a cut, the reference shows its label
# or id, and nothing is kept.
- self._warn(
- "render-term-limit",
- "Defined terms refer to each other too deeply or too often to resolve fully; "
- "some {{term:}} references show their definition id.",
- )
+ if self._stage == _DESIGNATING:
+ self._limit_hit()
+ else:
+ self._warn(
+ "render-term-limit",
+ "Defined terms refer to each other too deeply or too often to resolve fully; "
+ "some {{term:}} references show their definition id.",
+ )
return None
self._repeating += repeat
self._terms_open.append(definition_id)
@@ -1220,6 +1458,26 @@ def key(self) -> tuple[object, object]:
return (self.source, self.number)
+def _compose(parts: tuple[tuple[str, str], ...], base: str) -> str:
+ """The designation of an item or paragraph whose pieces are *parts* in a
+ section designated *base*, under the ``none`` scheme: what
+ ``_structure_list`` and ``_number_paragraph`` write for it, for a *base*
+ that is not the one they were given."""
+ designation = base
+ for template, n in parts:
+ designation = extend(designation, fill(template, n=n, section=base), textual=True)
+ return designation
+
+
+def _walk(inlines: tuple[Inline, ...]) -> Iterator[Inline]:
+ """Every inline in *inlines*, and in the children of each."""
+ for inline in inlines:
+ yield inline
+ children = getattr(inline, "children", None)
+ if isinstance(children, tuple):
+ yield from _walk(children)
+
+
def fill_condition(template: str, value: str, *, key: str = "condition") -> str:
return template.replace("{" + key + "}", value)
diff --git a/tests/test_none_designations.py b/tests/test_none_designations.py
new file mode 100644
index 0000000..0420e2d
--- /dev/null
+++ b/tests/test_none_designations.py
@@ -0,0 +1,416 @@
+"""Designations under the ``none`` scheme (§13.3): a section is designated by its heading as
+rendered, references and terms resolved, and its items and paragraphs follow; what happens to
+headings that refer to each other in a circle, too deeply, or at too great a length."""
+from __future__ import annotations
+
+import random
+
+import pytest
+from conftest import FRONT, text
+
+from legaldown_render import RenderResult, render
+from legaldown_render.resolve.resolver import Resolver, _compose
+from legaldown_render.tree import plain_text
+
+NONE = {"numbering.scheme": "none"}
+
+
+def lines(body: str, **settings: object) -> list[str]:
+ return [line for line in text(body, overrides=NONE, **settings).splitlines() if line]
+
+
+def rules(result: RenderResult) -> list[str]:
+ return [d.rule for d in result.diagnostics if d.rule.startswith("render-")]
+
+
+def run(body: str, **overrides: object) -> RenderResult:
+ return render(FRONT + body, format="text", overrides={**NONE, **overrides})
+
+
+def designations(body: str, **overrides: object) -> list[str]:
+ return [s.designation for s in run(body, **overrides).tree.sections]
+
+
+# -- What a heading designates ---------------------------------------------------
+
+def test_a_reference_in_a_heading_shows_the_designation_of_its_target() -> None:
+ out = lines("# About {{ref: ov}} here {#h}\n\n# Overview {#ov}\n\nSee {{ref: h}}.\n")
+ assert out[1:4] == ["About Overview here", "Overview", "See About Overview here."]
+
+
+def test_a_reference_to_a_later_heading_is_resolved_through_a_chain() -> None:
+ body = "# A {{ref: b}} {#a}\n\n# B {{ref: c}} {#b}\n\n# C {#c}\n\nSee {{ref: a}}, {{ref: b}}.\n"
+ assert designations(body) == ["A B C", "B C", "C"]
+ assert "See A B C, B C." in text(body, overrides=NONE)
+
+
+def test_a_reference_to_an_earlier_heading_too() -> None:
+ body = "# C {#c}\n\n# B {{ref: c}} {#b}\n\n# A {{ref: b}} {#a}\n"
+ assert designations(body) == ["C", "B C", "A B C"]
+
+
+def test_a_reference_formats_the_designation_inside_the_heading() -> None:
+ body = "# A {{ref: b}} {#a}\n\n# B {#b}\n\nSee {{ref: a}}.\n"
+ assert designations(body, **{"references.format": "'{designation}'"}) == ["A 'B'", "B"]
+
+
+def test_a_broken_reference_in_a_heading_shows_its_marker() -> None:
+ assert designations("# A {{ref: nope}} {#a}\n") == ["A [BROKEN REF: nope]"]
+
+
+def test_markup_is_dropped_and_white_space_is_collapsed() -> None:
+ assert designations("# Pay *by* **{{ref: b}}** now {#a}\n\n# B {#b}\n") == ["Pay by B now", "B"]
+
+
+def test_a_term_with_a_reference_in_a_heading() -> None:
+ body = ('# About the {{term: fee}} {#fee-h}\n\n# Overview {#ov}\n\n"Fee per {{ref: ov}}" {{def: fee}}\n\n'
+ "See {{ref: fee-h}}.\n")
+ out = lines(body)
+ assert "About the Fee per Overview" in out
+ assert "See About the Fee per Overview." in out
+ assert "Fee per Overview" in out # the definition, as before
+
+
+# -- Items and paragraphs follow their section -------------------------------------
+
+def test_items_follow_the_final_designation_of_their_section() -> None:
+ body = "# Termination {{ref: x}} {#t}\n\n- one {#it}\n - nested {#nn}\n\n# X {#x}\n\nSee {{ref: it}} and {{ref: nn}}.\n"
+ assert "See Termination X (a) and Termination X (a) (i)." in text(body, overrides=NONE)
+
+
+def test_a_heading_may_refer_to_an_item_under_another_heading() -> None:
+ body = "# Later {{ref: it}} {#l}\n\nSee {{ref: l}}.\n\n# Home {#h}\n\n- first {#it}\n - nested {#nn}\n\n# X {{ref: nn}} {#x}\n"
+ assert designations(body) == ["Later Home (a)", "Home", "X Home (a) (i)"]
+ assert "See Later Home (a)." in text(body, overrides=NONE)
+
+
+def test_paragraph_designations_follow_too() -> None:
+ body = "# T {{ref: b}} {#a}\n\nOne. {#p}\n\n# B {#b}\n\nSee {{ref: p}}.\n"
+ out = text(body, overrides={**NONE, "paragraphs.numbered": True,
+ "paragraphs.format": {"counter": "lower-alpha", "label": "({n})", "ref": "({n})"}})
+ assert "See T B (a)." in out
+
+
+def test_a_reference_to_an_unnumbered_item_shows_the_final_heading() -> None:
+ result = run("# A {{ref: b}} {#a}\n\n- one {#i}\n\n# B {#b}\n\nSee {{ref: i}}.\n", **{"enumeration.enabled": False})
+ assert "See A B." in result.output
+ assert [d.rule for d in result.diagnostics if d.rule == "ref-not-enumerated"] == ["ref-not-enumerated"]
+
+
+def test_an_empty_heading_designation_leaves_the_item_piece_alone() -> None:
+ assert designations('# {{def: x}} {#e}\n\n- one {#i}\n\n# T {{ref: i}} {#t}\n') == ["", "T (a)"]
+
+
+# -- render-ref-ambiguous reads the final designations ---------------------------------
+
+def test_headings_that_differ_once_resolved_are_not_ambiguous() -> None:
+ body = "# Part {{ref: a}} {#p1}\n\n# Part {{ref: b}} {#p2}\n\n# Alpha {#a}\n\n# Beta {#b}\n\nSee {{ref: p1}}.\n"
+ result = run(body)
+ assert "See Part Alpha." in result.output
+ assert "render-ref-ambiguous" not in {d.rule for d in result.diagnostics}
+
+
+def test_headings_that_are_the_same_once_resolved_are_ambiguous() -> None:
+ result = run("# Fee {{ref: x}} {#f1}\n\n# Fee Xylo {#f2}\n\n# Xylo {#x}\n\nSee {{ref: f1}}.\n")
+ assert "render-ref-ambiguous" in {d.rule for d in result.diagnostics}
+
+
+def test_alternative_headings_keep_their_own_text_and_the_first_is_referenced() -> None:
+ questions = FRONT.replace("---\n", "---\nquestions:\n forum:\n type: choice\n choices:\n courts: Courts\n"
+ " arbitration: Arbitration\n", 1)
+ body = ("# Disputes {{ref: z}} {#d when=forum:courts}\n\nSee {{ref: d}}.\n\n"
+ "# Disputes {{ref: y}} {#d when=forum:arbitration}\n\n# Z {#z}\n\n# Y {#y}\n")
+ result = render(questions + body, format="text", overrides=NONE)
+ assert [s.designation for s in result.tree.sections] == ["Disputes Z", "Disputes Y", "Z", "Y"]
+ assert "See Disputes Z." in result.output
+ assert "render-ref-ambiguous" not in {d.rule for d in result.diagnostics}
+
+
+# -- Anchors are given out once ------------------------------------------------------------
+
+def test_a_rebuild_keeps_every_anchor() -> None:
+ body = ('# *The "Buyer" {{def:}}* {{ref: b}} {#a}\n\n- one {#it}\n\nSee {{ref: it}}.\n\n# B {#b}\n')
+ out = render(FRONT + body, format="html", standalone=False, overrides=NONE).output
+ assert 'id="a"' in out and 'id="it"' in out and 'id="b"' in out
+ assert 'id="def:buyer"' in out
+ assert 'href="#it"' in out
+ assert out.count('id="a"') == 1
+
+
+# -- Circles, depth, length ----------------------------------------------------------------------
+
+def test_headings_that_refer_to_each_other_are_cut_at_the_first_return() -> None:
+ result = run("# A {{ref: b}} {#a}\n\n# B {{ref: a}} {#b}\n\nSee {{ref: a}}.\n")
+ assert [s.designation for s in result.tree.sections] == ["A B A", "B A"]
+ assert rules(result) == ["render-designation-cycle"]
+ assert "'a'" in next(d.message for d in result.diagnostics if d.rule == "render-designation-cycle")
+
+
+def test_a_heading_that_refers_to_itself_or_to_its_own_item() -> None:
+ assert rules(run("# A {{ref: a}} {#a}\n")) == ["render-designation-cycle"]
+ assert rules(run("# A {{ref: i}} {#a}\n\n- one {#i}\n")) == ["render-designation-cycle"]
+
+
+def _doubling(n: int) -> str:
+ return "".join(f"# H{i} {{{{ref: h{i + 1}}}}} {{{{ref: h{i + 1}}}}} {{#h{i}}}\n\n" for i in range(1, n)) + f"# H{n} {{#h{n}}}\n"
+
+
+def test_a_doubling_chain_is_capped_and_reported_once() -> None:
+ result = run(_doubling(200))
+ assert max(len(s.designation) for s in result.tree.sections) <= 1000
+ assert rules(result) == ["render-designation-limit"]
+ assert len(result.output) < 200_000
+
+
+def test_a_long_heading_without_references_is_not_cut_but_a_reference_to_it_is() -> None:
+ heading = "word " * 300
+ unreferenced = run(f"# {heading}{{#a}}\n\nText.\n")
+ assert rules(unreferenced) == []
+ assert len(unreferenced.tree.sections[0].designation) > 1000
+ referenced = run(f"# {heading}{{#a}}\n\nSee {{{{ref: a}}}}.\n")
+ assert rules(referenced) == ["render-designation-limit"]
+ shown = next(line for line in referenced.output.splitlines() if line.startswith("See "))
+ assert shown.endswith("\u2026.")
+ assert 990 < len(shown) - len("See \u2026.") <= 1000
+
+
+def test_thousands_of_references_to_a_long_heading_paste_a_bounded_text() -> None:
+ result = run("# " + "x" * 5000 + " {#a}\n\n" + "{{ref: a}} " * 2000 + "\n")
+ assert len(result.output) < 2000 * 1100
+
+
+def _chain(n: int, *, forward: bool = True) -> str:
+ ids = range(n) if forward else range(n - 1, -1, -1)
+ return "".join(f"# S{i} {{{{ref: s{i + 1}}}}} {{#s{i}}}\n\n" for i in ids) + f"# S{n} {{#s{n}}}\n"
+
+
+def test_a_long_forward_chain_is_cut_at_32_headings_and_reported() -> None:
+ result = run(_chain(500))
+ sections = result.tree.sections
+ assert sections[0].designation == " ".join(f"S{i}" for i in range(33))
+ assert sections[32].designation == " ".join(f"S{i}" for i in range(32, 65))
+ assert rules(result) == ["render-designation-limit"]
+
+
+def test_a_chain_inside_the_depth_limit_is_resolved_in_full() -> None:
+ result = run(_chain(30))
+ assert result.tree.sections[0].designation == " ".join(f"S{i}" for i in range(31))
+ assert rules(result) == []
+
+
+def _wrap(depth: int, inner: str) -> str:
+ out = inner
+ for i in range(depth):
+ mark = ("*", "_", "**", "__")[i % 4]
+ out = f"{mark}x {out} x{mark}"
+ return out
+
+
+def test_nested_formatting_does_not_exhaust_the_stack() -> None:
+ # The builder allows 100 levels in a tree; headings read through each other multiply that.
+ n = 60
+ body = "".join(f"# {_wrap(90, f'S{i} {{{{ref: s{i + 1}}}}}')} {{#s{i}}}\n\n" for i in range(n)) + f"# S{n} {{#s{n}}}\n"
+ result = run(body)
+ assert "{{" not in result.output
+ assert "render-designation-limit" in rules(result)
+
+
+# -- Work -----------------------------------------------------------------------------------------
+
+@pytest.fixture
+def directives(monkeypatch: pytest.MonkeyPatch): # noqa: ANN201
+ count = {"n": 0}
+ real = Resolver._directive
+
+ def counting(self, directive): # noqa: ANN001, ANN202
+ count["n"] += 1
+ return real(self, directive)
+
+ monkeypatch.setattr(Resolver, "_directive", counting)
+
+ def work(body: str) -> int:
+ count["n"] = 0
+ run(body)
+ return count["n"]
+
+ return work
+
+
+@pytest.mark.parametrize("make", [_chain, lambda n: _chain(n, forward=False), _doubling])
+def test_the_work_is_linear_in_the_headings_and_references(directives, make) -> None: # noqa: ANN001
+ small, large = directives(make(2500)), directives(make(5000))
+ assert large <= 2.1 * small
+ assert large <= 4 * 5000 + 100 # at most one directive per reference, in each of two passes
+
+
+def test_a_chain_of_terms_in_a_heading_is_still_worked_out_once_per_term(directives) -> None: # noqa: ANN001
+ n = 20
+ defs = [f'"T{i} {{{{term: t{i + 1}}}}} {{{{term: t{i + 1}}}}}" {{{{def: t{i}}}}}' for i in range(n)]
+ defs.append(f'"T{n}" {{{{def: t{n}}}}}')
+ body = "# About {{term: t0}}\n\nUsed: {{term: t0}}\n\n" + "\n\n".join(defs) + "\n"
+ assert directives(body) <= 8 * n + 60
+
+
+# -- Invariants on random documents --------------------------------------------------------------------
+
+def _document(rng: random.Random) -> str:
+ n = rng.randint(2, 6)
+ ids = [f"h{i}" for i in range(n)]
+ items = {i: [f"i{i}_{j}" for j in range(rng.randint(0, 3))] for i in range(n)}
+ out = []
+ for i in range(n):
+ words = [f"W{i}"]
+ later = [t for j in range(i + 1, n) for t in (ids[j], *items[j])] # later ones only: no circle
+ for _ in range(rng.randint(0, 2)):
+ if later:
+ words.append("{{ref: " + rng.choice(later) + "}}")
+ out.append(f"# {' '.join(words)} {{#{ids[i]}}}\n")
+ if items[i]:
+ out.append("\n".join(f"- item {{#{x}}}" for x in items[i]) + "\n")
+ everything = ids + [x for group in items.values() for x in group]
+ out.append("See {{ref: " + rng.choice(everything) + "}}.\n")
+ return "\n".join(out)
+
+
+def _anchors(result) -> list[str | None]: # noqa: ANN001
+ found: list[str | None] = []
+
+ def walk(blocks) -> None: # noqa: ANN001
+ for block in blocks:
+ for item in getattr(block, "items", ()):
+ found.append(item.anchor)
+ walk(item.blocks)
+
+ for section in result.tree.sections:
+ found.append(section.anchor)
+ walk(section.blocks)
+ return found
+
+
+@pytest.mark.parametrize("style", [None, "continental"])
+def test_designations_equal_headings_as_rendered_and_anchors_do_not_move(
+ monkeypatch: pytest.MonkeyPatch, style: str | None) -> None:
+ seen: list[Resolver] = []
+ real = Resolver.run
+ monkeypatch.setattr(Resolver, "run", lambda self: (seen.append(self), real(self))[1])
+ rng = random.Random(20261027)
+ for _ in range(60):
+ source = FRONT + _document(rng)
+ result = render(source, format="text", style=style, overrides=NONE)
+ for section in result.tree.sections:
+ assert " ".join(plain_text(section.title).split()) == section.designation, source
+ resolver = seen[-1]
+ for target in resolver.targets.values():
+ base = result.tree.sections[target.section_index].designation
+ assert _compose(target.parts, base) == target.designation, source
+ plain = render(source.replace("{{ref:", "{{party:"), format="text", style=style, overrides=NONE)
+ assert _anchors(result) == _anchors(plain), source
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+@pytest.mark.parametrize("heading", [False, True])
+def test_terms_with_nested_formatting_do_not_exhaust_the_stack(scheme: str, heading: bool) -> None:
+ n = 40
+ defs = "".join(f'"T{i} {_wrap(60, "{{term: t" + str(i + 1) + "}}")}" {{{{def: t{i}}}}}\n\n' for i in range(n))
+ title = "A {{term: t0}}" if heading else "A"
+ body = f"# {title}\n\nUsed: {{{{term: t0}}}}\n\n{defs}" + f'"End" {{{{def: t{n}}}}}\n'
+ result = render(FRONT + body, format="text", overrides={"numbering.scheme": scheme})
+ assert "{{" not in result.output
+ assert "render-term-limit" in {d.rule for d in result.diagnostics}
+
+
+# -- Limits that are reached inside terms --------------------------------------------------------
+
+
+def test_a_term_cut_by_its_limits_while_designating_is_reported() -> None:
+ # Two terms per hop: past 32 open terms a {{term:}} shows its id, which the walk must not do silently.
+ n = 20
+ defs = "".join(f'"A{i} {{{{term: b{i}}}}}" {{{{def: a{i}}}}}\n\n"B{i} {{{{ref: h{i + 1}}}}}" {{{{def: b{i}}}}}\n\n'
+ for i in range(n))
+ heads = "".join(f"# H{i} {{{{term: a{i}}}}} {{#h{i}}}\n\n" for i in range(n)) + f"# H{n} {{#h{n}}}\n\n"
+ result = run(heads + defs)
+ assert rules(result) == ["render-designation-limit"]
+ assert "terms" in next(d.message for d in result.diagnostics if d.rule == "render-designation-limit")
+ assert "{{" not in result.output
+
+
+def test_a_term_inside_deeply_nested_formatting_is_cut_inside_an_open_term_and_reported() -> None:
+ n = 12
+ defs = "".join(f'"T{i} {_wrap(8, "{{ref: h" + str(i + 1) + "}}")}" {{{{def: t{i}}}}}\n\n' for i in range(n))
+ heads = "".join(f"# H{i} {{{{term: t{i}}}}} {{#h{i}}}\n\n" for i in range(n)) + f"# H{n} {{#h{n}}}\n\n"
+ assert rules(run(heads + defs)) == ["render-designation-limit"]
+
+
+@pytest.mark.parametrize("scheme", ["decimal", "none"])
+def test_a_term_in_one_blocks_deepest_formatting_is_still_a_term(scheme: str) -> None:
+ # The builder allows 100 levels in a block; a term met there is not nested inside another unit.
+ body = f'# A\n\n{_wrap(99, "{{term: fee}}")}\n\n"Fee" {{{{def: fee}}}}\n'
+ result = render(FRONT + body, format="text", overrides={"numbering.scheme": scheme})
+ assert " Fee " in next(line for line in result.output.splitlines() if line.startswith("x "))
+ assert "render-term-limit" not in {d.rule for d in result.diagnostics}
+
+
+# -- Where a diagnostic about a heading, and the new Warnings, are ------------------------------------
+
+
+def _line(source: str, needle: str) -> int:
+ return next(number for number, line in enumerate(source.split("\n"), 1) if needle in line)
+
+
+def test_a_cycle_is_reported_at_the_heading_it_closes_on() -> None:
+ source = FRONT + "\nIntro.\n\n# A {{ref: b}} {#a}\n\nText.\n\n# B {{ref: a}} {#b}\n"
+ result = render(source, format="text", overrides=NONE)
+ assert [(d.rule, d.line) for d in result.diagnostics if d.rule == "render-designation-cycle"] == [
+ ("render-designation-cycle", _line(source, "# A "))]
+
+
+def test_a_limit_is_reported_at_the_heading_that_reached_it() -> None:
+ source = FRONT + "\n" + _chain(40)
+ result = render(source, format="text", overrides=NONE)
+ assert [d.line for d in result.diagnostics if d.rule == "render-designation-limit"] == [_line(source, "# S31 ")]
+ source = FRONT + "\n" + _doubling(40)
+ result = render(source, format="text", overrides=NONE)
+ lines = [d.line for d in result.diagnostics if d.rule == "render-designation-limit"]
+ assert len(lines) == 1 and lines[0] is not None
+
+
+def test_diagnostics_inside_a_referenced_heading_are_reported_once_at_that_heading() -> None:
+ source = (FRONT + "\n# A [x](ms-msdt:1)  {{include: f.lgd}} {#a}\n\n"
+ "# B {{ref: a}} {#b}\n\n# C {{ref: b}} {#c}\n\nSee {{ref: c}} and {{ref: a}}.\n")
+ result = render(source, format="text", overrides=NONE)
+ for rule in ("render-link-refused", "path-not-relative", "render-not-processed"):
+ assert [d.line for d in result.diagnostics if d.rule == rule] == [_line(source, "# A ")], rule
+
+
+def test_a_reference_inside_a_heading_is_a_link_there_and_not_in_the_table_of_contents() -> None:
+ result = render(FRONT + "# A {{ref: b}} {#a}\n\n# B {#b}\n", standalone=False,
+ overrides={**NONE, "contents.enabled": True})
+ page = result.output
+ nav = page[page.index("
@@ -57,7 +57,7 @@ Python 3.11 or newer. The dependencies are all pure Python: `legaldown-validator
```bash
legaldown-render contract.lgd -o contract.html # HTML page
legaldown-render contract.lgd -o contract.txt # plain text (format from extension)
-legaldown-render contract.lgd -o contract.docx # a Word document (in progress, see below)
+legaldown-render contract.lgd -o contract.docx # a Word document with live numbers and references (see "DOCX")
legaldown-render contract.lgd --style continental --locale cs-CZ -o smlouva.html # Czech dates, money, durations
legaldown-render contract.lgd --set numbering.scheme=legal-outline --set enumeration.enabled=false
legaldown-render contract.lgd --set contents.enabled=true --set contents.depth=3 # table of contents
@@ -113,6 +113,8 @@ with open("contract.html", "w", encoding="utf-8", newline="\n") as output:
output.write(result.output)
```
+A Word document is bytes, not text: `render(text, format="docx").binary` holds the file (and `.output` is empty).
+
`render()` also accepts a `RenderOptions`. Pass `answers={...}` to render a template filled in.
Each diagnostic has its `line` and its `file`: `render_file()` and the command name the file (its
name, without the directory); for text given to `render()`, pass `filename=` (a directory in it is dropped, `/` or `\`).
@@ -128,46 +130,67 @@ cannot be assembled with its answers (`RenderRefused`) do. All of them are `Rend
boundaries (`multiprocessing`, `concurrent.futures`) intact. `overrides=` takes dotted settings and
may not contain `version` or `extends`, so `overrides=legaldown_render.style.style_to_dict(style)` must drop those two keys.
-## DOCX (in progress)
+## DOCX
-`-o contract.docx` (or `--format docx`, or `render(text, format="docx")`) writes a Word document. The
-file is bytes: the command writes it to a file or, with `-o -` (or no `-o` when standard output is not a
-terminal), to standard output, and refuses to write it to a terminal; `--fragment` is a usage error for it; `render()` returns it as `result.binary` (`result.output` is empty). The writer needs no
-dependency beyond the base package.
+`-o contract.docx` (or `--format docx`, or `render(text, format="docx")`) writes a Word document that a lawyer can
+edit. It is written with the standard library alone, so it needs nothing beyond the base install.
-The design is that **in the Word file every number, cross-reference, the table of contents and the page
-numbers are live Word features**, not text: numbering definitions and fields, with the values the renderer
-computed as their cached results, so a lawyer who inserts a clause gets the numbers, references and contents
-updated by Word
+**The point of it:** in the Word file the numbers, the cross-references, the table of contents and the page
+numbers are Word's own features, not typed text. Insert a clause in Word, update the fields, and the clauses after it,
+every "see 2.1(b)" and the contents follow. The renderer still works every value out; it writes the value only as the
+cached result of a field, so the document reads right before any field is updated
([ADR 0008](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/decisions/0008-docx-word-native.md)).
-**What is there today:** a valid `.docx` (checked against the ISO/IEC 29500 schemas, and opened in LibreOffice in CI)
-with styles generated from the style template (`Normal`, `heading 1` to `heading 5`, `Title`, `Quote`, `toc 1` to
-`toc 5`, `Hyperlink`, ...), the page size and margins, a running header and footer from `page.header` and
-`page.footer` (the default footer is "3 / 12" as live page fields), the document's title and language,
-`w:updateFields` so Word refreshes fields when it opens the file (`docx.update_fields_on_open`), and deterministic
-output. **Numbering and cross-references are live:** headings, list items and numbered paragraphs are numbered by Word
-numbering definitions (headings by the heading styles, nested lists restarting by structure), and a
-`{{ref: clause}}` is `REF` fields on bookmarks whose cached results are the text the plain-text writer prints, so
-"1(b)(ii)" or "I.A.1" stays right when a lawyer inserts a clause and updates fields. A defined term or an attachment
-reference is an internal link. A reference is written as its text (it will not follow edits, and the render reports it) only where Word's fields cannot reproduce it:
-a number with punctuation around its counter or with the counters of other units that the reference writes itself, and the number of a heading level the document skips (an Error); how far Word's `\t` switch is trusted to leave the words of a number out is the style's `docx.reference_text_switch`. Where Word cannot draw what the renderer does (letters past 26, a label that shows the
-section's number or the counters of enclosing items where Word's list cannot show them, a number restarted by a fixed value, a list restarted by a fixed setting), the render
-reports it as a `render-docx-*` Warning or Info with the line, see
-[the conformance notes](https://github.com/ForLegalAI/legaldown-render/blob/main/CONFORMANCE.md). The body writer also
-lays out paragraphs, quotes, drafting notes, code, tables with a repeated header row, rules, links, blanks and
-choices. **Front and back matter are there too, in the order the HTML shows them:** the title and subtitle, the header
-rows and the parties (borderless tables), the **table of contents** (a live `TOC \o "1-N" \h \z \u` field whose
-cached entries are the contents the HTML shows, each a link to its heading with a `PAGEREF` page number that Word
-fills in when it updates the field; with `contents.attachments` the attachments are listed by outline level, so
-the field has no locale-dependent style list), the attachments (the heading and a titled placeholder each, bookmarked so
-that `{{attach:}}` is a `REF` field or a link, separated by a page break or a rule as `attachments.separator` says),
-and the signature blocks (borderless tables with signing lines, kept together on one page as far as one page allows). Viewers that never update
-fields (Word Online, Quick Look, Google Docs import, Outlook's preview) show the contents without page numbers. A
-reference whose form Word cannot compose from fields (a custom style's `{path}` item labels, say) is fixed text and
-is reported as `render-docx-reference`. The maintainers also check the golden documents in Word before each release ([the checklist](https://github.com/ForLegalAI/legaldown-render/blob/main/.github/PUBLISHING.md)), since CI cannot run Word. Settings Word cannot express (a `%` length, say) are an error for DOCX only. See
-[Styles and preferences](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/style-templates.md#docx)
-and the [roadmap](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/roadmap.md).
+**What is written.** The same document the HTML shows, in the same order: the title block (header rows and parties),
+the table of contents, the sections, the attachments (a heading and a titled placeholder each) and the signature blocks.
+Paragraphs, lists, quotes, drafting notes and the template view's condition labels, code, tables with a repeated header
+row, rules, links, defined terms, blanks and choices have Word styles generated from the style template, so a change to
+a style in Word applies everywhere. The page size, margins, fonts and colours are the style's, and the file carries the
+document's title and language.
+
+**What is live:**
+
+- Heading numbers (the heading styles are linked to a multilevel list), list items and numbered paragraphs are Word
+ numbering definitions. A nested list restarts under a new parent item, and a clause inserted before another renumbers it.
+- A `{{ref: clause}}` is `REF` fields on bookmarks, joined by the delimiters of the designation, so "1(b)(ii)" or
+ "I.A.1" is recomputed. `{{term:}}` and `{{attach:}}` are links (an attachment's title is a field).
+- The table of contents is a `TOC` field (`contents.enabled`, `contents.depth`), each entry a link with a `PAGEREF`
+ page number. The running header and footer are `page.header` and `page.footer`: the page number and the page count
+ are fields (the default footer reads "3 / 12").
+
+**Opening it.** Word asks once whether to update the fields (`docx.update_fields_on_open`, on by default), and then the
+cross-references and the contents with their page numbers are up to date. After an edit, press Ctrl+A, then F9, or
+right-click the contents and choose *Update field*. LibreOffice ignores the request (Tools, Update, All Indexes and
+Tables). A viewer that never updates fields (Word on the web, Quick Look, a Google Docs import, Outlook's preview) shows
+the numbers and references right, as cached, and the contents without page numbers.
+
+**Settings** (the style template, [Styles and preferences](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/style-templates.md#docx)):
+
+| Setting | Effect in the DOCX |
+|---|---|
+| `page.size`, `page.margin` | paper and margins; a length Word cannot express (`%`, `ch`, `ex`) is a style error, reported before anything is rendered |
+| `page.header`, `page.footer` | left, centre and right text with `{page}`, `{pages}`, `{title}`, `{version}`, `{date}` |
+| `docx.update_fields_on_open` | `true` (the default): Word updates every field when it opens the file; `false` writes no such request |
+| `docx.reference_text_switch` | `off`, `documented` (the default) or `guess`: how far a cross-reference may rely on Word's `\t` switch to leave the words of a number out (`Article I` shown as `I`) |
+| `contents.*`, `attachments.separator`, `signatures.enabled`, `numbering.*`, `enumeration.*`, `paragraphs.*`, `typography.*`, `headings.*` | as in the other formats; the DOCX meaning of each is in the style documentation |
+
+**Limits.** Word cannot do everything the renderer does, and the render says where, one `render-docx-*` diagnostic
+with the line (the rules are listed in [the conformance notes](https://github.com/ForLegalAI/legaldown-render/blob/main/CONFORMANCE.md#docx-output-136)):
+
+- A cross-reference that Word's fields cannot compose (a number with punctuation around its counter that the reference
+ leaves out, say, or the number of a heading level the document skips) is written as its text: right when the file is
+ opened, but it does not follow edits.
+- Where Word draws a number differently (letters past 26, Roman numerals from 4000, a label that shows the section's
+ number on a list that cannot carry it), or restarts a list by a fixed setting instead of by structure, the file is
+ right when it is opened and does not follow every edit made in Word.
+- Alternatives that share a number (§15.8, in the template view) show it as a field of the first: deleting the first in
+ Word breaks the others. Render with the answers or condition values to get a final document.
+- An image is shown as its alt text: no file is read (§2.3, §17.6).
+- CI checks every part against the ISO/IEC 29500 schemas and opens the documents in LibreOffice, which updates the
+ fields and is compared with the plain-text output. It cannot run Word, so the maintainers check the golden documents
+ in Word before a release ([the checklist](https://github.com/ForLegalAI/legaldown-render/blob/main/.github/PUBLISHING.md)).
+- `--fragment` does not apply: a Word document is always whole. On the command line the file goes to `-o FILE.docx`, or
+ to standard output with `-o -` (or without `-o` when standard output is not a terminal); it is never written to a terminal.
## Rendering documents you did not write
@@ -238,9 +261,10 @@ articles, 2.1-style items, numbered paragraphs), and `outline` (I. / A. / 1. / a
```bash
pip install -e ".[dev]"
-pytest # unit, golden, and CLI tests
+pytest # unit, golden, and CLI tests (pytest -n auto: on all cores, about a third of the time)
python tests/schemas/fetch.py && pytest -m ooxml_schema # DOCX parts against the OOXML schemas (needs lxml)
pytest -m libreoffice # DOCX opened in LibreOffice (needs soffice and python3-uno; skipped without)
+LEGALDOWN_SPEC_DIR=../LegalDown pytest -m "libreoffice or ooxml_schema" # also the DOCX acceptance corpus (tests/test_docx_acceptance.py)
pytest --update-golden # regenerate tests/golden/ after an intended output change
LEGALDOWN_SPEC_DIR=../LegalDown pytest -m conformance # the specification's examples and fixtures
ruff check .
diff --git a/docs/architecture.md b/docs/architecture.md
index 4af03cb..89cb5a6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -671,7 +671,14 @@ out when the validator reported the same path at the same line, or at none (`dia
## Testing
Each module is named for its topic. Shared helpers (the minimal document header, the `text` and
-`html` renderers, and running the command as a program) are in `tests/conftest.py`.
+`html` renderers, and running the command as a program) are in `tests/conftest.py`; those for the DOCX tests
+(reading a package, validating it, running LibreOffice) are in `tests/docx_support.py` and `tests/lo/`.
+
+Markers: `conformance` (the specification's documents), `ooxml_schema` (DOCX parts against the schemas), `libreoffice`
+(DOCX opened in LibreOffice; skipped without `soffice` and `python3-uno`, a failure when `LEGALDOWN_REQUIRE_DOCX_TOOLS`
+is set, as in the CI job that installs them) and `acceptance`: the DOCX acceptance corpus, whose run takes about half a
+minute of LibreOffice time and is left out of a plain `pytest` (it runs with `-m`, with `LEGALDOWN_ACCEPTANCE=1`, or when
+its file is named). `pytest -n auto` (pytest-xdist, in the `dev` extra) runs the whole suite in a third of the serial time.
| Suite | What it checks |
|---|---|
@@ -705,7 +712,8 @@ Each module is named for its topic. Shared helpers (the minimal document header,
| `tests/test_docx_special.py` | What the planner's hard cases become in the file: the heading list's levels that hold items, the reserved levels of lists nested in them, the detached and the next-nine-levels abstractNums, and later alternatives as `numId 0`, `w:ind` and `REF` fields; and that documents with them validate against the schemas (marker `ooxml_schema`) |
| `tests/test_docx_validation.py` | Every part of every generated document against the ISO/IEC 29500-4 schemas (marker `ooxml_schema`; needs lxml and `python tests/schemas/fetch.py`) |
| `tests/test_docx_roundtrip.py` | LibreOffice as the oracle of the numbering and the cross-references, in one LibreOffice session: every document of the corpus (the tests', the specification's, stress documents with cycles and unanchored ancestors) under the default, outline, continental, mixed and `none` schemes, with every cached field result made wrong (`XXX`), every numbered paragraph marked and a line per reference added; the number LibreOffice draws, every reference it computes and every body paragraph's text equal what the file cached and the renderer wrote, modulo the gaps the plan reports (`\t` ignored, letters past 26, labels the heading list cannot show); alternatives are checked against the renderer (their `numId 0` number is the holder's, as fields), and so are the continental items on the heading list and the lists on its reserved levels; the table of contents is built by LibreOffice from a `TOC` field the test adds and must list the headings with the numbers the plan gives and none of the items; and edit-then-update (`tests/lo/uno_edit.py`, which inserts and deletes paragraphs, and can put an inserted paragraph on another item's list as pasting a copy does): path labels on the reserved levels, in a detached list (and the documented continuation of a list copied under a new parent) and past nine levels, a heading inserted before a document that skips a level, items, headings, a clause and a nested item inserted before the targets as a lawyer does, fields updated, every reference and the numbers equal the renderer's for the edited source (the new nested item under the continental style reads `a)`), and, for a sub-heading restarted by a fixed value, the plan's warning and what an edit before it keeps right (marker `libreoffice`) |
+| `tests/test_docx_acceptance.py` | The measurable claims of ADR 0008 over the whole corpus (the tests' documents, the specification's examples and valid fixtures, the stress documents) under the default, outline and continental styles, with the contents on. **Equivalence:** LibreOffice's text, with fields and indexes updated, equals the plain-text writer's, line by line, modulo the differences the formats have by nature (one documented function: borders for rules and signing lines, table cells, quote and link markup, where a condition label stands, `label: value` rows, the contents' page numbers, which are checked to be positive, never decreasing and within the page count) and modulo the gaps the plan reports; the documents that differ are exactly the stress documents that ask for what Word draws differently. **Live after edits:** in one LibreOffice session, a heading, an item, a nested item, a numbered paragraph (continental) are inserted and a clause deleted in up to ten documents under each style, and the result equals the plain-text writer's for the source with the same edits (a difference is allowed only where the render reports a fixed number). **Validity and determinism:** every part validates and two renders are the same bytes. Negative controls show the comparison fails on a wrong number, a missing line, pages out of order |
| `tests/test_docx_matter.py` | The front and back matter: the XML of the title block, the `TOC` field and its cached entries, the attachments and the signature blocks; documents with every part (hostile text too) against the schemas; LibreOffice listing exactly the contents' entries after an update, resolving `{{attach:}}`, paging the attachments as configured, taking a heading inserted in the editor into the contents, and reading in the text writer's order |
-| `tests/test_docx_libreoffice.py` | The documents opened in LibreOffice through UNO (`tests/lo/uno_export.py`), fields updated: the words in order, header, footer, page count (marker `libreoffice`; skipped without `soffice` and `python3-uno`) |
-| `tests/test_docx_wordcheck.py`, `tests/word_check.py` | The generator of the document for the manual Word checks of what the planner assumes (`python tests/word_check.py OUT.docx`; `.github/PUBLISHING.md` items 8 and 9): one row for each label form the plan relies on (`\t` on `Article %1`, `§ %1`, `Section %1.%2`, `Čl. %1`, `No. %1`, `(%1)`, `%1)`, `%1:`), the heading list's reserved level `%1.%2(%6)` with levels 3 to 5 unused, and a skipped heading level, each with the planner's assumption beside it taken from the planner's own code; that the package is valid (marker `ooxml_schema`) and LibreOffice computes the plain fields as the planner caches them (marker `libreoffice`) |
+| `tests/test_docx_libreoffice.py` | The documents opened in LibreOffice through UNO (`tests/lo/uno_export.py`), fields updated: the words in order, header, footer, and the page count and the page numbers compared with each other and with page breaks, not with the page count of a font (marker `libreoffice`; skipped without `soffice` and `python3-uno`) |
+| `tests/test_docx_wordcheck.py`, `tests/word_check.py` | The generator of the document for the manual Word checks of what the planner assumes (`python tests/word_check.py OUT.docx`; `.github/PUBLISHING.md` items 8 and 9): one row for each label form the plan relies on (`\t` on `Article %1`, `§ %1`, `Section %1.%2`, `Čl. %1`, `No. %1`, `(%1)`, `%1)`, `%1:`), the heading list's reserved level `%1.%2(%6)` with levels 3 to 5 unused, and a skipped heading level, each with the planner's assumption beside it taken from the planner's own code; that the package is valid (marker `ooxml_schema`) and LibreOffice computes the plain fields as the planner caches them (marker `libreoffice`); and that the Python snippet in the checklist still runs and makes every file the checklist names, including the document of the level nothing numbered |
| `tests/conformance/` | Every example and fixture in the specification repository renders, every internal link has a target, and each invalid fixture renders its rule's failure marker. Needs `$LEGALDOWN_SPEC_DIR` or a sibling `../LegalDown` checkout |
diff --git a/docs/decisions/0008-docx-word-native.md b/docs/decisions/0008-docx-word-native.md
index 64bc701..2e511cd 100644
--- a/docs/decisions/0008-docx-word-native.md
+++ b/docs/decisions/0008-docx-word-native.md
@@ -65,6 +65,16 @@ Also decided here, because every later part rests on them:
- **Compatibility mode 15**, so Word does not open the file in Compatibility Mode. The settings are in
the order the schema requires (`updateFields` follows `characterSpacingControl` and precedes
`compat`), and contain no revision ids.
+- **Front and back matter.** The table of contents is a `TOC \o "1-N" \h \z \u` field whose cached result is the
+ planner's entries (each a link to a `_Toc` bookmark with a `PAGEREF` field whose cached page is empty, since only
+ Word knows the pages). The attachments heading and each attachment's title are paragraphs of styles with an outline
+ level, so `\u` lists them and the field needs no `\t` list of styles (whose separator depends on the locale). The
+ contents label has no outline level, so it is not an entry, and a condition label is a run inside its heading's
+ paragraph so that the contents keep it. A page break between attachments is the paragraph property "page break
+ before". The title block, the parties and the signature blocks are borderless tables.
+- **Output.** A DOCX is bytes: `RenderResult.binary` holds the file and `RenderResult.output` stays `str` (empty), so no
+ typed caller breaks. The command writes `-o FILE.docx`, or standard output with `-o -`, never to a terminal. Images
+ are shown as their alt text: below the Full level no file is read (§2.3, §17.6).
- **Safety.** One sanitiser is applied to every text, attribute, field instruction, bookmark
name, relationship and property value. It drops the characters XML cannot carry. Without it
`xml.etree` writes a control character raw and a lone surrogate as a character reference, and Word
@@ -174,7 +184,11 @@ Under the `none` scheme a reference is a `REF` to the heading text.
- Verification is part of the design: every part is validated against the schemas, the documents are
opened in LibreOffice (the oracle available in CI, which updates fields and reports the text), and
the same documents are checked by hand in Word before a release. LibreOffice ignores `\t`,
- `lvlRestart`, `SEQ \s` and `w:updateFields`, so those differences are normalised in the comparison.
+ `lvlRestart`, `SEQ \s` and `w:updateFields`, fills a skipped heading level with its start value, and counts
+ a level that nothing in a list numbered before a deeper item as 1 where Word is expected to read it as 0
+ (`render-docx-libreoffice-count`), so those differences are normalised in the comparison and the Word-only ones
+ are on the checklist. The acceptance corpus compares whole documents with the plain-text writer's output under
+ three styles, before and after edits made in the editor.
- Cost: an OOXML writer of our own, with its schema checks, instead of a library. The numbering
planner is the bulk of the work.
- Style template additions: `page.header`, `page.footer`, `docx.update_fields_on_open` and
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b8cef08..f412038 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -90,48 +90,47 @@ Rendering what other people send, and reporting problems where they are. No new
self-contained
- **Project.** CI also runs on Windows, and publishing to PyPI waits for CI
-### v0.5 — DOCX (in progress)
+### v0.5 — DOCX (implemented; released after the Word checks)
A document lawyers can edit: in the .docx every number, cross-reference, the table of contents and
the page numbers are live Word features (numbering definitions and fields), and the renderer's
values appear only as the cached results of fields. Decided in
[ADR 0008](decisions/0008-docx-word-native.md), which also settles the question 0004 left open.
-- **Package core (done).** `docx` writer in the base package, with the standard library only (no
- `[docx]` extra): the OOXML parts, styles generated from the style template, settings (compatibility
- mode 15, `w:updateFields`), page setup, running headers and footers with `PAGE`/`NUMPAGES` fields,
- document properties, deterministic output. `RenderResult.binary`, `-o x.docx`, `-o -`. The body at that point
- was an interim one: the title, `heading N` paragraphs and plain-text paragraphs, with no numbers yet
-- **Behaviour changes this brings.** `-o -` now writes to standard output for every format (it used to
- create a file named `-`); `.docx` is a writable extension, and a DOCX with no `-o` goes to standard output
- when that is not a terminal; `--fragment` with DOCX is a usage error; `RenderTree` gains `version` and
- `effective_date` (plain text), filled whether or not the title block shows them; `.docx` renders added
- the Warning `render-docx-interim` until the body was complete (it is gone since the front and back matter)
-- **Style settings (done).** `page.header` and `page.footer` (the HTML writer ignores them for now),
- `docx.update_fields_on_open`; settings Word cannot express are a `StyleError` for DOCX
-- **Verification (done).** Every part validated against the ISO/IEC 29500 schemas (fetched in CI, not
- stored), well-formedness fuzzing, a determinism test, and LibreOffice opening the documents through
- UNO as the oracle (a `docx` CI job)
-- **Body writer (done).** `numbering.xml`, numbered paragraphs, bookmarks, `REF` fields with cached results,
- term hyperlinks, and every block and inline, written from a `DocxPlan` (`resolve/docx_plan.py`)
-- **Planner: numbering, cross-references and contents plan (done).** The tree is mapped to Word numbering
- (heading list, list families, paragraphs) and checked against the resolver with a model of Word's counters;
- every `{{ref:}}` is covered by `REF` fields and delimiters (the designation cover), every anchor has a bookmark,
- and the table of contents is planned from Word's numbers; `render` runs the planner and reports its
- `render-docx-*` diagnostics. A LibreOffice round trip with wrong cached results checks the numbers and references
- against the plain-text oracle
-- **Front and back matter (done).** The title block (header rows and parties), the `TOC` field with its cached
- entries and `PAGEREF` page numbers, the attachments (bookmarked, separated by a page break or a rule) and the
- signature blocks; the `render-docx-interim` Warning is gone. A LibreOffice round trip checks the contents against the
- HTML's entries, also after a heading is inserted in the editor; a manual Word checklist for releases is in
- `.github/PUBLISHING.md`
-- **Planner: qualified labels, alternatives and resynchronised numbers (done).** The continental `5.1` items and the
- numbered paragraphs ride the heading list's level after their section's, and lists nested in them its reserved
- levels, so they restart by structure; alternatives that share a number (§15.8) are `numId 0` paragraphs whose
- number is a `REF` field of the first, with the units under them on a num of their own; a unit Word would count
- off the resolver's is restarted at a fixed value and reported. Clauses and nested items inserted through UNO are
- numbered as the renderer numbers them
-- **Next.** Running the manual Word checklist, which finds what only Word shows
+- **The writer.** A `docx` writer in the base package, written with the standard library only (no `[docx]`
+ extra): the OOXML parts, styles generated from the style template, settings (compatibility mode 15,
+ `w:updateFields`), page setup, running headers and footers with `PAGE`/`NUMPAGES` fields, document
+ properties, and deterministic bytes. The body is every block and inline: headings, paragraphs, lists, quotes,
+ drafting notes, code, tables with a repeated header row, rules, links, defined terms, blanks and choices
+ (images as their alt text), then the front and back matter: the title block, a live `TOC` field with cached
+ entries and `PAGEREF` page numbers, the attachments and the signature blocks
+- **The planner** (`resolve/docx_plan.py`): the render tree carries the numbering structure of every unit, and a pure
+ planning step maps it to Word numbering (the heading list, list families, paragraphs, qualified `5.1` labels on the
+ heading list's levels, alternatives that share a number as `numId 0` paragraphs with a `REF` field, restarts by
+ structure or by a fixed value), to bookmarks, and to the fields of every `{{ref:}}` (the designation cover), with the
+ cached results taken from a model of Word's counters. Where Word draws differently from the renderer, the render
+ reports a `render-docx-*` diagnostic (listed in [`CONFORMANCE.md`](../CONFORMANCE.md))
+- **Style settings.** `page.header` and `page.footer` (the HTML writer ignores them for now),
+ `docx.update_fields_on_open` and `docx.reference_text_switch`; settings Word cannot express are a `StyleError`
+ for DOCX only
+- **Verification.** Every part validated against the ISO/IEC 29500 schemas (fetched in CI, not stored), well-formedness
+ fuzzing, determinism, and LibreOffice as the oracle through UNO (a `docx` CI job): the numbers and references
+ unit by unit with the cached results made wrong on purpose, whole documents against the plain-text writer's
+ output under three styles, and edit-then-update (a heading, an item, a numbered paragraph inserted, a clause
+ deleted, as a lawyer does). The manual Word checklist in `.github/PUBLISHING.md` covers what only Word shows
+- **Behaviour changes this brings.**
+ - `-o -` writes to standard output for every format (it used to create a file named `-`).
+ - `.docx` is a writable extension; a DOCX with no `-o` goes to standard output when that is not a terminal, and is
+ never written to a terminal. `--fragment` with DOCX is a usage error.
+ - `render(..., format="docx")` returns the file as `RenderResult.binary` (bytes); `RenderResult.output` stays
+ `str` and is empty for it. `FORMATS` gains `docx`.
+ - `RenderTree` gains fields for the numbering structure (`formats`, and on sections, list items and
+ paragraphs `unit`, `number`, `parent_unit`, `shares_number_with`; `List.family`; `CrossRef.target_unit`,
+ `section_unit`) and `version` and `effective_date` (plain text, filled whether or not the title block shows
+ them). HTML and plain-text output are byte-identical to 0.4 (the goldens are unchanged).
+ - New `render-docx-*` diagnostics for DOCX renders only.
+- **Next.** Running the manual Word checklist, which finds what only Word shows; its findings are recorded in the
+ release notes. Whether the writer then claims §13.6 for DOCX is decided in `CONFORMANCE.md`
### v0.6 — PDF
diff --git a/docs/style-templates.md b/docs/style-templates.md
index e312a00..6dd95f3 100644
--- a/docs/style-templates.md
+++ b/docs/style-templates.md
@@ -279,6 +279,18 @@ Settings that were already part of the template gain a DOCX meaning:
| `signatures.enabled` | Borderless tables with a signing line, kept together on one page as far as one page allows. They follow the last attachment, as in the HTML. A name, title, date or place row whose label is blanked (`labels.signature_*`) and whose value is empty is left out, as the plain-text writer leaves it out |
| `title_block.parties`, `title_block.effective_date`, `title_block.version` | The header rows and the parties, as borderless tables |
+The numbering and wording settings are the same as in the other formats; what they become in the file:
+
+| Setting | In the DOCX |
+|---|---|
+| `numbering.*` | The multilevel list the heading styles are linked to: `counter` is the level's number format, `label` its level text (`{n}` the level's own counter, `{path}` the counters of the levels above it joined as the label says). A label with a literal `%` before a digit or a counter cannot be written as a level text and is a style error for DOCX. With `scheme: none` the headings are not numbered |
+| `enumeration.*`, `paragraphs.*` | The lists of items and the numbered paragraphs, one numbering definition for each kind. A label that shows `{section}` can be live only on the heading list's next level, and is otherwise written without it and reported (`render-docx-section-label`) |
+| `references.format`, `numbering.levels[].ref`, `enumeration.levels[].ref` | The literal text around the `REF` fields of a cross-reference and between them; the cached result of the field is what Word draws. See `docx.reference_text_switch` |
+| `definitions.style`, `definitions.term_style` | The character styles of a defining occurrence and of a `{{term:}}` reference |
+| `typography.justify` | The justification of `Normal` |
+| `locale`, `values.*`, `placeholders.*`, `template_view.*`, `labels.*` | As in the other formats: dates, amounts, blanks, choices and labels are written as text (they are not numbers Word can compute) |
+| `html.max_width`, `html.extra_css` | Not read |
+
The styles for this matter (`Title Block Text`, `Party Side`, `Party Name`, `Attachments Heading`, `Attachment Title`,
`Attachment File`, `Signature Side`, `Signature Text`, `Signature Line`) are Word styles like the others: a lawyer who
changes one in Word changes every paragraph that uses it.
From 51a37b31179f763305786155b64401abb94af8e9 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Wed, 28 Oct 2026 17:37:06 +0000
Subject: [PATCH 115/118] Read the LibreOffice tests' text from the document
model, not the text export filter
The plain-text export lays a table row out as one tab-separated line in some LibreOffice versions
(the CI's) and as one line per cell paragraph in others (24.2), so the tests passed locally and failed
in CI. The UNO scripts now walk the body (paragraphs, and tables by row and cell, in order) and write
one line per paragraph, led by the list label LibreOffice draws, which gives the same lines under every
version. The nested items no longer carry the filter's indent. The CI job prints the Ubuntu and
LibreOffice versions before the tests.
---
.github/workflows/ci.yml | 4 ++
tests/lo/uno_edit.py | 7 +--
tests/lo/uno_export.py | 9 ++--
tests/lo/uno_text.py | 57 +++++++++++++++++++++
tests/test_docx_libreoffice.py | 2 +-
tests/test_docx_lo_text.py | 91 ++++++++++++++++++++++++++++++++++
6 files changed, 162 insertions(+), 8 deletions(-)
create mode 100644 tests/lo/uno_text.py
create mode 100644 tests/test_docx_lo_text.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd2fbec..54637ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -99,6 +99,10 @@ jobs:
- run: |
sudo apt-get update
sudo apt-get install -y libreoffice-writer python3-uno
+ - name: Show the versions of the system and of LibreOffice
+ run: |
+ lsb_release -ds || cat /etc/os-release
+ soffice --version
- run: pip install -e ".[dev]"
- name: Cache the OOXML schemas
id: schemas
diff --git a/tests/lo/uno_edit.py b/tests/lo/uno_edit.py
index 8be179c..5832d09 100644
--- a/tests/lo/uno_edit.py
+++ b/tests/lo/uno_edit.py
@@ -3,7 +3,7 @@
the document says: for each job, a paragraph is inserted before the first paragraph that holds some text (so that
it takes that paragraph's numbering and style, as pressing Enter at its start does), or that paragraph is deleted,
then every field and index is updated (the indexes too, so a table of contents lists what was inserted) and the body
-is exported as plain text (``JOB.txt``, UTF-8).
+is written as plain text (``JOB.txt``, UTF-8, one line per paragraph, read from the document model: tests/lo/uno_text.py).
Run it with the system Python that has the ``uno`` module, **not** with the project's virtual environment:
@@ -31,6 +31,7 @@
import uno # type: ignore[import-not-found] # noqa: I001 - provided by python3-uno, not by pip
from com.sun.star.beans import PropertyValue # type: ignore[import-not-found]
from com.sun.star.text.ControlCharacter import PARAGRAPH_BREAK # type: ignore[import-not-found]
+from uno_text import body_text
def _value(name: str, value: object) -> PropertyValue:
@@ -124,8 +125,8 @@ def edit(out_dir: str, jobs: list[dict]) -> None:
indexes.getByIndex(position).update()
document.getTextFields().refresh()
document.refresh()
- document.storeToURL(uno.systemPathToFileUrl(os.path.join(out_dir, f"{job['name']}.txt")),
- (_value("FilterName", "Text (encoded)"), _value("FilterOptions", "UTF8")))
+ with open(os.path.join(out_dir, f"{job['name']}.txt"), "w", encoding="utf-8", newline="") as handle:
+ handle.write(body_text(document))
finally:
document.close(True)
finally:
diff --git a/tests/lo/uno_export.py b/tests/lo/uno_export.py
index 4167298..9246526 100644
--- a/tests/lo/uno_export.py
+++ b/tests/lo/uno_export.py
@@ -1,8 +1,8 @@
#!/usr/bin/python3
"""Open DOCX files in headless LibreOffice through UNO, update every field and index,
and write what the document says: one ``NAME.txt`` per file (the body as plain text,
-UTF-8, as LibreOffice's text export writes it) and one ``NAME.json`` with the page
-count and the text of the running header and footer.
+UTF-8, one line per paragraph, read from the document model: tests/lo/uno_text.py) and one
+``NAME.json`` with the page count and the text of the running header and footer.
Run it with the system Python that has the ``uno`` module (``/usr/bin/python3`` once
``python3-uno`` is installed), **not** with the project's virtual environment:
@@ -31,6 +31,7 @@
import uno # type: ignore[import-not-found] # noqa: I001 - provided by python3-uno, not by pip
from com.sun.star.beans import PropertyValue # type: ignore[import-not-found]
+from uno_text import body_text
def _value(name: str, value: object) -> PropertyValue:
@@ -93,8 +94,8 @@ def export(out_dir: str, files: list[str], *, update: bool = True) -> None:
if update:
_update(document)
header, footer = _page_texts(document)
- document.storeToURL(uno.systemPathToFileUrl(os.path.join(out_dir, f"{stem}.txt")),
- (_value("FilterName", "Text (encoded)"), _value("FilterOptions", "UTF8")))
+ with open(os.path.join(out_dir, f"{stem}.txt"), "w", encoding="utf-8", newline="") as handle:
+ handle.write(body_text(document))
pages = document.getCurrentController().PageCount
with open(os.path.join(out_dir, f"{stem}.json"), "w", encoding="utf-8") as handle:
json.dump({"header": header, "footer": footer, "pages": pages}, handle)
diff --git a/tests/lo/uno_text.py b/tests/lo/uno_text.py
new file mode 100644
index 0000000..18e5448
--- /dev/null
+++ b/tests/lo/uno_text.py
@@ -0,0 +1,57 @@
+"""What a LibreOffice document says, read from its model instead of from a text export filter.
+
+LibreOffice's plain-text export lays a table row out as one line (cells separated by tabs) in some versions
+and as one line per cell paragraph in others, so a test that compared that text would depend on the version of
+the LibreOffice that happens to be installed. The text here is made by walking the body the way the document
+holds it, so it is the same under every version: one line per paragraph, a table's rows and cells in order (each
+cell's paragraphs, and a table inside a cell, in place), a soft line break starting a new line, and, before the
+text of a numbered paragraph, the label LibreOffice draws for it (``ListLabelString``, which is what its numbering
+computes once the fields are updated) and one space.
+
+This module needs nothing from LibreOffice itself (it reads what the UNO objects answer), so the tests can
+run it over stand-ins (tests/test_docx_lo_text.py). It is imported by tests/lo/uno_export.py and
+tests/lo/uno_edit.py, which run under the system Python that has ``uno``.
+"""
+from __future__ import annotations
+
+import re
+
+PARAGRAPH = "com.sun.star.text.Paragraph"
+TABLE = "com.sun.star.text.TextTable"
+
+
+def _cell_order(name: str) -> tuple[int, int, str]:
+ """The reading order of a cell name ("A1", "B1", "A2", ..., "AA1"): by row, then by column."""
+ match = re.fullmatch(r"([A-Za-z]+)(\d+)", name)
+ assert match is not None, name
+ return int(match[2]), len(match[1]), match[1]
+
+
+def paragraph_lines(paragraph: object) -> list[str]:
+ """The lines of one paragraph: its text, split at the soft line breaks, the first one led by the list label
+ (and a space) when the paragraph shows one."""
+ label = paragraph.ListLabelString # type: ignore[attr-defined]
+ text = paragraph.getString() # type: ignore[attr-defined]
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+ if label:
+ lines[0] = f"{label} {lines[0]}"
+ return lines
+
+
+def collect(enumeration: object, lines: list[str]) -> None:
+ """Append the lines of every paragraph and table that *enumeration* (of a text or of a table cell) yields."""
+ while enumeration.hasMoreElements(): # type: ignore[attr-defined]
+ element = enumeration.nextElement() # type: ignore[attr-defined]
+ if element.supportsService(TABLE):
+ for name in sorted(element.getCellNames(), key=_cell_order):
+ collect(element.getCellByName(name).createEnumeration(), lines)
+ elif element.supportsService(PARAGRAPH):
+ lines += paragraph_lines(element)
+
+
+def body_text(document: object) -> str:
+ """The body of *document* as text: every line ends with a line feed, so that an empty last paragraph shows as
+ an empty last line, as in a plain-text file."""
+ lines: list[str] = []
+ collect(document.getText().createEnumeration(), lines) # type: ignore[attr-defined]
+ return "".join(f"{line}\n" for line in lines)
diff --git a/tests/test_docx_libreoffice.py b/tests/test_docx_libreoffice.py
index 85fd6b2..f02f9ec 100644
--- a/tests/test_docx_libreoffice.py
+++ b/tests/test_docx_libreoffice.py
@@ -100,7 +100,7 @@ def test_the_words_come_back_in_order(shown: dict[str, Exported]) -> None:
# the numbers are drawn by LibreOffice from the numbering definitions (test_docx_roundtrip.py compares them)
assert shown["round_trip"].lines == [
"Round Trip", "Sub", "Datum účinnosti", "4. března 2026", "Preamble ž€.", "1. First", "Body with emphasis, strong and code.", "Second line.",
- "1.1 Second", " (a) item one", " (b) item two", "A quote", "code line 1", " code line 2", "H1", "H2",
+ "1.1 Second", "(a) item one", "(b) item two", "A quote", "code line 1", " code line 2", "H1", "H2",
"a", "b", ""]
diff --git a/tests/test_docx_lo_text.py b/tests/test_docx_lo_text.py
new file mode 100644
index 0000000..31f1184
--- /dev/null
+++ b/tests/test_docx_lo_text.py
@@ -0,0 +1,91 @@
+"""The text tests/lo/uno_text.py reads from a LibreOffice document model: one line per paragraph, whatever the
+version of LibreOffice. It is run here over stand-ins for the UNO objects, so no LibreOffice is needed: the
+text export filter, which lays a table row out differently in different versions, is not involved at all."""
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+spec = importlib.util.spec_from_file_location("uno_text", Path(__file__).parent / "lo" / "uno_text.py")
+assert spec is not None and spec.loader is not None
+uno_text = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(uno_text)
+
+
+class Enumeration:
+ def __init__(self, items: list[object]) -> None:
+ self.items = list(items)
+
+ def hasMoreElements(self) -> bool: # noqa: N802 - the UNO name
+ return bool(self.items)
+
+ def nextElement(self) -> object: # noqa: N802
+ return self.items.pop(0)
+
+
+class Paragraph:
+ def __init__(self, text: str, label: str = "") -> None:
+ self.text, self.ListLabelString = text, label
+
+ def supportsService(self, name: str) -> bool: # noqa: N802
+ return name == uno_text.PARAGRAPH
+
+ def getString(self) -> str: # noqa: N802
+ return self.text
+
+
+class Cell:
+ def __init__(self, *items: object) -> None:
+ self.items = items
+
+ def createEnumeration(self) -> Enumeration: # noqa: N802
+ return Enumeration(list(self.items))
+
+
+class Table:
+ def __init__(self, cells: dict[str, Cell]) -> None:
+ self.cells = cells
+
+ def supportsService(self, name: str) -> bool: # noqa: N802
+ return name == uno_text.TABLE
+
+ def getCellNames(self) -> list[str]: # noqa: N802
+ return list(self.cells)
+
+ def getCellByName(self, name: str) -> Cell: # noqa: N802
+ return self.cells[name]
+
+
+class Document:
+ def __init__(self, *items: object) -> None:
+ self.items = items
+
+ def getText(self) -> Document: # noqa: N802
+ return self
+
+ def createEnumeration(self) -> Enumeration: # noqa: N802
+ return Enumeration(list(self.items))
+
+
+def test_a_table_row_is_one_line_per_cell_paragraph_in_reading_order() -> None:
+ # the cell names in the order a version of LibreOffice may list them: the text is by row, then by column
+ table = Table({"B2": Cell(Paragraph("d")), "A1": Cell(Paragraph("a")), "B1": Cell(Paragraph("b"), Paragraph("b2")),
+ "A2": Cell(Paragraph("c"))})
+ document = Document(Paragraph("before"), table, Paragraph(""))
+ assert uno_text.body_text(document) == "before\na\nb\nb2\nc\nd\n\n"
+
+
+def test_a_table_inside_a_cell_is_read_in_place_and_wide_tables_by_column_length() -> None:
+ inner = Table({"A1": Cell(Paragraph("x")), "B1": Cell(Paragraph("y"))})
+ names = ["A1", "B1", "Z1", "AA1", "A2"]
+ outer = Table({name: Cell(inner if name == "B1" else Paragraph(name)) for name in reversed(names)})
+ assert uno_text.body_text(Document(outer)) == "A1\nx\ny\nZ1\nAA1\nA2\n"
+
+
+def test_a_numbered_paragraph_leads_with_its_label_and_a_space_and_a_line_break_starts_a_line() -> None:
+ document = Document(Paragraph("First", "1."), Paragraph("one\ntwo\tthree", "(a)"), Paragraph("plain\r\nnext"))
+ assert uno_text.body_text(document) == "1. First\n(a) one\ntwo\tthree\nplain\nnext\n"
+
+
+def test_an_empty_document_has_no_text() -> None:
+ assert uno_text.body_text(Document()) == ""
From ca5343e41250d510843beccacce15f342899368e Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Wed, 28 Oct 2026 17:41:58 +0000
Subject: [PATCH 116/118] Acceptance tests and docs follow the model-based
LibreOffice text
---
docs/architecture.md | 3 ++-
tests/test_docx_acceptance.py | 12 ++++++------
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 89cb5a6..a70a43a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -672,7 +672,7 @@ out when the validator reported the same path at the same line, or at none (`dia
Each module is named for its topic. Shared helpers (the minimal document header, the `text` and
`html` renderers, and running the command as a program) are in `tests/conftest.py`; those for the DOCX tests
-(reading a package, validating it, running LibreOffice) are in `tests/docx_support.py` and `tests/lo/`.
+(reading a package, validating it, running LibreOffice) are in `tests/docx_support.py` and `tests/lo/`; the text of a LibreOffice document is read from its model (`tests/lo/uno_text.py`: a line a paragraph, the list label before a numbered one, a table's cells in order), not from its text export, whose table layout differs between versions.
Markers: `conformance` (the specification's documents), `ooxml_schema` (DOCX parts against the schemas), `libreoffice`
(DOCX opened in LibreOffice; skipped without `soffice` and `python3-uno`, a failure when `LEGALDOWN_REQUIRE_DOCX_TOOLS`
@@ -714,6 +714,7 @@ its file is named). `pytest -n auto` (pytest-xdist, in the `dev` extra) runs the
| `tests/test_docx_roundtrip.py` | LibreOffice as the oracle of the numbering and the cross-references, in one LibreOffice session: every document of the corpus (the tests', the specification's, stress documents with cycles and unanchored ancestors) under the default, outline, continental, mixed and `none` schemes, with every cached field result made wrong (`XXX`), every numbered paragraph marked and a line per reference added; the number LibreOffice draws, every reference it computes and every body paragraph's text equal what the file cached and the renderer wrote, modulo the gaps the plan reports (`\t` ignored, letters past 26, labels the heading list cannot show); alternatives are checked against the renderer (their `numId 0` number is the holder's, as fields), and so are the continental items on the heading list and the lists on its reserved levels; the table of contents is built by LibreOffice from a `TOC` field the test adds and must list the headings with the numbers the plan gives and none of the items; and edit-then-update (`tests/lo/uno_edit.py`, which inserts and deletes paragraphs, and can put an inserted paragraph on another item's list as pasting a copy does): path labels on the reserved levels, in a detached list (and the documented continuation of a list copied under a new parent) and past nine levels, a heading inserted before a document that skips a level, items, headings, a clause and a nested item inserted before the targets as a lawyer does, fields updated, every reference and the numbers equal the renderer's for the edited source (the new nested item under the continental style reads `a)`), and, for a sub-heading restarted by a fixed value, the plan's warning and what an edit before it keeps right (marker `libreoffice`) |
| `tests/test_docx_acceptance.py` | The measurable claims of ADR 0008 over the whole corpus (the tests' documents, the specification's examples and valid fixtures, the stress documents) under the default, outline and continental styles, with the contents on. **Equivalence:** LibreOffice's text, with fields and indexes updated, equals the plain-text writer's, line by line, modulo the differences the formats have by nature (one documented function: borders for rules and signing lines, table cells, quote and link markup, where a condition label stands, `label: value` rows, the contents' page numbers, which are checked to be positive, never decreasing and within the page count) and modulo the gaps the plan reports; the documents that differ are exactly the stress documents that ask for what Word draws differently. **Live after edits:** in one LibreOffice session, a heading, an item, a nested item, a numbered paragraph (continental) are inserted and a clause deleted in up to ten documents under each style, and the result equals the plain-text writer's for the source with the same edits (a difference is allowed only where the render reports a fixed number). **Validity and determinism:** every part validates and two renders are the same bytes. Negative controls show the comparison fails on a wrong number, a missing line, pages out of order |
| `tests/test_docx_matter.py` | The front and back matter: the XML of the title block, the `TOC` field and its cached entries, the attachments and the signature blocks; documents with every part (hostile text too) against the schemas; LibreOffice listing exactly the contents' entries after an update, resolving `{{attach:}}`, paging the attachments as configured, taking a heading inserted in the editor into the contents, and reading in the text writer's order |
+| `tests/test_docx_lo_text.py` | The reader of a LibreOffice document's text (`tests/lo/uno_text.py`) over stand-ins: one line a paragraph, the list label before a numbered paragraph, a table's cells by row then column, soft breaks as lines |
| `tests/test_docx_libreoffice.py` | The documents opened in LibreOffice through UNO (`tests/lo/uno_export.py`), fields updated: the words in order, header, footer, and the page count and the page numbers compared with each other and with page breaks, not with the page count of a font (marker `libreoffice`; skipped without `soffice` and `python3-uno`) |
| `tests/test_docx_wordcheck.py`, `tests/word_check.py` | The generator of the document for the manual Word checks of what the planner assumes (`python tests/word_check.py OUT.docx`; `.github/PUBLISHING.md` items 8 and 9): one row for each label form the plan relies on (`\t` on `Article %1`, `§ %1`, `Section %1.%2`, `Čl. %1`, `No. %1`, `(%1)`, `%1)`, `%1:`), the heading list's reserved level `%1.%2(%6)` with levels 3 to 5 unused, and a skipped heading level, each with the planner's assumption beside it taken from the planner's own code; that the package is valid (marker `ooxml_schema`) and LibreOffice computes the plain fields as the planner caches them (marker `libreoffice`); and that the Python snippet in the checklist still runs and makes every file the checklist names, including the document of the level nothing numbered |
| `tests/conformance/` | Every example and fixture in the specification repository renders, every internal link has a target, and each invalid fixture renders its rule's failure marker. Needs `$LEGALDOWN_SPEC_DIR` or a sibling `../LegalDown` checkout |
diff --git a/tests/test_docx_acceptance.py b/tests/test_docx_acceptance.py
index 5eb70d7..b83f2ac 100644
--- a/tests/test_docx_acceptance.py
+++ b/tests/test_docx_acceptance.py
@@ -180,8 +180,8 @@ def normalise(written: str, shown: str, tree: RenderTree) -> tuple[list[str], li
page numbers of the contents. The formats differ by nature in these ways, each undone here and in no other:
* a rule (``---``) and a signing line (``____``) are borders of paragraphs and cells in the DOCX, not text;
- * a table is pipes and a separator row in the text and cells in the DOCX, which LibreOffice's text export lays out as a
- line a cell or a line a row, as its version does: a row is compared as the words of its cells, however many lines they take;
+ * a table is pipes and a separator row in the text and cells in the DOCX (tests/lo/uno_text.py reads one line a cell): a row is
+ compared as the words of its cells, however many lines they take, so that a text laid out by row would do as well;
* a quote's ``>`` is the indent of a style in the DOCX;
* a link's ```` after its text is the target of the hyperlink;
* a condition label (§15.8) and the label of a drafting note are in ``[ ]`` in the text and a styled run in the DOCX,
@@ -241,8 +241,8 @@ def normalise(written: str, shown: str, tree: RenderTree) -> tuple[list[str], li
pages.append(int(entry[2]))
shown_lines = [line for line in squeezed if line]
- # A table's cells are one line each in some LibreOffice versions and one line a row in others: a row that the text
- # writer prints on one line is read from as many lines of LibreOffice's as it takes to say the same words.
+ # A row that the text writer prints on one line is read from as many lines of LibreOffice's as it takes to say the
+ # same words (one a cell, as tests/lo/uno_text.py reads the document, but not by that alone).
written_lines: list[str] = []
seen: list[str] = []
cursor = 0
@@ -336,8 +336,8 @@ def test_the_comparison_catches_a_wrong_number_a_missing_line_and_a_wrong_page_o
@pytest.mark.libreoffice
def test_a_table_row_on_one_line_reads_as_the_same_row_on_several() -> None:
- """LibreOffice's text export lays a table out as a line a cell (24.2) or a line a row (later versions): both are the
- document, so both pass."""
+ """A table laid out as a line a cell (as the document is read) or a line a row (as some LibreOffice text filters write
+ it) is the same table, so both pass."""
case = next(c for c in shown_corpus()["default"] if c.name == "documents/features.lgd")
assert case.shown is not None
text = case.shown.text
From ee70c24b0750465798caff946956b563fe4bc2d5 Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:21:48 +0000
Subject: [PATCH 117/118] Acceptance: edited documents must read exactly as the
text writer prints them; pin the reported gap lines
The edit-then-update test no longer excuses numeric differences (none occur in the subset), the gap documents are compared
line by line against the lines pinned for them, and the determinism test needs no schemas. The test hook matches the
acceptance file by name; a CI step runs the acceptance tests that carry no other marker.
---
.github/workflows/ci.yml | 3 ++
tests/conftest.py | 6 ++-
tests/test_docx_acceptance.py | 76 +++++++++++++++++++++--------------
3 files changed, 53 insertions(+), 32 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 43e3985..b1a860d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -119,6 +119,9 @@ jobs:
- run: pytest -q -m libreoffice
env:
LEGALDOWN_SPEC_DIR: .legaldown-spec
+ - run: pytest -q -m "acceptance and not libreoffice and not ooxml_schema"
+ env:
+ LEGALDOWN_SPEC_DIR: .legaldown-spec
validator-main:
# Surfaces changes in legaldown-validator's model before they are released
diff --git a/tests/conftest.py b/tests/conftest.py
index 117506e..0d6b241 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -64,8 +64,10 @@ def pytest_addoption(parser: pytest.Parser) -> None:
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Leave the DOCX acceptance corpus (marker ``acceptance``) out of a plain ``pytest`` run, which stays as quick as it
was: it runs when a marker expression is given (``-m libreoffice``, as the DOCX job does), when
- ``LEGALDOWN_ACCEPTANCE`` is set, or when a command-line argument names its file."""
- if config.getoption("markexpr") or os.environ.get("LEGALDOWN_ACCEPTANCE") or any("acceptance" in a for a in config.args):
+ ``LEGALDOWN_ACCEPTANCE`` is set, or when a command-line argument is its file (``tests/test_docx_acceptance.py``,
+ with or without a ``::test``)."""
+ if config.getoption("markexpr") or os.environ.get("LEGALDOWN_ACCEPTANCE") or any(
+ Path(argument.split("::")[0]).name == "test_docx_acceptance.py" for argument in config.args):
return
dropped = [item for item in items if item.get_closest_marker("acceptance")]
if dropped:
diff --git a/tests/test_docx_acceptance.py b/tests/test_docx_acceptance.py
index b83f2ac..f738736 100644
--- a/tests/test_docx_acceptance.py
+++ b/tests/test_docx_acceptance.py
@@ -7,17 +7,18 @@
* **Equivalence.** LibreOffice opens each document with its fields and indexes updated, and what it shows is what the
plain-text writer prints, line by line, **modulo the differences the formats have by nature** (:func:`normalise`:
borders for rules and signing lines, a table's cells, the markup of the plain-text layout) **and the gaps the plan
- reports** (:data:`GAPS`: a number Word draws differently from the renderer's, with a ``render-docx-*`` diagnostic).
+ reports** (:data:`GAPS`: a number Word draws differently from the renderer's, with a ``render-docx-*`` diagnostic;
+ :data:`GAP_LINES` pins each line that differs, so that nothing else may).
The numbers, the cross-references, the entries of the contents, the attachments and the signature blocks are in that
text. The page numbers of the contents are checked on their own: they are positive, never decrease and never pass the
number of pages.
* **Live after edits.** In one LibreOffice session, for a handful of documents under each style, a heading, a list
item, a nested item and (under the continental style) a numbered paragraph are inserted, and a clause is deleted,
as a lawyer does (:mod:`tests/lo/uno_edit.py`); the fields and indexes are updated; the result is what the text
- writer prints for the source with the same edits made. A document may differ only where the plan reports a number it
- fixes (:data:`FIXED`), and every such difference is listed.
+ writer prints for the source with the same edits made, exactly: in the documents of the subset no number is fixed in a
+ way these edits reach.
* **Valid and deterministic.** Every part of every document validates against the ISO/IEC 29500 schemas, and rendering a
- document twice gives the same bytes.
+ document twice gives the same bytes (that test has the marker ``acceptance`` alone).
The LibreOffice tests are skipped when ``soffice`` or ``python3-uno`` is missing and fail instead when
``LEGALDOWN_REQUIRE_DOCX_TOOLS`` is set (``tests/docx_support.py``). The module is part of the DOCX job (``pytest -m
@@ -81,12 +82,6 @@
#: and the two readings of a reference that only LibreOffice has (``\t`` ignored, a level nothing numbered counted).
GAPS = EXPLAINING | {"render-docx-number-mismatch", "render-docx-reference-word-only", "render-docx-libreoffice-count"}
-#: The diagnostics that say a number is fixed in the file: it is right when the file is opened and does not follow what
-#: is inserted or deleted before it (a restart by a fixed value, a counter shared with others, a reference that is text),
-#: besides the gaps above.
-FIXED = GAPS | {"render-docx-shared-counter", "render-docx-static-restart", "render-docx-reference",
- "render-docx-reference-cycle"}
-
# -- The corpus ---------------------------------------------------------------------------------------------------
@@ -260,27 +255,46 @@ def normalise(written: str, shown: str, tree: RenderTree) -> tuple[list[str], li
return written_lines, seen, pages
-def words(lines: list[str]) -> list[str]:
- """The words of *lines* that are not numbers or counters: four letters or more, which is what a gap leaves alone."""
- return [word for line in lines for word in re.findall(r"[^\W\d_]{4,}", line)]
-
-
def difference(written: list[str], shown: list[str]) -> str:
return "\n".join(difflib.unified_diff(written, shown, "text writer", "LibreOffice", lineterm="", n=1))
+#: The lines that differ, by (style, document), where the plan reports a gap: what the text writer prints, then what LibreOffice
+#: (which follows Word's numbering) shows. Nothing else may differ: not a line more, not a word of any length.
+GAP_LINES: dict[tuple[str, str], list[tuple[str, str]]] = {
+ # letters past 26: Word repeats them (bb), the renderer counts on (ab) -- render-docx-letter-overflow
+ ("default", "stress/letters"): [
+ ("(ab) item 28", "(bb) item 28"), ("(ac) item 29", "(cc) item 29"), ("(ad) item 30", "(dd) item 30"),
+ ("(ae) item 31", "(ee) item 31"), ("Ref 1(a) 1(z) 1(aa) 1(ab) 1(ae)", "Ref 1(a) 1(z) 1(aa) 1(bb) 1(ee)")],
+ ("outline", "stress/letters"): [
+ ("(ab) item 28", "(bb) item 28"), ("(ac) item 29", "(cc) item 29"), ("(ad) item 30", "(dd) item 30"),
+ ("(ae) item 31", "(ee) item 31"), ("Ref I(a) I(z) I(aa) I(ab) I(ae)", "Ref I(a) I(z) I(aa) I(bb) I(ee)")],
+ # a list that cannot show the section's number draws its own counter alone -- render-docx-section-label
+ ("continental", "stress/qualified"): [("1.1 deeper", "1 deeper")],
+ ("continental", "stress/paths"): [("1.1 four", "1 four"), ("1.1 seven", "1 seven")],
+ ("continental", "stress/ordered paths"): [
+ ("1.1 four", "1 four"), ("1.2 four b", "2 four b"), ("1.1 four c", "1 four c"), ("1.1 y", "1 y"), ("1.1 w", "1 w")],
+ ("continental", "stress/deep"): [
+ ("1.1 level 3", "1 level 3"), ("1.1 level 6", "1 level 6"), ("1.1 level 9", "1 level 9"),
+ ("1.1 level 9 again", "1 level 9 again")],
+}
+
+
def check(case: Shown) -> bool:
- """Assert the equivalence of *case*; returns True if it holds exactly, False if it holds modulo a reported gap."""
+ """Assert the equivalence of *case*; returns True if it holds exactly, False if it holds modulo the reported gap that
+ :data:`GAP_LINES` pins line by line (and that the render must report)."""
assert case.shown is not None
written, seen, pages = normalise(case.written, case.shown.text, case.tree)
if case.tree.contents:
assert pages, "the contents lists no page numbers"
assert pages == sorted(pages) and pages[0] >= 1 and pages[-1] <= case.shown.pages, (pages, case.shown.pages)
if written == seen:
+ assert (case.style, case.name) not in GAP_LINES, "the gap that was pinned is gone: remove it from GAP_LINES"
return True
assert case.rules & GAPS, f"the numbers differ and the render reports no gap:\n{difference(written, seen)}"
- assert words(written) == words(seen), f"a gap changes numbers, not words:\n{difference(written, seen)}"
- assert len(written) == len(seen), f"a gap changes numbers, not lines:\n{difference(written, seen)}"
+ assert len(written) == len(seen), f"a gap changes lines, not their number:\n{difference(written, seen)}"
+ differing = [(a, b) for a, b in zip(written, seen, strict=True) if a != b]
+ assert differing == GAP_LINES.get((case.style, case.name)), f"not the pinned gap:\n{difference(written, seen)}"
return False
@@ -525,18 +539,12 @@ def edited_corpus() -> dict[str, tuple[Edited, ...]]:
def test_the_document_follows_the_edits_a_lawyer_makes(style: str) -> None:
"""After a heading, an item, a nested item and a numbered paragraph are inserted and a clause is deleted, and the fields
and indexes are updated, the document reads as the plain-text writer prints the source with the same edits: every number
- and reference moved, the contents list the new heading. A document may differ only where its render reported a number
- that is fixed in the file, and then the difference is in numbers alone."""
- live = 0
+ and reference moved, the contents list the new heading. The text is equal, line for line, in every document: an edit
+ that reached a number fixed in the file (a restart by a fixed value) would show, and none does in this subset."""
for case in edited_corpus()[style]:
written, seen, _ = normalise(case.written, case.shown, case.after)
- if written == seen:
- live += 1
- continue
- fixed = case.rules & FIXED
- assert fixed, f"{style}/{case.name}: not live after {[e.kind for e in case.edits]}:\n{difference(written, seen)}"
- assert words(written) == words(seen), f"{style}/{case.name}:\n{difference(written, seen)}"
- assert live >= 7, [(c.name, [e.kind for e in c.edits]) for c in edited_corpus()[style]]
+ assert written == seen, (f"{style}/{case.name}: not live after {[e.kind for e in case.edits]}:\n"
+ f"{difference(written, seen)}")
@pytest.mark.libreoffice
@@ -568,11 +576,19 @@ def test_every_kind_of_edit_is_made_in_the_corpus() -> None:
@pytest.mark.ooxml_schema
@pytest.mark.parametrize("style", STYLES)
-def test_every_part_of_every_corpus_document_validates_and_a_second_render_is_the_same_bytes(style: str) -> None:
+def test_every_part_of_every_corpus_document_validates(style: str) -> None:
require_schemas()
+ for name, source in documents().items():
+ made_ = rendered(source, style)
+ if made_ is not None:
+ assert validation_errors(made_[0]) == [], f"{style}/{name}"
+
+
+@pytest.mark.parametrize("style", STYLES)
+def test_rendering_a_corpus_document_twice_gives_the_same_bytes(style: str) -> None:
+ """Needs no tool but the corpus (marker ``acceptance`` alone: ``pytest -m acceptance`` runs it)."""
for name, source in documents().items():
first, second = rendered(source, style), rendered(source, style)
assert (first is None) == (second is None), f"{style}/{name}"
if first is not None and second is not None:
assert first[0] == second[0], f"{style}/{name}: two renders differ"
- assert validation_errors(first[0]) == [], f"{style}/{name}"
From 39c989ec6abda2adab53e56286f3d76480e1886e Mon Sep 17 00:00:00 2001
From: george <95045003+dvejsada@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:21:48 +0000
Subject: [PATCH 118/118] Release steps for the Word check; docs: concepts,
plan_docx signature, tree fields, DOCX is not yet checked in Word
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PUBLISHING gains the step that follows the Word check (record the Word version and platform, update the places that say
the DOCX is unchecked, decide the §13.6 claim) and lists the docx job among those that gate a release.
---
.github/PUBLISHING.md | 12 +++++++++---
README.md | 7 ++++---
docs/architecture.md | 4 ++--
docs/concepts.md | 4 ++--
docs/roadmap.md | 2 +-
5 files changed, 18 insertions(+), 11 deletions(-)
diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md
index c3a083e..17cf7c6 100644
--- a/.github/PUBLISHING.md
+++ b/.github/PUBLISHING.md
@@ -51,7 +51,13 @@ version number can never be reused once published.
`pytest --update-golden tests/test_golden.py`, and check that `git diff tests/golden` shows
only that line. Also update the version named in [`CONFORMANCE.md`](../CONFORMANCE.md) and the
milestone in [`docs/roadmap.md`](../docs/roadmap.md).
-3. Commit, then publish a GitHub Release with the tag `vX.Y.Z`. Its notes are where this
+3. For a release that changes the DOCX writer, run the checklist below ("Checking the DOCX in Word") first, and record the
+ Word version and platform (for example Word for Microsoft 365, Windows 11, build number; or Word for Mac) with
+ its findings in the release notes. Then update what says the DOCX is not yet checked in Word: the row of
+ [`CONFORMANCE.md`](../CONFORMANCE.md) ("DOCX output", and its section), the heading of the milestone in
+ [`docs/roadmap.md`](../docs/roadmap.md), and the README's DOCX section; and decide whether the renderer now claims
+ DOCX for §13.6 or keeps HTML as the format it claims it with.
+4. 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 first runs the CI workflow on the tagged commit. Only if that passes does it build the
@@ -96,7 +102,7 @@ for doc in ("features", "template"):
source = Path(f"tests/documents/{doc}.lgd").read_text(encoding="utf-8")
for style in ("default", "continental", "outline"):
(out / f"{doc}.{style}.docx").write_bytes(render(source, format="docx", style=style, overrides=contents).binary)
- # the settings whose effect only Word shows (see items 7 and 9); `True` marks the ones about attachments, which
+ # the settings whose effect only Word shows (see items 6, 7 and 9); `True` marks the ones about attachments, which
# template.lgd has none of
for name, (about_attachments, extra) in {
"attachments-not-listed": (True, {"contents.attachments": False}),
@@ -262,7 +268,7 @@ and LibreOffice differ (`\t`, `lvlRestart`, `SEQ \s`, `w:updateFields`).
validator's next minor version removes.
- **The upload is gated on the tests.** `publish.yml` first calls [`ci.yml`](workflows/ci.yml)
(`on: workflow_call`) as the job `ci`, and `build` needs it, so the lint, test
- (Linux and Windows on Python 3.13) and conformance jobs must pass on the commit being published
+ (Linux and Windows on Python 3.13), conformance and docx (schemas and LibreOffice) jobs must pass on the commit being published
before anything is built, and `testpypi` and `pypi` need `build`. The `validator-main` job is
informational (`continue-on-error`) and never blocks a release. The caller grants the called
workflow `contents: read`; a called workflow cannot hold more permissions than its caller gives.
diff --git a/README.md b/README.md
index 16db18a..87f2cf6 100644
--- a/README.md
+++ b/README.md
@@ -136,7 +136,8 @@ may not contain `version` or `extends`, so `overrides=legaldown_render.style.sty
edit. It is written with the standard library alone, so it needs nothing beyond the base install.
**The point of it:** in the Word file the numbers, the cross-references, the table of contents and the page
-numbers are Word's own features, not typed text. Insert a clause in Word, update the fields, and the clauses after it,
+numbers are Word's own features, not typed text. (This is how the file is built and how it behaves in LibreOffice, which CI
+runs; it has not yet been checked in Word itself, which the maintainers do by hand before a release.) Insert a clause in Word, update the fields, and the clauses after it,
every "see 2.1(b)" and the contents follow. The renderer still works every value out; it writes the value only as the
cached result of a field, so the document reads right before any field is updated
([ADR 0008](https://github.com/ForLegalAI/legaldown-render/blob/main/docs/decisions/0008-docx-word-native.md)).
@@ -148,7 +149,7 @@ row, rules, links, defined terms, blanks and choices have Word styles generated
a style in Word applies everywhere. The page size, margins, fonts and colours are the style's, and the file carries the
document's title and language.
-**What is live:**
+**What is live** (by design; confirmed in LibreOffice, to be confirmed in Word):
- Heading numbers (the heading styles are linked to a multilevel list), list items and numbered paragraphs are Word
numbering definitions. A nested list restarts under a new parent item, and a clause inserted before another renumbers it.
@@ -189,7 +190,7 @@ with the line (the rules are listed in [the conformance notes](https://github.co
- CI checks every part against the ISO/IEC 29500 schemas and opens the documents in LibreOffice, which updates the
fields and is compared with the plain-text output. It cannot run Word, so the maintainers check the golden documents
in Word before a release ([the checklist](https://github.com/ForLegalAI/legaldown-render/blob/main/.github/PUBLISHING.md)).
-- `--fragment` does not apply: a Word document is always whole. On the command line the file goes to `-o FILE.docx`, or
+- `--fragment` (and `standalone=False` in the API, where it is ignored) does not apply: a Word document is always whole, and `--fragment` with it is a usage error. On the command line the file goes to `-o FILE.docx`, or
to standard output with `-o -` (or without `-o` when standard output is not a terminal); it is never written to a terminal.
## Rendering documents you did not write
diff --git a/docs/architecture.md b/docs/architecture.md
index a70a43a..9d65d1c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -245,7 +245,7 @@ references show; the Warning says so. Every other heading reads exactly as its d
### The DOCX planner (`resolve/docx_plan.py`)
The DOCX writer contains no numbering rules either (ADR 0003, [ADR 0008](decisions/0008-docx-word-native.md)).
-Between the resolved tree and that writer sits a pure function, `plan_docx(tree, *, file=None) -> (DocxPlan,
+Between the resolved tree and that writer sits a pure function, `plan_docx(tree, *, file=None, text_switch="documented", text_width=...) -> (DocxPlan,
diagnostics)`, which maps the numbering structure above to what Word needs: the abstractNums and nums, for every
section, list item and numbered paragraph the `(numId, ilvl)` its paragraph takes, the bookmark of every anchor, the
fields of every cross-reference, and the entries of the table of contents. It reads only `tree.formats` and the
@@ -676,7 +676,7 @@ Each module is named for its topic. Shared helpers (the minimal document header,
Markers: `conformance` (the specification's documents), `ooxml_schema` (DOCX parts against the schemas), `libreoffice`
(DOCX opened in LibreOffice; skipped without `soffice` and `python3-uno`, a failure when `LEGALDOWN_REQUIRE_DOCX_TOOLS`
-is set, as in the CI job that installs them) and `acceptance`: the DOCX acceptance corpus, whose run takes about half a
+is set, as in the CI job that installs them) and `acceptance`: the DOCX acceptance corpus, whose run takes about a
minute of LibreOffice time and is left out of a plain `pytest` (it runs with `-m`, with `LEGALDOWN_ACCEPTANCE=1`, or when
its file is named). `pytest -n auto` (pytest-xdist, in the `dev` extra) runs the whole suite in a third of the serial time.
diff --git a/docs/concepts.md b/docs/concepts.md
index 2d66b40..c946c23 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -21,7 +21,7 @@ these. Its inputs are:
- **an answers set** (optional, templates only): the values used to assemble a template before
rendering (§15.7)
-Its outputs are the **rendered file** (HTML or plain text, later DOCX and PDF) and a list of
+Its outputs are the **rendered file** (HTML, plain text or DOCX, later PDF) and a list of
**diagnostics**.
The source never changes during rendering. Given the same source, style template, and options,
@@ -99,7 +99,7 @@ style template, and the answers file the CLI is given with `--answers`.
| **Render tree** | This package's format-neutral tree of blocks and inlines, built from the source and document model. It is structurally complete: nested lists, inline formatting, and directives are all nodes |
| **Resolution** | The stage that computes everything the specification leaves to render time: numbers, designations, link targets, display text, formatted values, markers |
| **Resolved document** | The render tree after resolution. It is immutable and holds no unresolved LegalDown semantics. It is the only input to writers |
-| **Writer** | A backend that turns the resolved document into a file format (`html`, `text`, later `docx` and `pdf`). A writer contains no LegalDown rules, only layout |
+| **Writer** | A backend that turns the resolved document into a file format (`html`, `text`, `docx`, later `pdf`). A writer contains no LegalDown rules, only layout |
| **Style template** | The configuration file that controls presentation (§13.7). See [style templates](style-templates.md) |
| **Numbering scheme** | How headings are numbered: `decimal`, `legal-outline`, `mixed`, or `none` (§13.1) |
| **Enumeration** | How list items are labelled at each nesting level, for example (a) / (i) / (A) (§13.2) |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f412038..e6647e7 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -125,7 +125,7 @@ values appear only as the cached results of fields. Decided in
- `render(..., format="docx")` returns the file as `RenderResult.binary` (bytes); `RenderResult.output` stays
`str` and is empty for it. `FORMATS` gains `docx`.
- `RenderTree` gains fields for the numbering structure (`formats`, and on sections, list items and
- paragraphs `unit`, `number`, `parent_unit`, `shares_number_with`; `List.family`; `CrossRef.target_unit`,
+ paragraphs `unit`, `number`, `parent_unit`, `shares_number_with`; `List.family`, `List.depth`, `List.format` and `List.start`; `CrossRef.target_unit`,
`section_unit`) and `version` and `effective_date` (plain text, filled whether or not the title block shows
them). HTML and plain-text output are byte-identical to 0.4 (the goldens are unchanged).
- New `render-docx-*` diagnostics for DOCX renders only.