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.
-doc-engine-cli Generation Demo +doc-engine-cli turning a Markdown file into a PDF
``` @@ -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 +``` + +
+doc-engine building a multi-file document +
+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