Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion doc_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
available_themes,
compile_pdf,
)
from doc_engine.converter import convert_document, extract_title, strip_first_heading
from doc_engine.converter import (
convert_document,
extract_title,
extract_title_markup,
strip_first_heading,
)
from doc_engine.diagrams import DiagramError
from doc_engine.help import RichCommand, RichGroup
from doc_engine.linter import has_errors, lint
Expand Down Expand Up @@ -383,6 +388,7 @@ def run() -> bool:
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
title_markup = None if title or meta.get("title") else extract_title_markup(content)
resolved_author = chosen.author or _detect_git_user()
resolved_subtitle = subtitle or meta.get("subtitle") or ""
resolved_date = date or meta.get("date")
Expand Down Expand Up @@ -447,6 +453,7 @@ def run() -> bool:
compile_pdf(
typst_body=conversion.body,
title=resolved_title,
title_markup=title_markup,
author=resolved_author,
subtitle=resolved_subtitle,
date=resolved_date,
Expand Down
6 changes: 6 additions & 0 deletions doc_engine/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def compile_pdf(
paper: str = DEFAULT_PAPER,
pdf_standard: str | None = None,
code_theme: str | None = None,
title_markup: str | None = None,
) -> None:
source = template_path(template)
if not source.exists():
Expand Down Expand Up @@ -126,6 +127,8 @@ def compile_pdf(
date,
paper,
theme_inject,
# Custom templates retain the existing string-only title contract.
title_markup if Path(template).suffix != ".typ" else None,
),
encoding="utf-8",
)
Expand Down Expand Up @@ -161,7 +164,9 @@ def _build_main(
date: str | None = None,
paper: str = DEFAULT_PAPER,
theme_inject: str = "none",
title_markup: str | None = None,
) -> str:
title_line = f" title_content: [{title_markup}],\n" if title_markup is not None else ""
theme_line = f"#set raw(theme: {theme_inject})\n" if theme_inject != "none" else ""
date_line = f' date: "{_escape(date)}",\n' if date else ""
return (
Expand All @@ -170,6 +175,7 @@ def _build_main(
f"{_FIT_IMAGE}\n"
"#show: setup_doc.with(\n"
f' title: "{_escape(title)}",\n'
f"{title_line}"
f' subtitle: "{_escape(subtitle)}",\n'
f' author: "{_escape(author)}",\n'
f"{date_line}"
Expand Down
48 changes: 38 additions & 10 deletions doc_engine/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,16 @@ def finalize(self, data: str, state: Any) -> str:
return data


def _parse_markdown(markdown: str) -> tuple[list[dict], Any]:
md = mistune.create_markdown(renderer=None, plugins=[*_PLUGINS, _math_plugin])
return md.parse(markdown)


def _render_markdown_tokens(renderer: TypstRenderer, tokens: list[dict], state: Any) -> str:
renderer.load_footnotes(tokens, state)
return _CITATION.sub(r"@\1", renderer.render_tokens(tokens, state))


def convert_document(
markdown: str,
base_dir: Path | None = None,
Expand All @@ -468,11 +478,8 @@ def convert_document(
split_tall=split_tall,
anchors=anchors,
)
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)
tokens, state = _parse_markdown(markdown)
body = _render_markdown_tokens(renderer, tokens, state)
return Conversion(
body=body,
assets=renderer.assets,
Expand All @@ -485,12 +492,33 @@ def convert(markdown: str, base_dir: Path | None = None) -> str:
return convert_document(markdown, base_dir).body


def _title_tokens(markdown: str) -> tuple[list[dict], Any]:
tokens, state = _parse_markdown(markdown)
for token in tokens:
if token["type"] == "heading" and token["attrs"]["level"] == 1:
return token.get("children", []), state
return [], state


def extract_title(markdown: str) -> str:
for line in markdown.split("\n"):
stripped = line.strip()
if stripped.startswith("# ") and not stripped.startswith("##"):
return stripped[2:].strip() or "Documentation"
return "Documentation"
"""Plain text for metadata; formatting belongs in the displayed title."""

def text(tokens: list[dict]) -> str:
return "".join(
text(token["children"]) if "children" in token else token.get("raw", "")
for token in tokens
)

tokens, _ = _title_tokens(markdown)
return text(tokens) or "Documentation"


def extract_title_markup(markdown: str) -> str | None:
"""Render the promoted H1 through the same inline renderer as body headings."""
tokens, state = _title_tokens(markdown)
if not tokens:
return None
return _render_markdown_tokens(TypstRenderer(), tokens, state)


def strip_first_heading(markdown: str) -> str:
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/academic.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -18,6 +19,14 @@
let surface = rgb("#f8fafc")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

set page(
paper: paper,
Expand Down Expand Up @@ -90,7 +99,7 @@

align(left + horizon)[
#v(-10%)
#text(font: "Inter", size: 38pt, weight: 800, tracking: -1.5pt, fill: primary)[#title]
#text(font: "Inter", size: 38pt, weight: 800, tracking: -1.5pt, fill: primary)[#cover-title]
#if subtitle != "" [
#v(0.8em)
#text(font: "Inter", size: 15pt, weight: 400, fill: muted)[#subtitle]
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/article.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -21,6 +22,14 @@
let mono = ("DejaVu Sans Mono", "Cascadia Code", "Courier New")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

set page(
paper: paper,
Expand Down Expand Up @@ -84,7 +93,7 @@
// A LaTeX-style title block at the top of the first page, not a cover sheet.
align(center)[
#v(1em)
#text(size: 17pt, weight: 700)[#title]
#text(size: 17pt, weight: 700)[#cover-title]
#if subtitle != "" [
#v(0.5em)
#text(size: 12pt, style: "italic", fill: muted)[#subtitle]
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/book.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -17,6 +18,14 @@
let surface = rgb("#f5f2ed")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

set page(
paper: paper,
Expand Down Expand Up @@ -86,7 +95,7 @@
align(center + horizon)[
#line(length: 40%, stroke: 0.75pt + accent-color)
#v(1.5em)
#text(size: 32pt, weight: 700, fill: ink, style: "italic")[#title]
#text(size: 32pt, weight: 700, fill: ink, style: "italic")[#cover-title]
#if subtitle != "" [
#v(1em)
#text(size: 14pt, weight: 400, fill: muted, style: "italic")[#subtitle]
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/minimal.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -17,6 +18,14 @@
let surface = rgb("#f6f6f6")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

set page(
paper: paper,
Expand Down Expand Up @@ -74,7 +83,7 @@
]

block[
#text(size: 24pt, weight: 800, fill: ink)[#title]
#text(size: 24pt, weight: 800, fill: ink)[#cover-title]
#if subtitle != "" [
#v(0.3em)
#text(size: 13pt, weight: 400, fill: muted)[#subtitle]
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/modern.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -17,6 +18,14 @@
let surface = rgb("#f1f5f9")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

set page(
paper: paper,
Expand Down Expand Up @@ -80,7 +89,7 @@
#v(-8%)
#line(length: 18%, stroke: 3pt + accent-color)
#v(1.2em)
#text(size: 44pt, weight: 800, tracking: -2pt, fill: ink)[#title]
#text(size: 44pt, weight: 800, tracking: -2pt, fill: ink)[#cover-title]
#if subtitle != "" [
#v(0.8em)
#text(size: 16pt, weight: 400, fill: muted)[#subtitle]
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/report.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -20,6 +21,14 @@
let mono = ("DejaVu Sans Mono", "Cascadia Code", "Courier New")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

// Wide margins and a short measure: roughly 65 characters a line, which is
// the range that stays comfortable to read for pages at a time.
Expand Down Expand Up @@ -98,7 +107,7 @@
#v(-12%)
#line(length: 2.5em, stroke: 3pt + accent-color)
#v(1.5em)
#text(size: 30pt, weight: 600, fill: ink)[#title]
#text(size: 30pt, weight: 600, fill: ink)[#cover-title]
#if subtitle != "" [
#v(0.7em)
#text(size: 15pt, fill: muted)[#subtitle]
Expand Down
11 changes: 10 additions & 1 deletion doc_engine/templates/technical.typ
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#let setup_doc(
title: "",
title_content: none,
subtitle: "",
author: "Anonymous",
date: datetime.today().display(),
Expand All @@ -18,6 +19,14 @@
let line-color = rgb("#e4e4e7")

set document(author: author, title: title)
let cover-title = context {
// Capture the surrounding title size before raw's default scaling applies.
let title-size = text.size
show raw.where(block: false): it => text(
font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas", "Courier New"),
size: title-size, it.text)
if title_content == none { title } else { title_content }
}

set page(
paper: paper,
Expand Down Expand Up @@ -96,7 +105,7 @@
#set text(fill: white)
#text(font: ("Cascadia Code", "DejaVu Sans Mono", "Consolas"), size: 9pt, fill: white.transparentize(20%))[TECHNICAL DOCUMENT]
#v(1.2em)
#text(font: "Inter", size: 34pt, weight: 800, tracking: -1pt)[#title]
#text(font: "Inter", size: 34pt, weight: 800, tracking: -1pt)[#cover-title]
#if subtitle != "" [
#v(0.7em)
#text(font: "Inter", size: 14pt, weight: 400, fill: white.transparentize(15%))[#subtitle]
Expand Down
29 changes: 29 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,32 @@ def test_it_lists_what_the_build_supports(self) -> None:
def test_plain_info_is_still_human_readable(self) -> None:
result = CliRunner().invoke(cli, ["info"])
assert "{" not in result.output.split("Markdown support")[0]


class TestPromotedTitle:
def test_formatted_heading_reaches_compiler(self, tmp_path, monkeypatch) -> None:
import importlib

module = importlib.import_module("doc_engine.cli")
captured = {}
monkeypatch.setattr(module, "compile_pdf", lambda **kwargs: captured.update(kwargs))
doc = tmp_path / "doc.md"
doc.write_text("# **Document** *Title*\n\n## Body\nText.\n")
result = CliRunner().invoke(cli, ["build", str(doc)])
assert result.exit_code == 0, result.output
assert captured["title"] == "Document Title"
assert captured["title_markup"] == "*Document* _Title_"
assert "Document" not in captured["typst_body"]

def test_explicit_title_remains_literal(self, tmp_path, monkeypatch) -> None:
import importlib

module = importlib.import_module("doc_engine.cli")
captured = {}
monkeypatch.setattr(module, "compile_pdf", lambda **kwargs: captured.update(kwargs))
doc = tmp_path / "doc.md"
doc.write_text("# **Document** *Title*\n\nBody.\n")
result = CliRunner().invoke(cli, ["build", str(doc), "--title", "*Literal*"])
assert result.exit_code == 0, result.output
assert captured["title"] == "*Literal*"
assert captured["title_markup"] is None
Loading
Loading