diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d5886b0..32356c1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -7,6 +7,28 @@ on:
workflow_dispatch:
jobs:
+ lint:
+ name: Lint and format
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install ruff
+ run: python -m pip install --upgrade pip && python -m pip install ruff
+
+ - name: Check lint rules
+ run: ruff check doc_engine/ tests/
+
+ - name: Check formatting
+ run: ruff format --check doc_engine/ tests/
+
test:
name: ${{ matrix.os }} · Python ${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
@@ -61,6 +83,59 @@ jobs:
mkdir -p "$RUNNER_TEMP/smoke" && cd "$RUNNER_TEMP/smoke"
doc-engine --version
doc-engine info
- printf -- '---\ntitle: Smoke\naccent: teal\n---\n\n# Smoke\n\nText with a footnote[^1], a bracket ], and a task.\n\n- [x] done\n\n[^1]: note\n' > README.md
+ doc-engine --help
+ {
+ echo '---'
+ echo 'title: Smoke'
+ echo 'accent: teal'
+ echo '---'
+ echo
+ echo '# Smoke'
+ echo
+ echo 'A footnote[^1], a bracket ], a task, $10 in cash, and math $x^2$.'
+ echo
+ echo '- [x] done'
+ echo
+ echo '| A | B |'
+ echo '|---|---|'
+ echo '| 1 | 2 |'
+ echo
+ echo '```mermaid'
+ echo 'flowchart LR'
+ echo ' A-->B'
+ echo '```'
+ echo
+ echo '$$'
+ echo '\frac{-b \pm \sqrt{b^2-4ac}}{2a}'
+ echo '$$'
+ echo
+ echo '[^1]: note'
+ } > README.md
doc-engine build
test -f README_doc.pdf
+ doc-engine build --paper us-letter --template report --pdf-standard a-2b -o archival.pdf
+ test -f archival.pdf
+
+ docker:
+ name: Docker image builds
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build the image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: false
+ load: true
+ tags: doc-engine-cli:ci
+
+ - name: Run the CLI inside it
+ run: |
+ mkdir -p "$RUNNER_TEMP/docker-smoke"
+ printf '# Docker\n\nText and math $x^2$.\n\n```mermaid\nflowchart LR\n A-->B\n```\n' \
+ > "$RUNNER_TEMP/docker-smoke/README.md"
+ docker run --rm -v "$RUNNER_TEMP/docker-smoke:/workspace" doc-engine-cli:ci build
+ test -f "$RUNNER_TEMP/docker-smoke/README_doc.pdf"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a13599e..2bcfc80 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -67,7 +67,16 @@ passes after it — that is what stops the same bug from coming back.
## Code style
-There is no autoformatter in this project. Match the surrounding code:
+Formatting and linting are handled by [ruff](https://docs.astral.sh/ruff/), and
+CI checks both. Before opening a pull request:
+
+```bash
+ruff format doc_engine/ tests/
+ruff check doc_engine/ tests/
+```
+
+Ruff settles the mechanical questions. The rest is convention — match the
+surrounding code:
- Type hints on function signatures.
- Module-level constants in `SCREAMING_SNAKE_CASE`, prefixed with `_` when private.
diff --git a/README.md b/README.md
index 99b09fe..562ffd8 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
Transform any `README.md` into a premium, print-ready PDF report — no configuration, no templates, no LaTeX.
-
+
```
@@ -25,6 +25,30 @@ pipx install doc-engine-cli
+> [!NOTE]
+> ### v2.0.0 is finished and looking for testers
+>
+> All of v2.0.0 is written and merged here — diagrams, math, multi-file
+> documents, seven layouts, page sizes, and the rest. It is **not on PyPI yet**,
+> because I would rather find the rough edges before publishing than after.
+> `pipx install doc-engine-cli` still gives you the stable **1.1.1**.
+>
+> **Try it and help me break it:**
+>
+> ```bash
+> git clone https://github.com/leonardosalasd/doc-engine-cli.git
+> cd doc-engine-cli
+> pip install -e .
+> doc-engine build
+> ```
+>
+> Everything in this README describes v2.0.0 and works on that checkout. If
+> something misbehaves, an [issue](https://github.com/leonardosalasd/doc-engine-cli/issues/new)
+> or a note in [Discussions](https://github.com/leonardosalasd/doc-engine-cli/discussions)
+> is genuinely useful — that feedback is what decides when this ships.
+>
+> The release follows once it has been through real documents on real machines.
+
## Overview
**doc-engine-cli** is a developer-first CLI tool that converts Markdown files into professionally styled PDF documents using [Typst](https://typst.app/) as its rendering backend. It is designed for teams and individual developers who need high-quality documentation artifacts without the complexity of LaTeX or manual typesetting.
@@ -44,27 +68,36 @@ That's it. Zero configuration required.
| Feature | Description |
|---|---|
| **Zero-Config** | Auto-detects `README.md`, Git author, and document title. No setup files needed. |
-| **Five Templates** | Academic, modern, minimal, technical, and book layouts, each with a configurable accent color. Point `--template` at your own `.typ` file to go further. |
+| **Seven Templates** | Academic, article, book, minimal, modern, report, and technical layouts, each with a configurable accent color. Point `--template` at your own `.typ` file to go further. |
| **Front Matter** | An optional `---` metadata block sets the title, subtitle, author, template, and accent right inside the file. |
| **Watch Mode** | `--watch` rebuilds the PDF every time you save the source. |
+| **Diagrams** | ` ```mermaid ` and ` ```svg ` blocks are rendered as real diagrams, in pure Python — no Node, no headless browser. |
+| **Math** | LaTeX math, inline with `$…$` and display with `$$…$$`, translated into native Typst math. |
+| **Multi-File** | A `doc-engine.md` manifest builds one PDF from many files, diagrams, figures, and a bibliography. |
+| **Page Sizes** | A4 by default, plus A3–A6, ISO/JIS B5, and US letter, legal, and tabloid. |
+| **Project Config** | A `.doc-engine.toml` keeps a team's defaults out of every document. |
| **Rich Markdown** | Embeds local images, renders GitHub task lists as real checkboxes, and turns `[^1]` footnotes into native Typst footnotes. |
| **Error Checking** | Reports source problems with line and column before compiling. A `--dry-run` mode runs the check on its own. |
| **Non-Destructive** | Never overwrites an existing PDF — writes `report (1).pdf`, `report (2).pdf`, … unless you pass `--force`. |
-| **Premium Typography** | Inter font family with fallback chain, justified text, and optimized line spacing. |
+| **Premium Typography** | Font stacks that end in a font Typst ships, so a document looks the same in a bare container as on a laptop. |
| **Pure Python** | No external binaries required (no Pandoc, no LaTeX). Ships as a single `pip install`. |
| **Cross-Platform** | Works on Windows, macOS, and Linux with Python 3.10+. |
---
-## 🎓 Academic Features (v0.1.2+)
+## Academic Writing
-`doc-engine-cli` ships with a premium scientific layout (using Linux Libertine and Inter font-families) and **Zero-Config Bibliography** handling.
+Cite with the usual `[@citation-key]` syntax and drop a `refs.bib`,
+`references.bib`, or `bibliography.bib` next to your document. It is picked up
+automatically and rendered as an IEEE-styled references section:
-To add an IEEE-styled bibliography to your PDF:
-1. Create a `refs.bib`, `references.bib`, or `bibliography.bib` file in your repository.
-2. In your `README.md`, cite using standard syntax: `[@citation-key]`.
+```markdown
+As shown in [@smith2020], results vary.
+```
-When you run `doc-engine build`, the CLI will automatically detect your `.bib` file, securely bind it to the sandbox, and inject a formatted References page at the end of the document.
+Point `--bib` at a different file, or name one in front matter, to override the
+search. Combine it with the `academic`, `article`, or `report` layout and LaTeX
+math for a paper that needs no LaTeX toolchain.
---
@@ -127,9 +160,13 @@ doc-engine --help Show all commands and flags
| `-s, --subtitle` | none | Subtitle shown under the title on the cover. |
| `-a, --author` | `git config user.name` | Author name override. |
| `--date` | today | Date shown on the cover. |
-| `--template` | `academic` | A built-in layout (`academic`, `modern`, `minimal`, `technical`, `book`) or a path to your own `.typ` file. |
+| `--template` | `academic` | A built-in layout (`academic`, `article`, `book`, `minimal`, `modern`, `report`, `technical`) or a path to your own `.typ` file. |
| `--accent` | template default | Accent color as a hex value (`#2563eb`) or a name (`blue`, `teal`, `rose`, ...). |
+| `--paper` | `a4` | Page size: `a3`–`a6`, `iso-b5`, `jis-b5`, `us-letter`, `us-legal`, `us-tabloid`. |
| `--bib` | auto-detect `refs.bib` | Path to a custom `.bib` file for the bibliography. |
+| `--pdf-standard` | off | Write an archival PDF/A file: `a-2b` or `a-3b`. |
+| `--tall-images` | `fit` | What to do with a picture taller than a page: `fit` scales it onto one page, `split` cuts it across several. |
+| `--fetch-images` | off | Download images linked by URL instead of rendering their alt text. |
| `--no-branding` | off | Hide the `doc-engine` attribution from the PDF. |
| `--dry-run` | off | Check the Markdown for errors and exit without writing a PDF. |
| `-w, --watch` | off | Rebuild automatically whenever the source file changes. |
@@ -209,8 +246,8 @@ accent: teal
...
```
-Supported keys: `title`, `subtitle`, `author`, `date`, `template`, `accent`, and
-`bib`. A flag passed on the command line overrides the matching front-matter key,
+Supported keys: `title`, `subtitle`, `author`, `date`, `template`, `accent`,
+`paper`, and `bib`. A flag passed on the command line overrides the matching front-matter key,
which in turn overrides the auto-detected value.
---
@@ -235,9 +272,140 @@ change. Press `Ctrl+C` to stop.
---
+## Diagrams
+
+Fenced blocks tagged `mermaid` or `svg` become pictures instead of code:
+
+````markdown
+```mermaid
+flowchart LR
+ Client --> API --> Ledger
+```
+````
+
+Mermaid is rendered through an embedded JavaScript engine, so there is no Node
+install and no headless browser — it stays a plain `pip install`. A `svg` block
+is passed straight through, since Typst draws SVG natively.
+
+If a diagram has a syntax error, the build stops and reports Mermaid's own
+message rather than producing a broken document.
+
+---
+
+## Math
+
+LaTeX math is translated into native Typst math, inline with `$…$` and as a
+display block with `$$…$$`:
+
+```markdown
+The quadratic formula is $x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}$.
+
+$$
+P(A \mid B) = \frac{P(B \mid A)\,P(A)}{P(B)}
+$$
+```
+
+Greek letters, relations, fractions, roots, sub- and superscripts, font
+commands, matrices, and `cases` are covered. Anything unrecognized passes
+through with its backslash removed, which lands on the right Typst symbol most
+of the time.
+
+A `$` that is not math stays untouched, so prices and shell variables survive:
+`$10`, `$HOME`, and `export $PATH` all render as written.
+
+---
+
+## Multi-File Documents
+
+A project that has outgrown a single file lists its parts in `doc-engine.md`,
+using ordinary Markdown links so the manifest still reads as a table of
+contents on GitHub:
+
+```markdown
+---
+title: Payments Platform
+subtitle: Engineering Handbook
+template: report
+---
+
+- [Overview](doc/overview.md)
+- [Architecture](diagrams/architecture.mmd)
+- [Schema](img/schema.svg)
+- [References](bib/references.bib)
+```
+
+Then just build:
+
+```bash
+doc-engine build
+```
+
+
+
+
+One manifest, one command: sections, a diagram, a figure, and a bibliography.
+
+
+Each entry is handled by what it is:
+
+| Entry | What happens |
+|---|---|
+| `.md` | Appended as a section, headings intact |
+| `.mmd`, `.mermaid` | Rendered as a diagram at that point |
+| `.png`, `.svg`, `.jpg`, … | Placed as a captioned figure |
+| `.bib` | Registered as the bibliography for the document |
+
+Paths resolve against the manifest's folder, and every included file resolves
+its own images relative to itself — so a file builds the same way alone as it
+does inside the manifest. `--watch` follows every file the manifest names.
+
+---
+
+## Project Configuration
+
+Team defaults belong in a `.doc-engine.toml` next to the project, not repeated
+in every document:
+
+```toml
+[doc-engine]
+template = "report"
+accent = "teal"
+paper = "us-letter"
+```
+
+A `[tool.doc-engine]` table in `pyproject.toml` works the same way. Precedence
+runs command-line flag, then front matter, then this file.
+
+---
+
+## Large Images
+
+A picture that does not fit the text block is scaled down until it does, so
+nothing is ever clipped. For a tall diagram — a top-down flowchart, a long
+schema — scaling it onto one page can leave it unreadable, so it can be cut
+across pages at full size instead:
+
+```bash
+doc-engine build --tall-images split
+```
+
+---
+
+## Archival PDFs
+
+For documents that have to stay readable for decades:
+
+```bash
+doc-engine build --pdf-standard a-2b
+```
+
+`a-3b` is also accepted, which additionally allows embedded attachments.
+
+---
+
## Templates
-`doc-engine` ships with five layouts. Switch with `--template `, and recolor any of them with `--accent`.
+`doc-engine` ships with seven layouts. Switch with `--template `, and recolor any of them with `--accent`.
| Template | Look |
|---|---|
@@ -246,6 +414,8 @@ change. Press `Ctrl+C` to stop.
| `minimal` | No cover or table of contents — a compact title block, then straight into the content. |
| `technical` | Bold layout with a filled accent banner and section markers. Good for engineering docs. |
| `book` | Classic centered title page with chapter-style section breaks. |
+| `article` | A LaTeX paper: New Computer Modern, numbered sections, title block on page one. |
+| `report` | Roomy and easy on the eyes — 12pt on generous leading, wide margins, lots of air. |
```bash
doc-engine build --template book
@@ -390,20 +560,26 @@ doc-engine-cli/
│ ├── __init__.py # Package version
│ ├── __main__.py # python -m doc_engine entrypoint
│ ├── cli.py # Click-based CLI + Git detection
+│ ├── help.py # Rich help screens
+│ ├── config.py # .doc-engine.toml project settings
+│ ├── frontmatter.py # Leading --- metadata block
+│ ├── manifest.py # doc-engine.md multi-file builds
│ ├── converter.py # Markdown → Typst transpiler
+│ ├── latex.py # LaTeX math → Typst math
+│ ├── diagrams.py # Mermaid and SVG blocks
+│ ├── images.py # Cutting pictures taller than a page
+│ ├── remote.py # Downloading linked images
│ ├── compiler.py # Typst → PDF compilation engine
│ ├── linter.py # Source checks (line/column reporting)
│ └── templates/
│ ├── academic.typ # Default IEEE-style report
+│ ├── article.typ # LaTeX paper, numbered sections
+│ ├── report.typ # Roomy and legible
│ ├── modern.typ # Clean sans-serif layout
│ ├── minimal.typ # Compact, no cover page
│ ├── technical.typ # Accent banner + section markers
│ └── book.typ # Centered title page, chapter breaks
-├── tests/
-│ ├── __init__.py
-│ ├── test_converter.py # Unit tests for the converter
-│ ├── test_linter.py # Unit tests for the linter
-│ └── test_cli.py # CLI and template/accent tests
+├── tests/ # 181 tests across every module
├── pyproject.toml # Package configuration + dependencies
├── LICENSE # MIT License
├── .gitignore
@@ -420,6 +596,8 @@ doc-engine-cli/
| [`rich`](https://github.com/Textualize/rich) | Terminal formatting and progress indicators | MIT |
| [`mistune`](https://github.com/lepture/mistune) | Markdown parser (pure Python) | BSD-3 |
| [`typst`](https://github.com/messense/typst-py) | Typst compiler bindings | Apache-2.0 |
+| [`mermaidx`](https://github.com/MohammadRaziei/mermaidx) | Mermaid rendering without Node | MIT |
+| [`pillow`](https://github.com/python-pillow/Pillow) | Cutting pictures taller than a page | MIT-CMU |
All dependencies are pure Python — no external binaries (Pandoc, LaTeX, etc.) are required.
@@ -483,8 +661,9 @@ docker run --rm -v "$PWD:/workspace" ghcr.io/leonardosalasd/doc-engine-cli build
- [x] Line breaks (` `)
- [x] Task lists (`- [x]` / `- [ ]`)
- [x] Footnotes (`[^1]`)
-- [x] Local images (remote images still render as alt-text)
-- [ ] Math blocks
+- [x] Local images, and remote ones with `--fetch-images`
+- [x] Math blocks (LaTeX `$…$` and `$$…$$`)
+- [x] Mermaid and SVG diagram blocks
---
@@ -497,11 +676,15 @@ docker run --rm -v "$PWD:/workspace" ghcr.io/leonardosalasd/doc-engine-cli build
- [x] YAML front-matter support for metadata override
- [x] Local image embedding
- [x] Watch mode for continuous rebuilds
-- [ ] Math expressions (LaTeX-style `$...$`)
-- [ ] Multi-file documentation merge
-- [ ] Mermaid diagram rendering
-- [ ] Image downloading and embedding for remote URLs
-- [ ] PDF/A compliance for archival
+- [x] Math expressions (LaTeX-style `$...$`)
+- [x] Multi-file documentation merge
+- [x] Mermaid diagram rendering
+- [x] Image downloading and embedding for remote URLs
+- [x] PDF/A compliance for archival
+- [x] Page size selection
+- [x] Project-level configuration file
+- [ ] Syntax highlighting themes for code blocks
+- [ ] Cross-references between documents
---
diff --git a/assets/doc-engine-v2.gif b/assets/doc-engine-v2.gif
new file mode 100644
index 0000000..bf73c12
Binary files /dev/null and b/assets/doc-engine-v2.gif differ
diff --git a/assets/features-v2.gif b/assets/features-v2.gif
new file mode 100644
index 0000000..28db333
Binary files /dev/null and b/assets/features-v2.gif differ
diff --git a/doc_engine/__init__.py b/doc_engine/__init__.py
index 840d2b0..a2cb996 100644
--- a/doc_engine/__init__.py
+++ b/doc_engine/__init__.py
@@ -1,2 +1,3 @@
"""doc-engine-cli: Modern Markdown to Typst compiler."""
-__version__ = "1.1.1"
+
+__version__ = "2.0.0"
diff --git a/doc_engine/cli.py b/doc_engine/cli.py
index 834c540..6cc6bf1 100644
--- a/doc_engine/cli.py
+++ b/doc_engine/cli.py
@@ -1,17 +1,28 @@
import os
import re
+import shutil
import subprocess
import sys
+import tempfile
import time
from pathlib import Path
import click
-from rich.console import Console
+from rich.console import Console, Group
from rich.panel import Panel
-
-from doc_engine import __version__, frontmatter
-from doc_engine.compiler import DEFAULT_TEMPLATE, available_templates, compile_pdf
+from rich.table import Table
+
+from doc_engine import __version__, config, frontmatter, images, manifest, settings
+from doc_engine.compiler import (
+ DEFAULT_PAPER,
+ PAPER_SIZES,
+ PDF_STANDARDS,
+ available_templates,
+ compile_pdf,
+)
from doc_engine.converter import convert_document, extract_title, strip_first_heading
+from doc_engine.diagrams import DiagramError
+from doc_engine.help import RichCommand, RichGroup
from doc_engine.linter import has_errors, lint
if sys.platform == "win32":
@@ -134,15 +145,15 @@ def _print_issues(issues: list, filename: str) -> None:
console.print(f" [{color}]{issue.format(filename)}[/{color}]")
-@click.group(invoke_without_command=True)
+@click.group(cls=RichGroup, invoke_without_command=True)
@click.version_option(__version__, prog_name="doc-engine")
@click.pass_context
def cli(ctx: click.Context) -> None:
if ctx.invoked_subcommand is None:
- click.echo(ctx.get_help())
+ ctx.get_help()
-@cli.command()
+@cli.command(cls=RichCommand)
@click.argument("input_file", required=False, type=click.Path(exists=False))
@click.option("-o", "--output", default=None, help="Output PDF file path.")
@click.option("-t", "--title", default=None, help="Document title override.")
@@ -161,11 +172,56 @@ def cli(ctx: click.Context) -> None:
callback=_normalize_accent,
help="Accent color as a hex value (#2563eb) or a name (blue, teal, rose...).",
)
+@click.option(
+ "--paper",
+ default=None,
+ type=click.Choice(PAPER_SIZES, case_sensitive=False),
+ help=f"Page size (default: {DEFAULT_PAPER}).",
+)
@click.option("--bib", default=None, help="Path to a custom .bib file.")
-@click.option("--no-branding", "no_branding", is_flag=True, help="Hide the doc-engine attribution in the PDF.")
-@click.option("--dry-run", "dry_run", is_flag=True, help="Check the Markdown for errors without producing a PDF.")
-@click.option("-w", "--watch", "watch", is_flag=True, help="Rebuild automatically whenever the source changes.")
-@click.option("-f", "--force", "force", is_flag=True, help="Overwrite the output file instead of writing a new one.")
+@click.option(
+ "--no-branding", "no_branding", is_flag=True, help="Hide the doc-engine attribution in the PDF."
+)
+@click.option(
+ "--dry-run",
+ "dry_run",
+ is_flag=True,
+ help="Check the Markdown for errors without producing a PDF.",
+)
+@click.option(
+ "--pdf-standard",
+ "pdf_standard",
+ default=None,
+ type=click.Choice(PDF_STANDARDS, case_sensitive=False),
+ help="Write an archival PDF/A file.",
+)
+@click.option(
+ "--tall-images",
+ "tall_images",
+ default=None,
+ type=click.Choice(("fit", "split"), case_sensitive=False),
+ help="What to do with a picture taller than a page (default: fit).",
+)
+@click.option(
+ "--fetch-images",
+ "fetch_images",
+ is_flag=True,
+ help="Download images that are linked by URL instead of skipping them.",
+)
+@click.option(
+ "-w",
+ "--watch",
+ "watch",
+ is_flag=True,
+ help="Rebuild automatically whenever the source changes.",
+)
+@click.option(
+ "-f",
+ "--force",
+ "force",
+ is_flag=True,
+ help="Overwrite the output file instead of writing a new one.",
+)
@click.option("--open", "open_pdf", is_flag=True, help="Open the PDF after generation.")
def build(
input_file: str | None,
@@ -176,8 +232,12 @@ def build(
date: str | None,
template: str | None,
accent: str | None,
+ paper: str | None,
bib: str | None,
no_branding: bool,
+ pdf_standard: str | None,
+ tall_images: str | None,
+ fetch_images: bool,
dry_run: bool,
watch: bool,
force: bool,
@@ -194,14 +254,23 @@ def build(
cwd = Path.cwd()
+ try:
+ project = config.load(cwd)
+ except config.ConfigError as exc:
+ console.print(f"[bold red]Error:[/bold red] {exc}")
+ raise SystemExit(1) from exc
+ if project:
+ console.print(f" [dim]Settings:[/dim] [cyan]{config.CONFIG_NAME}[/cyan]")
+
if input_file:
input_path = Path(input_file)
else:
- input_path = _find_file(cwd, _README_CANDIDATES)
+ input_path = manifest.find(cwd) or _find_file(cwd, _README_CANDIDATES)
if not input_path:
console.print(
- "[bold red]Error:[/bold red] No README.md found in current directory.\n"
- "[dim]Provide an input file or run from a directory containing a README.md.[/dim]"
+ "[bold red]Error:[/bold red] Nothing to build in this directory.\n"
+ "[dim]Add a README.md, write a doc-engine.md manifest, "
+ "or name a file to convert.[/dim]"
)
raise SystemExit(1)
console.print(f" [dim]Auto-detected:[/dim] [cyan]{input_path.name}[/cyan]")
@@ -220,47 +289,109 @@ def build(
console.print("[bold green]✓[/bold green] No issues found.")
return
+ flags = {
+ "template": template,
+ "paper": paper,
+ "accent": accent,
+ "author": author,
+ "bib": bib,
+ "pdf_standard": pdf_standard,
+ "no_branding": no_branding,
+ "fetch_images": fetch_images,
+ "tall_images": tall_images,
+ }
+
output_path = Path(output) if output else Path(f"{input_path.stem}_doc.pdf")
if not force:
output_path = _unique_path(output_path)
def run() -> bool:
- raw = input_path.read_text(encoding="utf-8")
- meta, content = frontmatter.parse(raw)
-
- issues = lint(raw)
- if issues:
- _print_issues(issues, str(input_path))
- console.print()
- if has_errors(issues):
- console.print("[bold red]Aborted:[/bold red] fix the errors above, or run [cyan]--dry-run[/cyan] to recheck.")
+ collected: manifest.Manifest | None = None
+ if manifest.is_manifest(input_path):
+ try:
+ collected = manifest.load(input_path)
+ except manifest.ManifestError as exc:
+ console.print(f"[bold red]Error:[/bold red] {exc}")
return False
-
- resolved_template = template or _resolve_template(meta.get("template", DEFAULT_TEMPLATE))
- if resolved_template is None:
- console.print(f"[bold red]Error:[/bold red] Unknown template in front matter — {meta.get('template')}")
+ meta = collected.metadata
+ content = ""
+ to_lint = [entry.path for entry in collected.entries if entry.kind == "markdown"]
+ else:
+ meta, content = frontmatter.parse(input_path.read_text(encoding="utf-8"))
+ to_lint = [input_path]
+
+ for source in to_lint:
+ issues = lint(source.read_text(encoding="utf-8"))
+ if issues:
+ _print_issues(issues, str(source))
+ console.print()
+ if has_errors(issues):
+ console.print(
+ "[bold red]Aborted:[/bold red] fix the errors above, or run [cyan]--dry-run[/cyan] to recheck."
+ )
+ return False
+
+ try:
+ chosen = settings.resolve(
+ flags=flags,
+ front_matter=meta,
+ project=project,
+ accent_lookup=_accent_hex,
+ template_lookup=_resolve_template,
+ )
+ except settings.SettingsError as exc:
+ console.print(f"[bold red]Error:[/bold red] {exc}")
return False
- resolved_accent = accent
- if resolved_accent is None and meta.get("accent"):
- resolved_accent = _accent_hex(meta["accent"])
- if resolved_accent is None:
- console.print(f"[bold yellow]Warning:[/bold yellow] Ignoring unknown accent — {meta['accent']}")
+ for warning in chosen.warnings:
+ console.print(f"[bold yellow]Warning:[/bold yellow] {warning}")
- resolved_bib = _resolve_bib(bib or meta.get("bib"), cwd)
- resolved_title = title or meta.get("title") or extract_title(content)
- resolved_author = author or meta.get("author") or _detect_git_user()
+ resolved_bib = _resolve_bib(chosen.bib, cwd)
+ if resolved_bib is None and collected is not None:
+ resolved_bib = collected.bibliography
+ resolved_title = title or meta.get("title") or extract_title(content) or input_path.stem
+ resolved_author = chosen.author or _detect_git_user()
resolved_subtitle = subtitle or meta.get("subtitle") or ""
resolved_date = date or meta.get("date")
+ split_ratio = images.page_ratio(chosen.paper) if chosen.split_tall_images else None
console.print(f" [dim]Title:[/dim] [white]{resolved_title}[/white]")
console.print(f" [dim]Author:[/dim] [white]{resolved_author}[/white]")
- console.print(f" [dim]Template:[/dim] [white]{_template_label(resolved_template)}[/white]")
+ console.print(
+ f" [dim]Template:[/dim] [white]{_template_label(chosen.template)}[/white]"
+ f" [dim]on[/dim] [white]{chosen.paper}[/white]"
+ )
console.print(f" [dim]Output:[/dim] [cyan]{output_path}[/cyan]")
console.print()
+ scratch = (
+ Path(tempfile.mkdtemp(prefix="doc-engine-"))
+ if chosen.fetch_images or chosen.split_tall_images
+ else None
+ )
+
with console.status("[bold blue]Converting Markdown → Typst…[/bold blue]"):
- conversion = convert_document(strip_first_heading(content), base_dir=input_path.parent)
+ try:
+ if collected is not None:
+ conversion = manifest.assemble(
+ collected,
+ work_dir=scratch,
+ fetch_remote=chosen.fetch_images,
+ split_tall=split_ratio,
+ )
+ else:
+ conversion = convert_document(
+ strip_first_heading(content),
+ base_dir=input_path.parent,
+ work_dir=scratch,
+ fetch_remote=chosen.fetch_images,
+ split_tall=split_ratio,
+ )
+ except DiagramError as exc:
+ console.print(
+ f"\n[bold red]Diagram failed:[/bold red] {exc.language} block — {exc.message}"
+ )
+ return False
with console.status("[bold blue]Compiling PDF…[/bold blue]"):
try:
@@ -272,11 +403,14 @@ def run() -> bool:
date=resolved_date,
output_path=str(output_path),
bib_file=str(resolved_bib.resolve()) if resolved_bib else None,
- template=resolved_template,
- accent=resolved_accent,
- branding=not no_branding,
+ template=chosen.template,
+ accent=chosen.accent,
+ branding=chosen.branding,
version=__version__,
assets=conversion.assets,
+ generated=conversion.generated,
+ paper=chosen.paper,
+ pdf_standard=chosen.pdf_standard,
)
except Exception as exc:
message = getattr(exc, "message", None) or str(exc)
@@ -284,8 +418,16 @@ def run() -> bool:
for hint in getattr(exc, "hints", []) or []:
console.print(f" [dim]hint: {hint}[/dim]")
return False
+ finally:
+ if scratch is not None:
+ shutil.rmtree(scratch, ignore_errors=True)
- console.print(f"[bold green]✓[/bold green] Generated → [bold cyan]{output_path}[/bold cyan]")
+ for warning in conversion.warnings:
+ console.print(f"[bold yellow]Warning:[/bold yellow] {warning}")
+
+ console.print(
+ f"[bold green]✓[/bold green] Generated → [bold cyan]{output_path}[/bold cyan]"
+ )
return True
ok = run()
@@ -313,20 +455,40 @@ def _resolve_bib(bib: str | None, cwd: Path) -> Path | None:
def _watch(input_path: Path, run) -> None:
- console.print("\n[dim]Watching for changes — press Ctrl+C to stop.[/dim]")
- last = _mtime(input_path)
+ watched = _watch_list(input_path)
+ count = len(watched)
+ noun = "file" if count == 1 else "files"
+ console.print(f"\n[dim]Watching {count} {noun} — press Ctrl+C to stop.[/dim]")
+ last = _fingerprint(watched)
try:
while True:
time.sleep(0.5)
- current = _mtime(input_path)
+ current = _fingerprint(watched)
if current != last:
last = current
console.rule(f"[dim]{time.strftime('%H:%M:%S')} — rebuilding[/dim]")
run()
+ # A rebuild can change which files the manifest pulls in.
+ watched = _watch_list(input_path)
+ last = _fingerprint(watched)
except KeyboardInterrupt:
console.print("\n[dim]Stopped.[/dim]")
+def _watch_list(input_path: Path) -> list[Path]:
+ """The manifest and everything it pulls in, so editing any part rebuilds."""
+ if not manifest.is_manifest(input_path):
+ return [input_path]
+ try:
+ return [input_path, *manifest.load(input_path).sources]
+ except (manifest.ManifestError, OSError):
+ return [input_path]
+
+
+def _fingerprint(paths: list[Path]) -> tuple[float, ...]:
+ return tuple(_mtime(path) for path in paths)
+
+
def _mtime(path: Path) -> float:
try:
return path.stat().st_mtime
@@ -334,20 +496,29 @@ def _mtime(path: Path) -> float:
return 0.0
-@cli.command()
+@cli.command(cls=RichCommand)
def info() -> None:
- """Show version, repository, and available templates."""
- templates = ", ".join(available_templates())
- body = (
- f"[bold white]doc-engine-cli[/bold white] [dim]v{__version__}[/dim]\n"
- "Zero-config Markdown → PDF documentation engine.\n\n"
- f"[dim]Repository:[/dim] [cyan]{REPO_URL}[/cyan]\n"
- f"[dim]Templates:[/dim] {templates}\n\n"
- "[dim]Common usage[/dim]\n"
- " doc-engine build\n"
- " doc-engine build README.md --template modern --accent teal\n"
- " doc-engine build --watch\n\n"
- "[dim]Run[/dim] [cyan]doc-engine --help[/cyan] [dim]for every command and flag.[/dim]"
+ """Show version, repository, and what this build supports."""
+ facts = Table.grid(padding=(0, 2))
+ facts.add_column(style="dim", no_wrap=True, vertical="top")
+ facts.add_column()
+ facts.add_row("Repository", f"[cyan]{REPO_URL}[/cyan]")
+ facts.add_row("Templates", ", ".join(available_templates()))
+ facts.add_row("Page sizes", f"{', '.join(PAPER_SIZES)} [dim](default {DEFAULT_PAPER})[/dim]")
+ facts.add_row("Accents", f"{', '.join(sorted(_NAMED_ACCENTS))} [dim]or any hex[/dim]")
+
+ body = Group(
+ f"[bold white]doc-engine-cli[/bold white] [dim]v{__version__}[/dim]",
+ "Turn Markdown into a polished PDF. No LaTeX, no config.",
+ "",
+ facts,
+ "",
+ "[dim]Markdown support[/dim]",
+ " tables · task lists · footnotes · local images · bibliography",
+ " mermaid and svg code blocks rendered as diagrams",
+ " LaTeX math, inline with $…$ and display with $$…$$",
+ "",
+ "[dim]Run[/dim] [cyan]doc-engine --help[/cyan] [dim]for every command and flag.[/dim]",
)
console.print(Panel(body, border_style="blue", padding=(1, 2), title="info"))
diff --git a/doc_engine/compiler.py b/doc_engine/compiler.py
index 3a6090b..3017b95 100644
--- a/doc_engine/compiler.py
+++ b/doc_engine/compiler.py
@@ -6,6 +6,24 @@
_TEMPLATES_DIR = Path(__file__).parent / "templates"
DEFAULT_TEMPLATE = "academic"
+DEFAULT_PAPER = "a4"
+
+# Archival profiles typst-py accepts. a-2b is the usual choice for documents
+# that have to be readable decades from now; a-3b additionally allows embedded
+# attachments.
+PDF_STANDARDS = ("a-2b", "a-3b")
+
+PAPER_SIZES = (
+ "a3",
+ "a4",
+ "a5",
+ "a6",
+ "iso-b5",
+ "jis-b5",
+ "us-legal",
+ "us-letter",
+ "us-tabloid",
+)
def available_templates() -> list[str]:
@@ -38,6 +56,9 @@ def compile_pdf(
subtitle: str = "",
date: str | None = None,
assets: dict[str, str] | None = None,
+ generated: dict[str, str] | None = None,
+ paper: str = DEFAULT_PAPER,
+ pdf_standard: str | None = None,
) -> None:
source = template_path(template)
if not source.exists():
@@ -61,18 +82,47 @@ def compile_pdf(
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(origin, destination)
+ for name, content in (generated or {}).items():
+ destination = tmp / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(content, encoding="utf-8")
+
accent_inject = f'rgb("{accent}")' if accent else "none"
main_file = tmp / "main.typ"
main_file.write_text(
_build_main(
- typst_body, title, author, bib_inject, accent_inject,
- branding, version, subtitle, date,
+ typst_body,
+ title,
+ author,
+ bib_inject,
+ accent_inject,
+ branding,
+ version,
+ subtitle,
+ date,
+ paper,
),
encoding="utf-8",
)
- typst.compile(str(main_file), output=resolved_output)
+ if pdf_standard:
+ typst.compile(str(main_file), output=resolved_output, pdf_standards=[pdf_standard])
+ else:
+ typst.compile(str(main_file), output=resolved_output)
+
+
+# Pictures keep their natural size unless they do not fit the text block, in
+# which case they shrink until they do. Forcing every image to full width blows
+# up small diagrams, and constraining only the width lets a tall one run past
+# the bottom of the page, where Typst clips whatever does not fit.
+_FIT_IMAGE = """#let fit-image(path) = context layout(area => {
+ let img = image(path)
+ let natural = measure(img)
+ let scale = calc.min(1.0, area.width / natural.width, area.height / natural.height)
+ if scale >= 1.0 { img } else { image(path, width: natural.width * scale) }
+})
+"""
def _build_main(
@@ -85,10 +135,12 @@ def _build_main(
version: str,
subtitle: str = "",
date: str | None = None,
+ paper: str = DEFAULT_PAPER,
) -> str:
date_line = f' date: "{_escape(date)}",\n' if date else ""
return (
'#import "template.typ": setup_doc\n\n'
+ f"{_FIT_IMAGE}\n"
"#show: setup_doc.with(\n"
f' title: "{_escape(title)}",\n'
f' subtitle: "{_escape(subtitle)}",\n'
@@ -98,6 +150,7 @@ def _build_main(
f" accent: {accent_inject},\n"
f" branding: {'true' if branding else 'false'},\n"
f' version: "{version}",\n'
+ f' paper: "{paper}",\n'
")\n\n"
f"{body}"
)
diff --git a/doc_engine/config.py b/doc_engine/config.py
new file mode 100644
index 0000000..a7a4a4a
--- /dev/null
+++ b/doc_engine/config.py
@@ -0,0 +1,92 @@
+"""Project-level defaults from a `.doc-engine.toml` file.
+
+A team that always builds with the same layout should not have to repeat the
+flags, and should not have to put them in every document's front matter either:
+
+ [doc-engine]
+ template = "report"
+ accent = "teal"
+ paper = "us-letter"
+
+Settings are read from `.doc-engine.toml` in the current folder, or from a
+`[tool.doc-engine]` table in `pyproject.toml`. Front matter overrides the file,
+and a command-line flag overrides both.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+if sys.version_info >= (3, 11):
+ import tomllib
+else: # tomllib landed in 3.11; tomli is the same parser under its old name.
+ try:
+ import tomli as tomllib
+ except ImportError:
+ tomllib = None
+
+CONFIG_NAME = ".doc-engine.toml"
+_PYPROJECT = "pyproject.toml"
+
+# Only these keys mean anything; anything else in the table is ignored so a
+# typo cannot silently change how a document is built.
+KNOWN_KEYS = (
+ "template",
+ "accent",
+ "paper",
+ "author",
+ "bib",
+ "branding",
+ "pdf_standard",
+ "fetch_images",
+ "tall_images",
+)
+
+
+class ConfigError(Exception):
+ """A configuration file that could not be read."""
+
+
+def load(directory: Path) -> dict[str, str]:
+ """Return the settings for a build started in *directory*."""
+ if tomllib is None:
+ return {}
+
+ dedicated = directory / CONFIG_NAME
+ if dedicated.is_file():
+ return _read(dedicated, ("doc-engine", "doc_engine"))
+
+ pyproject = directory / _PYPROJECT
+ if pyproject.is_file():
+ return _read(pyproject, ("tool",), nested="doc-engine")
+
+ return {}
+
+
+def _read(path: Path, keys: tuple[str, ...], nested: str | None = None) -> dict[str, str]:
+ try:
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
+ except (tomllib.TOMLDecodeError, OSError) as exc:
+ raise ConfigError(f"{path.name}: {exc}") from exc
+
+ table: dict = {}
+ for key in keys:
+ found = data.get(key)
+ if isinstance(found, dict):
+ table = found.get(nested, {}) if nested else found
+ if isinstance(table, dict) and table:
+ break
+ table = {}
+
+ return {
+ key: _stringify(value)
+ for key, value in table.items()
+ if key in KNOWN_KEYS and value is not None
+ }
+
+
+def _stringify(value: object) -> str:
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ return str(value)
diff --git a/doc_engine/converter.py b/doc_engine/converter.py
index c71c613..3905046 100644
--- a/doc_engine/converter.py
+++ b/doc_engine/converter.py
@@ -17,6 +17,8 @@
import mistune
+from doc_engine import diagrams, images, latex, remote
+
_TYPST_ESCAPES = {
"\\": "\\\\",
"#": "\\#",
@@ -33,6 +35,14 @@
}
_PLUGINS = ["table", "strikethrough", "task_lists", "footnotes"]
+
+_BLOCK_MATH = r"^ {0,3}\$\$[ \t]*\n(?P[\s\S]+?)\n\$\$[ \t]*$"
+
+# mistune's own inline rule writes the closing guard as a lookahead where it
+# needs a lookbehind, so "it costs $10 and $20" parses the middle as math and
+# mangles it. Requiring no space beside either delimiter, and no digit after the
+# closing one, keeps prices and shell variables out of math mode.
+_INLINE_MATH = r"\$(?!\s)(?P(?:[^$\\\n]|\\.)+?)(? str:
return "".join(_TYPST_ESCAPES.get(ch, ch) for ch in text)
+def _math_plugin(md: mistune.Markdown) -> None:
+ """Register math parsing with a stricter inline rule than mistune ships."""
+
+ def parse_block(block: Any, match: Any, state: Any) -> int:
+ state.append_token({"type": "block_math", "raw": match.group("math_text")})
+ return match.end() + 1
+
+ def parse_inline(inline: Any, match: Any, state: Any) -> int:
+ state.append_token({"type": "inline_math", "raw": match.group("math_text")})
+ return match.end()
+
+ md.block.register("block_math", _BLOCK_MATH, parse_block, before="list")
+ md.inline.register("inline_math", _INLINE_MATH, parse_inline, before="link")
+
+
def _render_children(renderer: mistune.BaseRenderer, token: dict, state: Any) -> str:
children = token.get("children")
if not children:
@@ -62,20 +87,43 @@ def _render_children(renderer: mistune.BaseRenderer, token: dict, state: Any) ->
@dataclass
class Conversion:
+ """Typst markup plus everything the compiler must place beside it.
+
+ `assets` maps a sandbox-relative name to a file on disk to copy. `generated`
+ maps a name to content produced during conversion, such as a rendered
+ diagram, which has no file of its own.
+ """
+
body: str
assets: dict[str, str] = field(default_factory=dict)
+ generated: dict[str, str] = field(default_factory=dict)
+ warnings: list[str] = field(default_factory=list)
class TypstRenderer(mistune.BaseRenderer):
NAME = "typst"
- def __init__(self, base_dir: Path | None = None) -> None:
+ def __init__(
+ self,
+ base_dir: Path | None = None,
+ namespace: str = "",
+ work_dir: Path | None = None,
+ fetch_remote: bool = False,
+ split_tall: float | None = None,
+ ) -> None:
super().__init__()
self._ordered_stack: list[bool] = []
self._base_dir = base_dir
+ self._namespace = namespace
+ self._work_dir = work_dir
+ self._fetch_remote = fetch_remote
+ self._split_tall = split_tall
+ self.warnings: list[str] = []
self._footnotes: dict[str, str] = {}
self._asset_names: dict[str, str] = {}
+ self._cut_pieces: dict[str, list[str]] = {}
self.assets: dict[str, str] = {}
+ self.generated: dict[str, str] = {}
def text(self, token: dict, state: Any) -> str:
return _escape(token["raw"])
@@ -101,7 +149,43 @@ def image(self, token: dict, state: Any) -> str:
asset = self._register_image(url)
if asset is None:
return f"[{alt}]" if alt else ""
- return f'#image("{asset}")'
+ return self._place(asset)
+
+ def _place(self, asset: str) -> str:
+ """Emit a picture, cut across pages when it is too tall for one."""
+ if self._split_tall is None:
+ return f'#fit-image("{asset}")'
+ pieces = self._cut(asset)
+ if len(pieces) == 1:
+ return f'#fit-image("{pieces[0]}")'
+ return "\n#pagebreak(weak: true)\n".join(f'#fit-image("{p}")' for p in pieces)
+
+ def _cut(self, asset: str) -> list[str]:
+ # The same picture can appear more than once, and it is registered under
+ # one name, so the pieces are remembered. Cutting twice would look for a
+ # file whose entry the first pass already replaced.
+ if asset in self._cut_pieces:
+ return self._cut_pieces[asset]
+
+ source = Path(self.assets.get(asset, ""))
+ if not source.is_file() or self._work_dir is None:
+ return [asset]
+ try:
+ written = images.split(source, self._work_dir, self._split_tall, Path(asset).stem)
+ except images.SplitError as exc:
+ self.warnings.append(str(exc))
+ return [asset]
+ if len(written) == 1:
+ return [asset]
+
+ names = []
+ for index, piece in enumerate(written):
+ name = f"assets/{self._namespace}{Path(asset).stem}_part{index}.png"
+ self.assets[name] = str(piece)
+ names.append(name)
+ del self.assets[asset]
+ self._cut_pieces[asset] = names
+ return names
def linebreak(self, token: dict, state: Any) -> str:
return "\\\n"
@@ -118,6 +202,12 @@ def inline_html(self, token: dict, state: Any) -> str:
return "\\\n"
return ""
+ def inline_math(self, token: dict, state: Any) -> str:
+ return f"${latex.to_typst(token['raw'])}$"
+
+ def block_math(self, token: dict, state: Any) -> str:
+ return f"\n$ {latex.to_typst(token['raw'])} $\n\n"
+
def footnote_ref(self, token: dict, state: Any) -> str:
key = token["raw"]
body = self._footnotes.get(key, "")
@@ -142,13 +232,21 @@ def block_code(self, token: dict, state: Any) -> str:
info = token.get("attrs", {}).get("info", "") or ""
lang = info.split()[0] if info else ""
code = token["raw"]
+ if lang and diagrams.is_diagram(lang):
+ return self._render_diagram(lang, code)
return f"\n```{lang}\n{code}```\n\n"
+ def _render_diagram(self, language: str, source: str) -> str:
+ svg = diagrams.render(language, source)
+ name = f"assets/{self._namespace}diagram_{len(self.generated)}.svg"
+ self.generated[name] = svg
+ return f'\n#align(center)[#fit-image("{name}")]\n\n'
+
def block_quote(self, token: dict, state: Any) -> str:
content = _render_children(self, token, state).strip()
return (
"#block(\n"
- ' inset: (left: 1.2em, y: 0.6em),\n'
+ " inset: (left: 1.2em, y: 0.6em),\n"
' stroke: (left: 2.5pt + rgb("#4a90d9")),\n'
' fill: rgb("#f0f4f8"),\n'
" radius: 2pt,\n"
@@ -203,8 +301,8 @@ def table(self, token: dict, state: Any) -> str:
if not head:
return ""
- head_row = head.get("children", [{}])[0]
- cells = head_row.get("children", [])
+ # A table_head holds its cells directly, without a table_row in between.
+ cells = head.get("children", [])
n = len(cells)
if n == 0:
return ""
@@ -249,7 +347,11 @@ def load_footnotes(self, tokens: list[dict], state: Any) -> None:
self._footnotes[str(key)] = " ".join(rendered.split())
def _register_image(self, url: str) -> str | None:
- if not url or self._base_dir is None or _REMOTE.match(url) or url.startswith("data:"):
+ if not url or url.startswith("data:"):
+ return None
+ if _REMOTE.match(url):
+ return self._download(url)
+ if self._base_dir is None:
return None
source = (self._base_dir / url).expanduser()
if not source.is_file():
@@ -257,23 +359,65 @@ def _register_image(self, url: str) -> str | None:
resolved = str(source.resolve())
if resolved in self._asset_names:
return self._asset_names[resolved]
- name = f"assets/{len(self.assets)}_{_UNSAFE.sub('_', source.name)}"
+ name = f"assets/{self._namespace}{len(self.assets)}_{_UNSAFE.sub('_', source.name)}"
self._asset_names[resolved] = name
self.assets[name] = resolved
return name
+ def _download(self, url: str) -> str | None:
+ """Fetch a linked image, when the build asked for that."""
+ if not self._fetch_remote or self._work_dir is None:
+ return None
+ if url in self._asset_names:
+ return self._asset_names[url]
+ try:
+ downloaded = remote.fetch(url, self._work_dir)
+ except remote.DownloadError as exc:
+ self.warnings.append(f"could not fetch {url} — {exc}")
+ return None
+ name = f"assets/{self._namespace}{len(self.assets)}_{downloaded.name}"
+ self._asset_names[url] = name
+ self.assets[name] = str(downloaded)
+ return name
+
def finalize(self, data: str, state: Any) -> str:
return data
-def convert_document(markdown: str, base_dir: Path | None = None) -> Conversion:
- renderer = TypstRenderer(base_dir=base_dir)
- md = mistune.create_markdown(renderer=None, plugins=_PLUGINS)
+def convert_document(
+ markdown: str,
+ base_dir: Path | None = None,
+ namespace: str = "",
+ work_dir: Path | None = None,
+ fetch_remote: bool = False,
+ split_tall: float | None = None,
+) -> Conversion:
+ """Convert Markdown to Typst.
+
+ *namespace* prefixes the names of assets and rendered diagrams, so several
+ documents assembled into one PDF cannot overwrite each other's files.
+ *work_dir* is scratch space for anything written during conversion.
+ *fetch_remote* allows downloading linked images, and *split_tall* is the
+ page proportion above which a picture is cut across pages.
+ """
+ renderer = TypstRenderer(
+ base_dir=base_dir,
+ namespace=namespace,
+ work_dir=work_dir,
+ fetch_remote=fetch_remote,
+ split_tall=split_tall,
+ )
+ md = mistune.create_markdown(renderer=None, plugins=[*_PLUGINS, _math_plugin])
tokens, state = md.parse(markdown)
renderer.load_footnotes(tokens, state)
body = renderer.render_tokens(tokens, state)
body = _CITATION.sub(r"@\1", body)
- return Conversion(body=body, assets=renderer.assets)
+ return Conversion(
+ body=body,
+ assets=renderer.assets,
+ generated=renderer.generated,
+ warnings=renderer.warnings,
+ )
def convert(markdown: str, base_dir: Path | None = None) -> str:
diff --git a/doc_engine/diagrams.py b/doc_engine/diagrams.py
new file mode 100644
index 0000000..cbc8e72
--- /dev/null
+++ b/doc_engine/diagrams.py
@@ -0,0 +1,62 @@
+"""Fenced blocks that become pictures instead of code.
+
+A ```mermaid block is rendered to SVG through mermaidx, which runs Mermaid on an
+embedded JavaScript engine — no Node install, no headless browser. A ```svg block
+is passed straight through, since Typst renders SVG natively.
+"""
+
+from __future__ import annotations
+
+MERMAID_LANGUAGES = ("mermaid", "mmd")
+SVG_LANGUAGES = ("svg",)
+
+
+class DiagramError(Exception):
+ """A diagram source that could not be rendered."""
+
+ def __init__(self, language: str, message: str) -> None:
+ super().__init__(message)
+ self.language = language
+ self.message = message
+
+
+def is_diagram(language: str) -> bool:
+ lang = language.strip().lower()
+ return lang in MERMAID_LANGUAGES or lang in SVG_LANGUAGES
+
+
+def render(language: str, source: str) -> str:
+ """Return the SVG for a diagram block, raising DiagramError if it is invalid."""
+ lang = language.strip().lower()
+ if lang in SVG_LANGUAGES:
+ return source
+ if lang in MERMAID_LANGUAGES:
+ return _render_mermaid(source)
+ raise DiagramError(language, f"unsupported diagram language: {language}")
+
+
+def _render_mermaid(source: str) -> str:
+ try:
+ import mermaidx
+ except ImportError as exc:
+ raise DiagramError(
+ "mermaid",
+ "mermaid rendering needs the 'mermaidx' package, which ships with "
+ "doc-engine-cli — reinstall with 'pip install --upgrade doc-engine-cli'",
+ ) from exc
+
+ try:
+ return mermaidx.render(source).svg()
+ except DiagramError:
+ raise
+ except Exception as exc:
+ raise DiagramError("mermaid", _clean(str(exc))) from exc
+
+
+def _clean(message: str) -> str:
+ """Trim mermaidx's wrapper so the user sees Mermaid's own complaint."""
+ text = message.strip()
+ for prefix in ("Mermaid rendering failed:", "Error:"):
+ if text.startswith(prefix):
+ text = text[len(prefix) :].strip()
+ return " ".join(text.split())
diff --git a/doc_engine/help.py b/doc_engine/help.py
new file mode 100644
index 0000000..c54236e
--- /dev/null
+++ b/doc_engine/help.py
@@ -0,0 +1,202 @@
+"""Help screens rendered with Rich.
+
+Click's default help splits the tool in two: `--help` lists the commands, and
+the flags people actually want live behind `build --help`. Since building is
+what nearly every run does, the top-level screen shows those flags directly,
+grouped by what they affect rather than in one flat column.
+
+Help text is read from the Click parameters themselves, so there is one source
+of truth for every description.
+"""
+
+from __future__ import annotations
+
+import click
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+from rich.text import Text
+
+# Which build options belong under which heading, in the order they are shown.
+_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
+ ("Input and output", ("output", "force", "open_pdf")),
+ ("Document details", ("title", "subtitle", "author", "date", "bib")),
+ ("Appearance", ("template", "accent", "paper", "no_branding")),
+ ("While you work", ("watch", "dry_run")),
+)
+
+_EXAMPLES = (
+ ("doc-engine build", "Build README.md from the current folder"),
+ ("doc-engine build notes.md", "Build a specific file"),
+ ("doc-engine build --watch", "Rebuild every time you save"),
+ ("doc-engine build --template modern --accent teal", "Pick a layout and color"),
+ ("doc-engine build --paper us-letter", "Use US Letter instead of A4"),
+ ("doc-engine build --dry-run", "Check for problems without writing a PDF"),
+)
+
+
+class RichGroup(click.Group):
+ """The top-level command, whose help doubles as the guide to `build`."""
+
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
+ console = Console()
+ _banner(console, ctx)
+ _usage(console)
+ _examples(console)
+
+ build = self.get_command(ctx, "build")
+ if build is not None:
+ _options(console, build)
+
+ _commands(console, self, ctx)
+ _footer(console)
+
+
+class RichCommand(click.Command):
+ """A subcommand whose options are grouped the same way."""
+
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
+ console = Console()
+ console.print()
+ console.print(
+ Text.assemble(
+ ("Usage ", "bold"),
+ (f"doc-engine {self.name} ", "bold cyan"),
+ (_argument_hint(self), "cyan"),
+ ("[OPTIONS]", "dim"),
+ )
+ )
+ if self.help:
+ console.print(Text(f" {self.help.strip()}", style="dim"))
+ if self.name == "build":
+ _examples(console)
+ _options(console, self)
+ _footer(console)
+
+
+def _banner(console: Console, ctx: click.Context) -> None:
+ from doc_engine import __version__
+
+ console.print()
+ console.print(
+ Panel(
+ Text.assemble(
+ ("doc-engine ", "bold white"),
+ (f"v{__version__}", "dim"),
+ ("\nTurn Markdown into a polished PDF. No LaTeX, no config.", "dim"),
+ ),
+ border_style="blue",
+ padding=(0, 2),
+ )
+ )
+
+
+def _usage(console: Console) -> None:
+ console.print()
+ console.print(
+ Text.assemble(
+ ("Usage ", "bold"),
+ ("doc-engine build ", "bold cyan"),
+ ("[FILE] ", "cyan"),
+ ("[OPTIONS]", "dim"),
+ )
+ )
+ console.print(
+ Text.assemble(
+ (" ", ""),
+ ("FILE", "cyan"),
+ (" defaults to the README.md in the current folder.", "dim"),
+ )
+ )
+
+
+def _examples(console: Console) -> None:
+ console.print()
+ console.print(Text("Examples", style="bold"))
+ table = Table.grid(padding=(0, 2))
+ table.add_column(style="cyan", no_wrap=True)
+ table.add_column(style="dim")
+ for command, description in _EXAMPLES:
+ table.add_row(f" {command}", description)
+ console.print(table)
+
+
+def _options(console: Console, command: click.Command) -> None:
+ by_name = {param.name: param for param in command.params}
+ shown: set[str] = set()
+
+ for heading, names in _GROUPS:
+ rows = [(by_name[name], name) for name in names if name in by_name]
+ if not rows:
+ continue
+ console.print()
+ console.print(Text(heading, style="bold"))
+ table = Table.grid(padding=(0, 2))
+ table.add_column(style="cyan", no_wrap=True)
+ table.add_column()
+ for param, name in rows:
+ shown.add(name)
+ table.add_row(f" {_flags(param)}", _describe(param))
+ console.print(table)
+
+ extra = [
+ param
+ for param in command.params
+ if param.name not in shown
+ and isinstance(param, click.Option)
+ and param.name not in ("help",)
+ ]
+ if extra:
+ console.print()
+ console.print(Text("Other", style="bold"))
+ table = Table.grid(padding=(0, 2))
+ table.add_column(style="cyan", no_wrap=True)
+ table.add_column()
+ for param in extra:
+ table.add_row(f" {_flags(param)}", _describe(param))
+ console.print(table)
+
+
+def _commands(console: Console, group: click.Group, ctx: click.Context) -> None:
+ console.print()
+ console.print(Text("Commands", style="bold"))
+ table = Table.grid(padding=(0, 2))
+ table.add_column(style="cyan", no_wrap=True)
+ table.add_column(style="dim")
+ for name in sorted(group.list_commands(ctx)):
+ command = group.get_command(ctx, name)
+ if command is None:
+ continue
+ table.add_row(f" doc-engine {name}", (command.short_help or command.help or "").strip())
+ table.add_row(" doc-engine --version", "Print the version and exit")
+ console.print(table)
+
+
+def _footer(console: Console) -> None:
+ console.print()
+ console.print(
+ Text.assemble(
+ ("Docs ", "dim"),
+ ("https://github.com/leonardosalasd/doc-engine-cli", "cyan"),
+ )
+ )
+ console.print()
+
+
+def _flags(param: click.Parameter) -> str:
+ if isinstance(param, click.Argument):
+ return param.name.upper()
+ return ", ".join(param.opts)
+
+
+def _describe(param: click.Parameter) -> Text:
+ text = Text(getattr(param, "help", "") or "")
+ choices = param.type.choices if isinstance(param.type, click.Choice) else None
+ if choices:
+ text.append(f"\n{', '.join(choices)}", style="dim")
+ return text
+
+
+def _argument_hint(command: click.Command) -> str:
+ names = [p.name.upper() for p in command.params if isinstance(p, click.Argument)]
+ return f"[{names[0]}] " if names else ""
diff --git a/doc_engine/images.py b/doc_engine/images.py
new file mode 100644
index 0000000..0afa387
--- /dev/null
+++ b/doc_engine/images.py
@@ -0,0 +1,82 @@
+"""Placing pictures that are taller than a page.
+
+A wide diagram only has to be scaled down. A tall one — a top-down flowchart,
+a long schema — has no good answer at page width: scaled to fit a single page it
+becomes unreadable, and left alone it runs off the bottom. Typst does not
+paginate an image, so the picture is cut into page-sized pieces here and placed
+one after another.
+"""
+
+from __future__ import annotations
+
+import math
+from pathlib import Path
+
+# Height divided by width for each page size, used to decide when a picture is
+# too tall to sit on one page. Every A and B size shares the same proportions.
+_ISO_RATIO = math.sqrt(2)
+PAGE_RATIOS = {
+ "a3": _ISO_RATIO,
+ "a4": _ISO_RATIO,
+ "a5": _ISO_RATIO,
+ "a6": _ISO_RATIO,
+ "iso-b5": _ISO_RATIO,
+ "jis-b5": _ISO_RATIO,
+ "us-letter": 11 / 8.5,
+ "us-legal": 14 / 8.5,
+ "us-tabloid": 17 / 11,
+}
+
+# A picture is only cut when it is clearly taller than the page, so ordinary
+# portrait figures are left alone.
+TOLERANCE = 1.15
+
+# Cutting a picture into more pieces than this means each one is a sliver.
+MAX_PIECES = 12
+
+
+class SplitError(Exception):
+ """A picture that could not be cut."""
+
+
+def page_ratio(paper: str) -> float:
+ return PAGE_RATIOS.get(paper, _ISO_RATIO)
+
+
+def needs_splitting(width: int, height: int, ratio: float) -> bool:
+ return width > 0 and height / width > ratio * TOLERANCE
+
+
+def split(source: Path, into: Path, ratio: float, stem: str) -> list[Path]:
+ """Cut *source* into page-tall pieces, returning them in reading order."""
+ try:
+ from PIL import Image
+ except ImportError as exc:
+ raise SplitError(
+ "splitting tall images needs the 'pillow' package, which ships with "
+ "doc-engine-cli — reinstall with 'pip install --upgrade doc-engine-cli'"
+ ) from exc
+
+ try:
+ with Image.open(source) as picture:
+ picture.load()
+ width, height = picture.size
+ if not needs_splitting(width, height, ratio):
+ return [source]
+
+ pieces = min(math.ceil((height / width) / ratio), MAX_PIECES)
+ step = math.ceil(height / pieces)
+
+ into.mkdir(parents=True, exist_ok=True)
+ written: list[Path] = []
+ for index in range(pieces):
+ top = index * step
+ bottom = min(top + step, height)
+ if top >= bottom:
+ break
+ target = into / f"{stem}_{index}.png"
+ picture.crop((0, top, width, bottom)).save(target)
+ written.append(target)
+ return written
+ except OSError as exc:
+ raise SplitError(f"could not read {source.name}: {exc}") from exc
diff --git a/doc_engine/latex.py b/doc_engine/latex.py
new file mode 100644
index 0000000..e04b214
--- /dev/null
+++ b/doc_engine/latex.py
@@ -0,0 +1,387 @@
+"""LaTeX math translated into Typst math.
+
+Typst has its own math language rather than a LaTeX dialect, so `$\\frac{a}{b}$`
+means nothing to it: a backslash escapes the next character, and the compiler
+ends up complaining about an unknown variable named `rac`. This module rewrites
+the LaTeX people already have in their Markdown into the equivalent Typst.
+
+Coverage is the common ground of real documents — Greek letters, operators and
+relations, fractions and roots, sub- and superscripts, matrices, cases, and the
+usual font commands. Anything unrecognized is passed through with its backslash
+removed, which is usually right, since most LaTeX command names match a Typst
+symbol of the same name.
+"""
+
+from __future__ import annotations
+
+import re
+
+# Every name here was checked against the Typst compiler; see tests/test_latex.py.
+_SYMBOLS = {
+ "varepsilon": "epsilon.alt",
+ "vartheta": "theta.alt",
+ "varphi": "phi.alt",
+ "varrho": "rho.alt",
+ "varsigma": "sigma.alt",
+ "varpi": "pi.alt",
+ "infty": "infinity",
+ "cdot": "dot.op",
+ "cdots": "dots.c",
+ "ldots": "dots.h",
+ "dots": "dots.h",
+ "vdots": "dots.v",
+ "ddots": "dots.down",
+ "pm": "plus.minus",
+ "mp": "minus.plus",
+ "le": "<=",
+ "leq": "<=",
+ "ge": ">=",
+ "geq": ">=",
+ "ne": "!=",
+ "neq": "!=",
+ "ll": "<<",
+ "gg": ">>",
+ "sim": "tilde.op",
+ "simeq": "tilde.eq",
+ "cong": "tilde.equiv",
+ "propto": "prop",
+ "notin": "in.not",
+ "ni": "in.rev",
+ "subseteq": "subset.eq",
+ "supseteq": "supset.eq",
+ "cup": "union",
+ "cap": "inter",
+ "setminus": "without",
+ "oplus": "xor",
+ "otimes": "times.o",
+ "circ": "compose",
+ "to": "arrow.r",
+ "rightarrow": "arrow.r",
+ "leftarrow": "arrow.l",
+ "leftrightarrow": "arrow.l.r",
+ "Rightarrow": "arrow.r.double",
+ "Leftarrow": "arrow.l.double",
+ "Leftrightarrow": "arrow.l.r.double",
+ "implies": "arrow.r.double",
+ "iff": "arrow.l.r.double",
+ "mapsto": "arrow.r.bar",
+ "langle": "chevron.l",
+ "rangle": "chevron.r",
+ "mid": "|",
+ "vert": "|",
+ "Vert": "||",
+ "lvert": "|",
+ "rvert": "|",
+ "lVert": "||",
+ "rVert": "||",
+ "emptyset": "emptyset",
+ "varnothing": "emptyset",
+ "prod": "product",
+ "bigcup": "union.big",
+ "bigcap": "inter.big",
+ "int": "integral",
+ "iint": "integral.double",
+ "oint": "integral.cont",
+ "nabla": "nabla",
+ "partial": "partial",
+ "ast": "ast",
+ "star": "star",
+ "bullet": "circle.filled.small",
+ "top": "top",
+ "bot": "bot",
+ "perp": "perp",
+ "angle": "angle",
+ "triangle": "triangle",
+ "aleph": "aleph",
+ "hbar": "ℏ",
+ "ell": "ell",
+ "Re": "Re",
+ "Im": "Im",
+ "quad": "quad",
+ "qquad": "wide",
+ "lnot": "not",
+ "neg": "not",
+ "land": "and",
+ "lor": "or",
+ "wedge": "and",
+ "vee": "or",
+ "subsetneq": "subset.neq",
+ "supsetneq": "supset.neq",
+ "vdash": "tack.r",
+ "models": "models",
+ "nmid": "divides.not",
+ "parallel": "parallel",
+ "lfloor": "floor.l",
+ "rfloor": "floor.r",
+ "lceil": "ceil.l",
+ "rceil": "ceil.r",
+ "bigoplus": "xor.big",
+ "bigotimes": "times.o.big",
+ "bigvee": "or.big",
+ "bigwedge": "and.big",
+ "coprod": "product.co",
+ "imath": "dotless.i",
+ "jmath": "dotless.j",
+ "Diamond": "diamond",
+ "square": "square",
+ "uparrow": "arrow.t",
+ "downarrow": "arrow.b",
+ "updownarrow": "arrow.t.b",
+ "nearrow": "arrow.tr",
+ "searrow": "arrow.br",
+ "nwarrow": "arrow.tl",
+ "swarrow": "arrow.bl",
+ "longrightarrow": "arrow.r.long",
+ "longleftarrow": "arrow.l.long",
+ "longleftrightarrow": "arrow.l.r.long",
+ "hookrightarrow": "arrow.r.hook",
+ "hookleftarrow": "arrow.l.hook",
+ "rightharpoonup": "harpoon.rt",
+ "leftharpoonup": "harpoon.lt",
+ "approxeq": "approx.eq",
+ "preceq": "prec.eq",
+ "succeq": "succ.eq",
+ "prec": "prec",
+ "succ": "succ",
+ "asymp": "asymp",
+ "bowtie": "join",
+ "Join": "join",
+ "therefore": "therefore",
+ "because": "because",
+ "prime": "prime",
+ "degree": "degree",
+ "colon": ":",
+ "ldotp": ".",
+ "wp": "cal(P)",
+ "hslash": "ℏ",
+}
+
+# Commands whose LaTeX braces become Typst call arguments.
+_ONE_ARG = {
+ "sqrt": "sqrt",
+ "text": None,
+ "textrm": None,
+ "textbf": "bold",
+ "textit": "italic",
+ "mathbb": "bb",
+ "mathcal": "cal",
+ "mathfrak": "frak",
+ "mathbf": "bold",
+ "mathit": "italic",
+ "mathrm": "upright",
+ "mathsf": "sans",
+ "mathtt": "mono",
+ "operatorname": None,
+ "hat": "hat",
+ "widehat": "hat",
+ "bar": "macron",
+ "overline": "overline",
+ "underline": "underline",
+ "vec": "arrow",
+ "overrightarrow": "arrow",
+ "overleftarrow": "arrow.l",
+ "overbrace": "overbrace",
+ "underbrace": "underbrace",
+ "tilde": "tilde",
+ "widetilde": "tilde",
+ "dot": "dot",
+ "ddot": "dot.double",
+ "boldsymbol": "bold",
+}
+
+_TWO_ARG = {
+ "frac": "frac",
+ "dfrac": "frac",
+ "tfrac": "frac",
+ "binom": "binom",
+}
+
+# LaTeX environments and the Typst constructor that replaces them.
+_MATRIX_ENVIRONMENTS = {
+ "matrix": ("mat", None),
+ "pmatrix": ("mat", "#none"),
+ "bmatrix": ("mat", '"["'),
+ "Bmatrix": ("mat", '"{"'),
+ "vmatrix": ("mat", '"|"'),
+ "Vmatrix": ("mat", '"||"'),
+}
+
+# How `\\` and `&` translate, per context: LaTeX separates matrix columns with
+# `&` and rows with `\\`, while Typst uses `,` and `;`. In cases, every row is a
+# single Typst argument, so `&` is only alignment and collapses to a space.
+_ROW_SEPARATORS = {
+ "normal": ("\\", "&"),
+ "matrix": (";", ","),
+ "cases": (",", " "),
+}
+
+_SPACING = {",": "thin", ";": "med", ":": "med", "!": "", " ": " "}
+_COMMAND = re.compile(r"[A-Za-z]+")
+_LETTERS = re.compile(r"[A-Za-z]+")
+
+# Typst reads a run of letters as one identifier and fails when it does not know
+# it, while LaTeX reads `xy` as x times y. Runs are therefore split into single
+# letters unless they name one of these operators, which Typst does define.
+_OPERATOR_NAMES = """
+arccos arcsin arctan arg ceil cos cosh cot coth csc deg det dif dim exp floor
+gcd hom id im inf ker lcm lim liminf limsup ln log max min mod Pr round sec
+sin sinh sup tan tanh tr upright
+"""
+_OPERATORS = frozenset(_OPERATOR_NAMES.split()) # noqa: SIM905 - a word list reads better here
+
+
+def to_typst(latex: str) -> str:
+ """Translate a LaTeX math expression into Typst math markup."""
+ return _convert(latex.strip()).strip()
+
+
+def _convert(source: str, mode: str = "normal") -> str:
+ out: list[str] = []
+ index = 0
+ while index < len(source):
+ char = source[index]
+ if char == "\\":
+ text, index = _command(source, index, mode)
+ _append(out, text)
+ elif char == "{":
+ group, index = _group(source, index)
+ _append(out, f"({_convert(group, mode)})")
+ elif char == "&":
+ _append(out, _ROW_SEPARATORS[mode][1])
+ index += 1
+ elif char.isalpha():
+ match = _LETTERS.match(source, index)
+ run = match.group()
+ index = match.end()
+ _append(out, run if run in _OPERATORS else " ".join(run))
+ else:
+ _append(out, char)
+ index += 1
+ return "".join(out)
+
+
+def _append(out: list[str], text: str) -> None:
+ """Add *text*, keeping a space between neighbours that would otherwise merge.
+
+ Typst would read the result of `i` followed by `\\pi` as one identifier
+ called `ipi`, so adjacent names have to stay separated.
+ """
+ if text and out:
+ previous = out[-1]
+ if previous and (previous[-1].isalnum() or previous[-1] == ".") and text[0].isalnum():
+ out.append(" ")
+ out.append(text)
+
+
+def _command(source: str, index: int, mode: str) -> tuple[str, int]:
+ """Translate the command starting at the backslash in *index*."""
+ index += 1
+ if index >= len(source):
+ return "", index
+
+ char = source[index]
+ if char == "\\":
+ return _ROW_SEPARATORS[mode][0], index + 1
+ if char in _SPACING:
+ return _SPACING[char], index + 1
+ if char in "{}|":
+ return {"{": "{", "}": "}", "|": "||"}[char], index + 1
+
+ match = _COMMAND.match(source, index)
+ if not match:
+ return char, index + 1
+
+ name = match.group()
+ index = match.end()
+
+ if name == "begin":
+ return _environment(source, index)
+ if name == "end":
+ _, index = _next_argument(source, index)
+ return "", index
+ if name in ("left", "right"):
+ return "", index
+ if name in _TWO_ARG:
+ first, index = _next_argument(source, index)
+ second, index = _next_argument(source, index)
+ return f"{_TWO_ARG[name]}({_convert(first, mode)}, {_convert(second, mode)})", index
+ if name in _ONE_ARG:
+ if name == "sqrt" and _peek(source, index) == "[":
+ degree, index = _bracket(source, index)
+ radicand, index = _next_argument(source, index)
+ return f"root({_convert(degree, mode)}, {_convert(radicand, mode)})", index
+ argument, index = _next_argument(source, index)
+ function = _ONE_ARG[name]
+ if function is None:
+ return f'"{argument.strip()}"', index
+ return f"{function}({_convert(argument, mode)})", index
+
+ return _SYMBOLS.get(name, name), index
+
+
+def _environment(source: str, index: int) -> tuple[str, int]:
+ """Translate a `\\begin{env}...\\end{env}` block into its Typst constructor."""
+ name, index = _next_argument(source, index)
+ name = name.strip()
+ body, index = _environment_body(source, index, name)
+
+ if name == "cases":
+ return f"cases({_convert(body, 'cases').strip()})", index
+ if name in _MATRIX_ENVIRONMENTS:
+ constructor, delimiter = _MATRIX_ENVIRONMENTS[name]
+ inner = _convert(body, "matrix").strip().strip(";").strip()
+ if delimiter is None:
+ return f"{constructor}(delim: #none, {inner})", index
+ if delimiter == "#none":
+ return f"{constructor}({inner})", index
+ return f"{constructor}(delim: {delimiter}, {inner})", index
+ return _convert(body, "normal"), index
+
+
+def _environment_body(source: str, index: int, name: str) -> tuple[str, int]:
+ """Return everything up to the matching `\\end{name}`, and the index past it."""
+ closing = f"\\end{{{name}}}"
+ end = source.find(closing, index)
+ if end == -1:
+ return source[index:], len(source)
+ return source[index:end], end + len(closing)
+
+
+def _next_argument(source: str, index: int) -> tuple[str, int]:
+ """Read the next `{...}` group, or a single token when there are no braces."""
+ while index < len(source) and source[index] == " ":
+ index += 1
+ if index >= len(source):
+ return "", index
+ if source[index] == "{":
+ return _group(source, index)
+ if source[index] == "\\":
+ match = _COMMAND.match(source, index + 1)
+ end = match.end() if match else index + 2
+ return source[index:end], end
+ return source[index], index + 1
+
+
+def _group(source: str, index: int) -> tuple[str, int]:
+ depth = 0
+ start = index + 1
+ while index < len(source):
+ if source[index] == "{":
+ depth += 1
+ elif source[index] == "}":
+ depth -= 1
+ if depth == 0:
+ return source[start:index], index + 1
+ index += 1
+ return source[start:], index
+
+
+def _bracket(source: str, index: int) -> tuple[str, int]:
+ end = source.find("]", index)
+ if end == -1:
+ return "", index
+ return source[index + 1 : end], end + 1
+
+
+def _peek(source: str, index: int) -> str:
+ return source[index] if index < len(source) else ""
diff --git a/doc_engine/linter.py b/doc_engine/linter.py
index 5551780..0654fe5 100644
--- a/doc_engine/linter.py
+++ b/doc_engine/linter.py
@@ -50,9 +50,7 @@ def lint(markdown: str) -> list[Issue]:
issues.extend(_check_links(line, number))
if in_fence:
- issues.append(
- Issue(fence_opened_at, 1, "error", "unclosed fenced code block")
- )
+ issues.append(Issue(fence_opened_at, 1, "error", "unclosed fenced code block"))
return issues
@@ -66,13 +64,9 @@ def _check_links(line: str, number: int) -> list[Issue]:
if url:
continue
if is_image:
- found.append(
- Issue(number, column, "warning", "image source is empty")
- )
+ found.append(Issue(number, column, "warning", "image source is empty"))
else:
- found.append(
- Issue(number, column, "error", "link URL must not be empty")
- )
+ found.append(Issue(number, column, "error", "link URL must not be empty"))
return found
diff --git a/doc_engine/manifest.py b/doc_engine/manifest.py
new file mode 100644
index 0000000..70c8425
--- /dev/null
+++ b/doc_engine/manifest.py
@@ -0,0 +1,194 @@
+"""A manifest that builds one PDF out of many files.
+
+A project that outgrew a single Markdown file lists its parts in a manifest —
+`doc-engine.md` by default — as ordinary Markdown links, so the file still reads
+and renders as a table of contents on GitHub:
+
+ ---
+ title: Payments API
+ template: academic
+ ---
+
+ - [Overview](doc/overview.md)
+ - [Architecture](diagrams/architecture.mmd)
+ - [Schema](img/schema.png)
+ - [References](bib/references.bib)
+
+Each entry is handled by what it is. Markdown files are appended as sections,
+diagram sources are rendered where they appear, images are placed as figures,
+and a bibliography is registered for the whole document. Paths resolve against
+the manifest's own folder, and headings inside an included file keep their
+levels, so a `##` stays a subsection of the entry above it.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from doc_engine import diagrams, frontmatter
+from doc_engine.converter import Conversion, convert, convert_document
+
+MANIFEST_NAMES = ("doc-engine.md", "docengine.md", "SUMMARY.md")
+
+_LINK = re.compile(r"\[(?P