diff --git a/doc_engine/cli.py b/doc_engine/cli.py index 154d871..c497353 100644 --- a/doc_engine/cli.py +++ b/doc_engine/cli.py @@ -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 @@ -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") @@ -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, diff --git a/doc_engine/compiler.py b/doc_engine/compiler.py index 6253f70..e3dfb2b 100644 --- a/doc_engine/compiler.py +++ b/doc_engine/compiler.py @@ -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(): @@ -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", ) @@ -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 ( @@ -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}" diff --git a/doc_engine/converter.py b/doc_engine/converter.py index ba35611..05d73d6 100644 --- a/doc_engine/converter.py +++ b/doc_engine/converter.py @@ -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, @@ -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, @@ -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: diff --git a/doc_engine/templates/academic.typ b/doc_engine/templates/academic.typ index aace344..34bb6a6 100644 --- a/doc_engine/templates/academic.typ +++ b/doc_engine/templates/academic.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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, @@ -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] diff --git a/doc_engine/templates/article.typ b/doc_engine/templates/article.typ index 5186b5e..3bef074 100644 --- a/doc_engine/templates/article.typ +++ b/doc_engine/templates/article.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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, @@ -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] diff --git a/doc_engine/templates/book.typ b/doc_engine/templates/book.typ index de1552d..acff394 100644 --- a/doc_engine/templates/book.typ +++ b/doc_engine/templates/book.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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, @@ -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] diff --git a/doc_engine/templates/minimal.typ b/doc_engine/templates/minimal.typ index 3280874..6f2a48a 100644 --- a/doc_engine/templates/minimal.typ +++ b/doc_engine/templates/minimal.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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, @@ -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] diff --git a/doc_engine/templates/modern.typ b/doc_engine/templates/modern.typ index bd2e83e..fec211a 100644 --- a/doc_engine/templates/modern.typ +++ b/doc_engine/templates/modern.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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, @@ -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] diff --git a/doc_engine/templates/report.typ b/doc_engine/templates/report.typ index e72fa03..81fcb90 100644 --- a/doc_engine/templates/report.typ +++ b/doc_engine/templates/report.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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. @@ -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] diff --git a/doc_engine/templates/technical.typ b/doc_engine/templates/technical.typ index 7863725..57baf51 100644 --- a/doc_engine/templates/technical.typ +++ b/doc_engine/templates/technical.typ @@ -1,5 +1,6 @@ #let setup_doc( title: "", + title_content: none, subtitle: "", author: "Anonymous", date: datetime.today().display(), @@ -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, @@ -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] diff --git a/tests/test_cli.py b/tests/test_cli.py index a20cf01..d2af8de 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_compiler.py b/tests/test_compiler.py new file mode 100644 index 0000000..e0bb1e0 --- /dev/null +++ b/tests/test_compiler.py @@ -0,0 +1,21 @@ +from doc_engine.compiler import compile_pdf + + +def test_formatted_title_keeps_custom_template_contract(tmp_path): + template = tmp_path / "custom.typ" + template.write_text( + '#let setup_doc(title: "", subtitle: "", author: "", ' + 'bibliography_file: none, accent: none, branding: true, version: "", ' + 'paper: "a4", body) = { title; body }', + encoding="utf-8", + ) + output = tmp_path / "custom.pdf" + compile_pdf( + "Body", + "Bold Title", + "Author", + str(output), + template=str(template), + title_markup="*Bold* Title", + ) + assert output.read_bytes().startswith(b"%PDF-") diff --git a/tests/test_converter.py b/tests/test_converter.py index 648d92e..9df3633 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -3,6 +3,7 @@ convert, convert_document, extract_title, + extract_title_markup, strip_first_heading, ) @@ -221,3 +222,35 @@ def test_body_keeps_its_formatting(self) -> None: result = convert("> [!TIP]\n> Use **bold** and `code`.\n") assert "*bold*" in result assert "`code`" in result + + +class TestFormattedTitle: + def test_title_uses_body_plugins_and_citations(self) -> None: + for inline in ("~~struck~~ title", "The $x^2$ theorem", "Result [@smith2020]"): + assert extract_title_markup(f"# {inline}") == convert(f"# {inline}").strip()[2:] + + def test_plugin_metadata_is_plain(self) -> None: + assert extract_title("# ~~Old~~ $x^2$ result") == "Old x^2 result" + + def test_reference_links_use_document_definitions(self) -> None: + result = extract_title_markup("# [Guide][ref]\n\n[ref]: https://example.com") + assert result == '#link("https://example.com")[Guide]' + + def test_metadata_uses_plain_text(self) -> None: + assert extract_title("# **Document** *Title*") == "Document Title" + + def test_display_preserves_emphasis(self) -> None: + assert extract_title_markup("# **Document** *Title*") == "*Document* _Title_" + + def test_literal_markers_survive(self) -> None: + assert extract_title(r"# \*literal\* and `code_name`") == "*literal* and code_name" + assert ( + extract_title_markup(r"# \*literal\* and `code_name`") == r"\*literal\* and `code_name`" + ) + + def test_nested_formatting(self) -> None: + assert extract_title("# **Bold and *italic***") == "Bold and italic" + assert extract_title_markup("# **Bold and *italic***") == "*Bold and _italic_*" + + def test_missing_heading_has_no_markup(self) -> None: + assert extract_title_markup("## Body heading") is None