diff --git a/.gitignore b/.gitignore index 78efe6a..4e46006 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ Cargo.lock.bak # IDE .idea/ .vscode/ -.claude/ *.iml # LibreOffice lock files (created when opening a doc for testing) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a1d5d..46f5020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] — 2026-06-14 + +A math-rendering and robustness release. The native math layout engine gained +font-aware metrics, real bold, and properly-centred accents; custom OTF/CFF +fonts now embed correctly in PDF; and a broad rendering sweep fixed several +overflow / silent-drop bugs. + +### Added + +- **Font-aware math metrics.** The math layout engine now measures glyph + advances against the face that will actually render them (the embedded PDF + font, or bundled DejaVu for SVG/PNG) via a `GlyphMetrics` provider, so + reserved width matches drawn glyphs — equations stay aligned under + `--pdf-font`, not just with the default font. +- **Math bold.** `\mathbf`, `\textbf`, `\boldsymbol`, `\bm`, `\pmb`, `\mathbfit` + now render in real bold weight (PDF/SVG/PNG); previously a no-op, and + `\boldsymbol` leaked literal source. +- **`\dfrac` / `\tfrac` / `\cfrac`** and **`\mid` / `\vert` / `\Vert`** are now + recognised (previously leaked literal source). +- **GFM table column alignment** (`:---`, `:---:`, `---:`) is honoured in PDF, + SVG, PNG, and HTML. +- **Inline ``** blocks are now rasterised and embedded instead of + being silently dropped. + +### Fixed + +- **OTF/CFF font embedding.** PDF now emits `CIDFontType0` + `FontFile3` for + CFF/OpenType faces (e.g. STIX Two Math) instead of mislabelling them as + TrueType, which strict readers rejected. +- **Math accents** (`\bar`, `\hat`, `\vec`, `\tilde`, `\dot`, `\ddot`) are drawn + as centred geometry rather than zero-advance combining marks, which had + landed at the glyph's right edge (and differently per font — e.g. `\bar{d}` + rendered as "đ"). +- **Long titles no longer overflow.** Title slides (all layouts) and section + dividers auto-shrink to fit; wrapped content-slide headings push the body and + underline down instead of overprinting them. +- **Nested blockquotes** (`>`/`>>`/`>>>`) keep every level instead of rendering + only the deepest. +- **Long unbreakable tokens** (URLs, hashes, CamelCase) hard-break to wrap + instead of overflowing the slide edge (PDF/SVG); HTML uses `overflow-wrap`. +- **Tabs in code** expand to 4-column tab stops instead of rendering as + missing-glyph boxes. +- **`{width=N%}` > 100** clamps instead of leaking the literal attribute text. +- **`:::` columns** handle leading/trailing/duplicate dividers (the fence form + `::: … ::: … :::`) instead of dumping all content into the right column. +- **Task-list items** show only their checkbox, not a checkbox *and* a bullet + (PDF/SVG/HTML/PPTX). +- **Display-math pagination** weights generated equation images by their real + aspect ratio, so a one-line equation no longer splits onto a near-empty + continuation slide. + +### Known limitations + +- Right-to-left text uses `direction: rtl` for right-alignment but does not + perform full Unicode bidi reordering of mixed LTR/RTL runs. +- CJK and colour-emoji glyphs require `--cjk ` for PDF (the bundled DejaVu + face does not cover them). +- Table column alignment is not yet applied to the editable Office/ODF outputs + (pptx/odp/odt/docx). + ## [0.2.0] — 2026-05-24 A feature-and-fix-heavy follow-up to the initial release. New CLI surface diff --git a/Cargo.lock b/Cargo.lock index d4ef459..25f41f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,7 +523,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "md2any" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index c597953..d687a42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "md2any" -version = "0.2.0" +version = "0.3.0" edition = "2021" rust-version = "1.74" description = "Markdown → PowerPoint, OpenDocument Impress, PDF, Word, Writer, HTML, SVG, and PNG. One markdown source, one small Rust binary." @@ -22,6 +22,11 @@ exclude = [ "/examples/*.odp", "/examples/*.docx", "/examples/*.odt", + # The manual in binary office formats (~1.2 MB) is redundant with HELP.md + # for a source crate; keep it in the repo, out of the published package. + "/docs/manual", + # Large pre-rendered sample images not referenced by any shipped example. + "/examples/assets/*.png", ] [[bin]] diff --git a/README.md b/README.md index 2dbb5d1..d74c9b6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ binary that converts markdown into editable PowerPoint, OpenDocument Impress, PDF, Word, LibreOffice Writer, standalone HTML, and per-slide SVG/PNG files. +

+ Content, code, and table slides rendered by md2any +

+ ```bash md2any talk.md # → talk.pptx (PowerPoint) md2any talk.md -o talk.odp # → talk.odp (LibreOffice Impress) @@ -44,6 +48,24 @@ md2any is the only tool in this list that produces every common Office / OpenDocument format **natively** from a single small binary — no headless browser, no LaTeX stack, no language runtime. +## Native math — no LaTeX, no KaTeX, no browser + +A built-in layout engine renders display math (fractions, radicals, scripts, +matrices, cases, accents, bold) as **selectable text and vector geometry** — +not images, and without a TeX or JavaScript stack. Bring any math font with +`--pdf-font` (here, STIX Two Math); metrics adapt to the face automatically. + +

+ Euler's identity, the Lorentz factor, the Dirac equation, and Bayes' theorem +

+ +It scales all the way up to the full **Standard Model Lagrangian** on a single +A4 page ([source](examples/standard-model-lagrangian-a4.md)): + +

+ The Standard Model Lagrangian rendered on one A4 page +

+ ## Install ### Prebuilt binaries @@ -138,13 +160,13 @@ md2any hello.md --serve # live preview at http://localhost:8421 | **Themes** | Light + dark built-in, full custom palettes via YAML overlay | | **Layouts** | Clean / studio / frame / bold | | **Aspect ratios** | 16:9, 4:3, 9:16, A3/A4/A5, Letter, Legal, Tabloid + landscape variants + custom `WxH[unit]` | -| **Markdown** | Lists (9 deep nesting), tables, dark-by-default code blocks (40+ tags incl. mainframe/IBM i, Haskell/BCPL, web/API/config, and `bf`), block quotes, footnotes, links (clickable), strike, inline code | +| **Markdown** | Lists (9 deep nesting), task lists, tables with column alignment (`:---:` / `---:`), dark-by-default code blocks (40+ tags incl. mainframe/IBM i, Haskell/BCPL, web/API/config, and `bf`), block quotes, footnotes, links (clickable), strike, inline code, inline `` | | **Layout extras** | Side-by-side columns (`:::`), landscape code two-up (`--code-columns` / `columns=2`), image/text full-page hints, per-slide background (``), TOC injection, hand-tuned pagination with widow/orphan control | -| **Math** | `$inline$` and `$$display$$` LaTeX subset -> Unicode, including accents/text/math alphabets; `--math source` preserves source; `--math svg` uses a built-in box layout for display fractions, radicals, scripts, matrices/cases/arrays, scalable delimiters, and aligned equations | +| **Math** | `$inline$` and `$$display$$` LaTeX subset -> Unicode, including accents/text/math alphabets; `--math source` preserves source; `--math svg` uses a built-in box layout for display fractions, radicals, scripts, matrices/cases/arrays, scalable delimiters, aligned equations, **bold** (`\mathbf`/`\boldsymbol`), and drawn accents (`\bar`/`\hat`/`\vec`). Font-aware metrics keep equations aligned under `--pdf-font` (incl. OTF/CFF math fonts like STIX) | | **Diagrams** | Embedded `dot`, `mermaid`, `plantuml` (shells out if installed) | | **Speaker notes** | `` HTML comments | | **Transitions** | `fade`, `push`, `wipe`, `cover`, `split` (PPTX/ODP/PDF) | -| **RTL / CJK** | `direction: rtl` for Arabic/Hebrew; CJK via the consumer's font | +| **RTL / CJK** | `direction: rtl` right-aligns Arabic/Hebrew (no full bidi reordering of mixed runs yet); CJK & emoji need `--cjk ` for PDF | | **Workflow** | `--watch` for rebuild-on-save, `--serve` for live preview with hot reload, `--check` for lint mode | | **Handouts** | `--handout 2|4|6` for N-up A4 printable PDF | | **Multi-file** | Concat multiple `.md` files; `-` reads stdin | @@ -371,6 +393,10 @@ std::fs::write("talk.pdf", bytes)?; `--font-fallback` - DejaVu does not include CJK glyphs; for PDF Chinese/Japanese/Korean decks, pass `--cjk PATH` or include a CJK face in `--font-fallback` +- `direction: rtl` right-aligns Arabic/Hebrew but does not yet perform full + Unicode bidirectional reordering of mixed LTR/RTL runs +- GFM table column alignment (`:---:`, `---:`) is applied in PDF / SVG / PNG / + HTML; the editable Office/ODF outputs (pptx/odp/odt/docx) don't carry it yet - Speaker notes attach natively in PPTX / ODP, can be emitted as a PDF companion with `--with-notes`, can be appended to DOCX/ODT via `--doc-style speaker-notes`, and can be bundled with deck + handout via diff --git a/docs/img/equations.png b/docs/img/equations.png new file mode 100644 index 0000000..40b29f8 Binary files /dev/null and b/docs/img/equations.png differ diff --git a/docs/img/lagrangian.png b/docs/img/lagrangian.png new file mode 100644 index 0000000..e30724f Binary files /dev/null and b/docs/img/lagrangian.png differ diff --git a/docs/img/slides.png b/docs/img/slides.png new file mode 100644 index 0000000..e3ee4bd Binary files /dev/null and b/docs/img/slides.png differ diff --git a/docs/torture-test-findings.md b/docs/torture-test-findings.md new file mode 100644 index 0000000..9cbb395 --- /dev/null +++ b/docs/torture-test-findings.md @@ -0,0 +1,97 @@ +# md2any torture-test findings + +Broad stress test of the renderer (2026-06-14). Six domains — tables/lists/columns, +code/syntax, typography/unicode, images/SVG, themes/layouts/aspects, and multi-format +robustness — each rendered to PDF/PNG (and cross-checked in HTML/Office formats) and +visually inspected. + +**Multi-format output is solid:** 72 artifacts across all 8 formats (pptx, odp, pdf, +docx, odt, html, svg, png) — no crashes, no corrupt archives, no dropped content. +All defects below are in the slide layout/render layer. + +Status legend: ☐ open · ☑ fixed · ◐ in progress · ⊘ won't-fix / larger effort + +--- + +## HIGH + +- ☑ **H1 — Long titles overflow & overprint the footer.** Fixed across **all** + title layouts (Clean/Studio/Frame/Bold): `fit_hero_size` shrinks the hero title + to fit above the subtitle/footer; Bold no longer drops the title off the top of + its accent block (`src/pdf.rs`). Subtitle/author stay visible. + +- ☑ **H2 — Wrapped content headings collide with body.** Fixed: `render_content_slide` + measures the wrapped title line count and pushes the underline + body down by the + extra lines (`src/pdf.rs`). Was: fixed-height title band. + +- ☑ **H3 — Section-divider long titles clipped off the bottom.** Fixed: section + divider (Clean/Frame) now auto-shrinks via `fit_hero_size` (`src/pdf.rs`). + +- ☑ **H4 — Inline `` silently dropped.** Fixed: the parser accumulates + inline SVG across HTML events and routes it through the image pipeline as a + base64 `data:image/svg+xml` block, rasterised like external SVG (`src/parser.rs`). + +- ☑ **H5 — Table column alignment ignored** (`:---:`, `---:`). Fixed: added + `ir::ColumnAlign` + `Table.aligns`, captured from the GFM delimiter row; honoured + in **PDF / SVG / PNG / HTML**. (Office formats — pptx/odp/odt/docx — thread the + data but don't yet apply native cell alignment; see L7.) + +- ☑ **H6 — Tabs in code → tofu boxes + broken indentation.** Fixed: code lines + expand tabs to 4-column tab stops at parse time, fixing every backend + (`src/parser.rs`). + +## Known-limitation class (larger efforts) + +- ⊘ **H7 — RTL (Arabic/Hebrew) not reordered.** No bidi; Hebrew reversed, Arabic + left-aligned. Needs a bidi pass — larger effort. + +- ⊘ **H8 — CJK + emoji → tofu in PDF.** Only DejaVu embedded; no CJK/emoji fallback + font. HTML preserves them. Needs a fallback font pipeline — larger effort. + +## MEDIUM + +- ☑ **M1 — Nested blockquotes collapse.** Fixed: the quote accumulator only + resets on the outermost `>`, so nested levels keep the outer paragraphs + (`src/parser.rs`). + +- ☑ **M2 — Long unbreakable tokens overflow the right edge.** Fixed: PDF/SVG + wrappers hard-break a token wider than the line into character chunks; HTML + uses `overflow-wrap: anywhere` (`src/pdf.rs`, `src/svg.rs`, `src/html.rs`). + +- ☑ **M3 — `{width=N%}` > 100 leaks literal `{width=…}` as body text** (+ spurious + slide). Fixed: `parse_width_pct` now clamps to 1..=100 instead of returning `None` + (`src/parser.rs`). + +- ☑ **M4 — `:::` columns: a leading/extra marker dumps everything into the right + column** (empty left). Fixed: `coalesce_columns` now splits on all dividers and + drops empty segments, so the fence form `::: … ::: … :::` works (`src/paginate.rs`). + +- ☑ **M5 — Task-list items render a bullet AND a checkbox** (`● ☐ task`). Fixed: + added `ListItem::is_task()`; bullet suppressed in pdf/svg/html/pptx render_list. + ODT/DOCX use native list styles and still show both — needs a no-bullet list + style declaration (follow-up, see L7). + +- ☐ **M6 — Pagination over-splits in 16:9** — image+caption and tables split with + slides ~60% empty (a4 fits them). Same family as the math-image weight fix already + landed; the height budget is too conservative for several block types in 16:9. + +## LOW / polish + +- ☐ L1 — Code: leading/trailing blank lines inside a fence kept verbatim. +- ☐ L2 — Rust `'static` lifetime mis-highlighted as a char/string literal. +- ☐ L3 — ` ` not decoded (renders literal `nbsp`). +- ☐ L4 — `₿` (U+20BF) tofu; other currency symbols fine. +- ☐ L5 — Misleading "aspect too small" error for ratios like `16:10`/`1:1` (only + `16:9`/`4:3`/`9:16` presets accepted). +- ☐ L6 — TOC lists only H1 sections. + +--- + +## Fix order + +Cheap/safe first, then the big UX wins: +M3 (width clamp) → M5 (task-list bullet) → M4 (`:::` leading marker) → +H1/H2/H3 (title & heading auto-fit + body push-down) → H4 (inline SVG) → +H5 (table alignment, needs IR field) → M6 (16:9 pagination budget) → +M1 (nested blockquotes) → M2 (token overflow) → low/polish. +H7/H8 (RTL, CJK/emoji) are larger and tracked separately. diff --git a/examples/famous-equations.md b/examples/famous-equations.md new file mode 100644 index 0000000..8f5a26d --- /dev/null +++ b/examples/famous-equations.md @@ -0,0 +1,78 @@ +--- +title: A Wall of Famous Equations +subtitle: The greatest hits, set in STIX Two Math +author: md2any +date: auto +theme: light +aspect: 16:9 +layout: clean +math: svg +math_block_align: center +math_scale: 1.2 +math_max_height: 230 +toc: false +--- + +# A Wall of Famous Equations + +A greatest-hits tour — from a patent clerk's β to the geometry of spacetime. + +## Euler's Identity + +The most beautiful equation in mathematics — five constants, one line. + +$$ e^{i\pi} + 1 = 0 $$ + +## Mass–Energy Equivalence + +Einstein's 1905 sequel to *On the Electrodynamics of Moving Bodies*. + +$$ E = mc^2 $$ + +## The Lorentz Factor + +Straight out of §6 of that 1905 paper — the β that warps space and time. + +$$ \gamma = \frac{1}{\sqrt{1 - \dfrac{v^2}{c^2}}} $$ + +## The Schrödinger Equation + +How a quantum state evolves in time. + +$$ i\hbar\frac{\partial}{\partial t}\Psi = \hat{H}\Psi $$ + +## The Dirac Equation + +Relativity meets the quantum — and predicts antimatter. + +$$ \left(i\hbar\gamma^\mu\partial_\mu - mc\right)\psi = 0 $$ + +## Einstein's Field Equations + +Matter tells spacetime how to curve; spacetime tells matter how to move. + +$$ G_{\mu\nu} + \Lambda g_{\mu\nu} = \frac{8\pi G}{c^4}\,T_{\mu\nu} $$ + +## The Gaussian Integral + +The bell curve's deceptively simple area. + +$$ \int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} $$ + +## Bayes' Theorem + +How evidence updates belief. + +$$ P(A \mid B) = \frac{P(B \mid A)\,P(A)}{P(B)} $$ + +## Boltzmann's Entropy + +Carved on his gravestone in Vienna. + +$$ S = k_B \ln W $$ + +## The Basel Problem + +Euler summed the reciprocal squares — and π showed up. + +$$ \sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} $$ diff --git a/examples/standard-model-lagrangian-a4.md b/examples/standard-model-lagrangian-a4.md index 9af8cd9..608ccb2 100644 --- a/examples/standard-model-lagrangian-a4.md +++ b/examples/standard-model-lagrangian-a4.md @@ -2,8 +2,93 @@ theme: light layout: clean aspect: a4 +break_mode: off --- - + -![Full Standard Model Lagrangian](assets/standard-model-lagrangian-a4.png){width=100%} +```math +\mathcal{L}_{SM} = -\frac{1}{2}\partial_\nu g^a_\mu\partial^\nu g^{a\mu} +-g_s f^{abc}\partial_\mu g^a_\nu g^{b\mu}g^{c\nu} +-\frac{1}{4}g_s^2 f^{abc}f^{ade}g^b_\mu g^c_\nu g^{d\mu}g^{e\nu} +-\partial_\nu W^+_\mu\partial^\nu W^{-\mu}-M^2W^+_\mu W^{-\mu} +-\frac{1}{2}\partial_\nu Z^0_\mu\partial^\nu Z^{0\mu}-\frac{1}{2c_w^2}M^2Z^0_\mu Z^{0\mu} +-\frac{1}{2}\partial_\mu A_\nu\partial^\mu A^\nu +-igc_w(\partial_\nu Z^0_\mu(W^{+\mu}W^{-\nu}-W^{+\nu}W^{-\mu}) +-Z^0_\nu(W^+_\mu\partial^\nu W^{-\mu}-W^-_\mu\partial^\nu W^{+\mu}) ++Z^0_\mu(W^+_\nu\partial^\nu W^{-\mu}-W^-_\nu\partial^\nu W^{+\mu})) +-igs_w(\partial_\nu A_\mu(W^{+\mu}W^{-\nu}-W^{+\nu}W^{-\mu}) +-A_\nu(W^+_\mu\partial^\nu W^{-\mu}-W^-_\mu\partial^\nu W^{+\mu}) ++A_\mu(W^+_\nu\partial^\nu W^{-\mu}-W^-_\nu\partial^\nu W^{+\mu})) +-\frac{1}{2}g^2W^+_\mu W^{-\mu}W^+_\nu W^{-\nu} ++\frac{1}{2}g^2W^+_\mu W^{+\mu}W^-_\nu W^{-\nu} ++g^2c_w^2(Z^0_\mu W^+_\nu Z^{0\mu}W^{-\nu}-Z^0_\mu Z^{0\mu}W^+_\nu W^{-\nu}) ++g^2s_w^2(A_\mu W^+_\nu A^\mu W^{-\nu}-A_\mu A^\mu W^+_\nu W^{-\nu}) ++g^2s_wc_w(A_\mu Z^0_\nu(W^{+\mu}W^{-\nu}+W^{-\mu}W^{+\nu}) +-2A_\mu Z^{0\mu}W^+_\nu W^{-\nu})-\frac{1}{2}\partial_\mu H\partial^\mu H-2M^2\alpha_hH^2 +-\partial_\mu\phi^+\partial^\mu\phi^--\frac{1}{2}\partial_\mu\phi^0\partial^\mu\phi^0 +-\beta_h(\frac{2M^2}{g^2}+\frac{2M}{g}H+\frac{1}{2}(H^2+\phi^0\phi^0+2\phi^+\phi^-)) ++\frac{2M^4}{g^2}\alpha_h-g\alpha_hM(H^3+H\phi^0\phi^0+2H\phi^+\phi^-) +-\frac{1}{8}g^2\alpha_h(H^4+(\phi^0)^4+4(\phi^+\phi^-)^2+4(\phi^0)^2\phi^+\phi^- ++4H^2\phi^+\phi^-+2(\phi^0)^2H^2) +-gMW^+_\mu W^{-\mu}H-\frac{1}{2}g\frac{M}{c_w^2}Z^0_\mu Z^{0\mu}H +-\frac{i}{2}g(W^+_\mu(\phi^0\partial^\mu\phi^- - \phi^-\partial^\mu\phi^0) +-W^-_\mu(\phi^0\partial^\mu\phi^+ - \phi^+\partial^\mu\phi^0)) ++\frac{1}{2}g(W^+_\mu(H\partial^\mu\phi^- - \phi^-\partial^\mu H) ++W^-_\mu(H\partial^\mu\phi^+ - \phi^+\partial^\mu H)) ++\frac{1}{2}g\frac{1}{c_w}Z^0_\mu(H\partial^\mu\phi^0-\phi^0\partial^\mu H) ++M(\frac{1}{c_w}Z^0_\mu\partial^\mu\phi^0+W^+_\mu\partial^\mu\phi^-+W^-_\mu\partial^\mu\phi^+) +-ig\frac{s_w^2}{c_w}MZ^0_\mu(W^{+\mu}\phi^- - W^{-\mu}\phi^+) ++igs_wMA_\mu(W^{+\mu}\phi^- - W^{-\mu}\phi^+) +-ig\frac{1-2c_w^2}{2c_w}Z^0_\mu(\phi^+\partial^\mu\phi^- - \phi^-\partial^\mu\phi^+) ++igs_wA_\mu(\phi^+\partial^\mu\phi^- - \phi^-\partial^\mu\phi^+) +-\frac{1}{4}g^2W^+_\mu W^{-\mu}(H^2+(\phi^0)^2+2\phi^+\phi^-) +-\frac{1}{8}g^2\frac{1}{c_w^2}Z^0_\mu Z^{0\mu}(H^2+(\phi^0)^2+2(2s_w^2-1)^2\phi^+\phi^-) +-\frac{1}{2}g^2\frac{s_w}{c_w}Z^0_\mu\phi^0(W^{+\mu}\phi^-+W^{-\mu}\phi^+) +-\frac{i}{2}g^2\frac{s_w}{c_w}Z^0_\mu H(W^{+\mu}\phi^- - W^{-\mu}\phi^+) ++\frac{1}{2}g^2s_wA_\mu\phi^0(W^{+\mu}\phi^-+W^{-\mu}\phi^+) ++\frac{i}{2}g^2s_wA_\mu H(W^{+\mu}\phi^- - W^{-\mu}\phi^+) +-g^2\frac{s_w}{c_w}(2c_w^2-1)Z^0_\mu A^\mu\phi^+\phi^- - g^2s_w^2A_\mu A^\mu\phi^+\phi^- ++\frac{i}{2}g_s\lambda^a_{ij}(\bar{q}_i\gamma^\mu q_j)g^a_\mu +-\bar{e}^\lambda(\gamma\partial+m_e^\lambda)e^\lambda-\bar{\nu}^\lambda(\gamma\partial+m_\nu^\lambda)\nu^\lambda +-\bar{u}^\lambda_j(\gamma\partial+m_u^\lambda)u^\lambda_j-\bar{d}^\lambda_j(\gamma\partial+m_d^\lambda)d^\lambda_j ++igs_wA_\mu(-(\bar{e}^\lambda\gamma^\mu e^\lambda) ++\frac{2}{3}(\bar{u}^\lambda_j\gamma^\mu u^\lambda_j)-\frac{1}{3}(\bar{d}^\lambda_j\gamma^\mu d^\lambda_j)) ++\frac{ig}{4c_w}Z^0_\mu(\bar{\nu}^\lambda\gamma^\mu(1+\gamma^5)\nu^\lambda ++\bar{e}^\lambda\gamma^\mu(4s_w^2-1-\gamma^5)e^\lambda ++\bar{d}^\lambda_j\gamma^\mu(\frac{4}{3}s_w^2-1-\gamma^5)d^\lambda_j ++\bar{u}^\lambda_j\gamma^\mu(1-\frac{8}{3}s_w^2+\gamma^5)u^\lambda_j) ++\frac{ig}{2\sqrt{2}}W^+_\mu((\bar{\nu}^\lambda\gamma^\mu(1+\gamma^5)U^{lep}_{\lambda\kappa}e^\kappa) ++(\bar{u}^\lambda_j\gamma^\mu(1+\gamma^5)C_{\lambda\kappa}d^\kappa_j)) ++\frac{ig}{2\sqrt{2}}W^-_\mu((\bar{e}^\kappa U^{lep\dagger}_{\kappa\lambda}\gamma^\mu(1+\gamma^5)\nu^\lambda) ++(\bar{d}^\kappa_jC^\dagger_{\kappa\lambda}\gamma^\mu(1+\gamma^5)u^\lambda_j)) ++\frac{ig}{2M\sqrt{2}}\phi^+(-m_e^\kappa\bar{\nu}^\lambda U^{lep}_{\lambda\kappa}(1-\gamma^5)e^\kappa ++m_\nu^\lambda\bar{\nu}^\lambda U^{lep}_{\lambda\kappa}(1+\gamma^5)e^\kappa) ++\frac{ig}{2M\sqrt{2}}\phi^-(m_e^\lambda\bar{e}^\lambda U^{lep\dagger}_{\lambda\kappa}(1+\gamma^5)\nu^\kappa +-m_\nu^\kappa\bar{e}^\lambda U^{lep\dagger}_{\lambda\kappa}(1-\gamma^5)\nu^\kappa) +-\frac{g m_\nu^\lambda}{2M}H(\bar{\nu}^\lambda\nu^\lambda)-\frac{g m_e^\lambda}{2M}H(\bar{e}^\lambda e^\lambda) ++\frac{ig m_\nu^\lambda}{2M}\phi^0(\bar{\nu}^\lambda\gamma^5\nu^\lambda) +-\frac{ig m_e^\lambda}{2M}\phi^0(\bar{e}^\lambda\gamma^5e^\lambda) +-\frac{1}{4}\bar{\nu}_\lambda M^R_{\lambda\kappa}(1-\gamma^5)\hat{\nu}_\kappa +-\frac{1}{4}\bar{\nu}_\lambda M^R_{\lambda\kappa}(1-\gamma^5)\hat{\nu}_\kappa ++\frac{ig}{2M\sqrt{2}}\phi^+(-m_d^\kappa\bar{u}^\lambda_jC_{\lambda\kappa}(1-\gamma^5)d^\kappa_j ++m_u^\lambda\bar{u}^\lambda_jC_{\lambda\kappa}(1+\gamma^5)d^\kappa_j) ++\frac{ig}{2M\sqrt{2}}\phi^-(m_d^\lambda\bar{d}^\lambda_jC^\dagger_{\lambda\kappa}(1+\gamma^5)u^\kappa_j +-m_u^\kappa\bar{d}^\lambda_jC^\dagger_{\lambda\kappa}(1-\gamma^5)u^\kappa_j) +-\frac{g m_u^\lambda}{2M}H(\bar{u}^\lambda_j u^\lambda_j)-\frac{g m_d^\lambda}{2M}H(\bar{d}^\lambda_jd^\lambda_j) ++\frac{ig m_u^\lambda}{2M}\phi^0(\bar{u}^\lambda_j\gamma^5u^\lambda_j) +-\frac{ig m_d^\lambda}{2M}\phi^0(\bar{d}^\lambda_j\gamma^5d^\lambda_j) ++\bar{G}^a\partial^2G^a+g_sf^{abc}\partial_\mu\bar{G}^aG^bg^c_\mu ++X^+(\partial^2-M^2)X^+ + X^-(\partial^2-M^2)X^- + X^0(\partial^2-\frac{M^2}{c_w^2})X^0+\bar{Y}\partial^2Y ++igc_wW^+_\mu(\partial^\mu X^0X^- - \partial^\mu X^+X^0) ++igs_wW^+_\mu(\partial^\mu YX^- - \partial^\mu X^+Y) ++igc_wW^-_\mu(\partial^\mu X^-X^0 - \partial^\mu X^0X^+) ++igs_wW^-_\mu(\partial^\mu X^-Y - \partial^\mu YX^+) ++igc_wZ^0_\mu(\partial^\mu X^+X^+ - \partial^\mu X^-X^-) ++igs_wA_\mu(\partial^\mu X^+X^+ - \partial^\mu X^-X^-) +-\frac{1}{2}gM(\bar{X}^+X^+H+\bar{X}^-X^-H+\frac{1}{c_w^2}\bar{X}^0X^0H) ++\frac{1-2c_w^2}{2c_w}igM(\bar{X}^+X^0\phi^- - \bar{X}^-X^0\phi^+) ++\frac{1}{2c_w}igM(\bar{X}^0X^-\phi^+ - \bar{X}^0X^+\phi^-) ++igMs_w(\bar{X}^0X^-\phi^+ - \bar{X}^0X^+\phi^-) ++\frac{i}{2}gM(\bar{X}^+X^+\phi^0 - \bar{X}^-X^-\phi^0). +``` diff --git a/examples/standard-model-lagrangian-markup-a4.md b/examples/standard-model-lagrangian-markup-a4.md deleted file mode 100644 index 608ccb2..0000000 --- a/examples/standard-model-lagrangian-markup-a4.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -theme: light -layout: clean -aspect: a4 -break_mode: off ---- - - - -```math -\mathcal{L}_{SM} = -\frac{1}{2}\partial_\nu g^a_\mu\partial^\nu g^{a\mu} --g_s f^{abc}\partial_\mu g^a_\nu g^{b\mu}g^{c\nu} --\frac{1}{4}g_s^2 f^{abc}f^{ade}g^b_\mu g^c_\nu g^{d\mu}g^{e\nu} --\partial_\nu W^+_\mu\partial^\nu W^{-\mu}-M^2W^+_\mu W^{-\mu} --\frac{1}{2}\partial_\nu Z^0_\mu\partial^\nu Z^{0\mu}-\frac{1}{2c_w^2}M^2Z^0_\mu Z^{0\mu} --\frac{1}{2}\partial_\mu A_\nu\partial^\mu A^\nu --igc_w(\partial_\nu Z^0_\mu(W^{+\mu}W^{-\nu}-W^{+\nu}W^{-\mu}) --Z^0_\nu(W^+_\mu\partial^\nu W^{-\mu}-W^-_\mu\partial^\nu W^{+\mu}) -+Z^0_\mu(W^+_\nu\partial^\nu W^{-\mu}-W^-_\nu\partial^\nu W^{+\mu})) --igs_w(\partial_\nu A_\mu(W^{+\mu}W^{-\nu}-W^{+\nu}W^{-\mu}) --A_\nu(W^+_\mu\partial^\nu W^{-\mu}-W^-_\mu\partial^\nu W^{+\mu}) -+A_\mu(W^+_\nu\partial^\nu W^{-\mu}-W^-_\nu\partial^\nu W^{+\mu})) --\frac{1}{2}g^2W^+_\mu W^{-\mu}W^+_\nu W^{-\nu} -+\frac{1}{2}g^2W^+_\mu W^{+\mu}W^-_\nu W^{-\nu} -+g^2c_w^2(Z^0_\mu W^+_\nu Z^{0\mu}W^{-\nu}-Z^0_\mu Z^{0\mu}W^+_\nu W^{-\nu}) -+g^2s_w^2(A_\mu W^+_\nu A^\mu W^{-\nu}-A_\mu A^\mu W^+_\nu W^{-\nu}) -+g^2s_wc_w(A_\mu Z^0_\nu(W^{+\mu}W^{-\nu}+W^{-\mu}W^{+\nu}) --2A_\mu Z^{0\mu}W^+_\nu W^{-\nu})-\frac{1}{2}\partial_\mu H\partial^\mu H-2M^2\alpha_hH^2 --\partial_\mu\phi^+\partial^\mu\phi^--\frac{1}{2}\partial_\mu\phi^0\partial^\mu\phi^0 --\beta_h(\frac{2M^2}{g^2}+\frac{2M}{g}H+\frac{1}{2}(H^2+\phi^0\phi^0+2\phi^+\phi^-)) -+\frac{2M^4}{g^2}\alpha_h-g\alpha_hM(H^3+H\phi^0\phi^0+2H\phi^+\phi^-) --\frac{1}{8}g^2\alpha_h(H^4+(\phi^0)^4+4(\phi^+\phi^-)^2+4(\phi^0)^2\phi^+\phi^- -+4H^2\phi^+\phi^-+2(\phi^0)^2H^2) --gMW^+_\mu W^{-\mu}H-\frac{1}{2}g\frac{M}{c_w^2}Z^0_\mu Z^{0\mu}H --\frac{i}{2}g(W^+_\mu(\phi^0\partial^\mu\phi^- - \phi^-\partial^\mu\phi^0) --W^-_\mu(\phi^0\partial^\mu\phi^+ - \phi^+\partial^\mu\phi^0)) -+\frac{1}{2}g(W^+_\mu(H\partial^\mu\phi^- - \phi^-\partial^\mu H) -+W^-_\mu(H\partial^\mu\phi^+ - \phi^+\partial^\mu H)) -+\frac{1}{2}g\frac{1}{c_w}Z^0_\mu(H\partial^\mu\phi^0-\phi^0\partial^\mu H) -+M(\frac{1}{c_w}Z^0_\mu\partial^\mu\phi^0+W^+_\mu\partial^\mu\phi^-+W^-_\mu\partial^\mu\phi^+) --ig\frac{s_w^2}{c_w}MZ^0_\mu(W^{+\mu}\phi^- - W^{-\mu}\phi^+) -+igs_wMA_\mu(W^{+\mu}\phi^- - W^{-\mu}\phi^+) --ig\frac{1-2c_w^2}{2c_w}Z^0_\mu(\phi^+\partial^\mu\phi^- - \phi^-\partial^\mu\phi^+) -+igs_wA_\mu(\phi^+\partial^\mu\phi^- - \phi^-\partial^\mu\phi^+) --\frac{1}{4}g^2W^+_\mu W^{-\mu}(H^2+(\phi^0)^2+2\phi^+\phi^-) --\frac{1}{8}g^2\frac{1}{c_w^2}Z^0_\mu Z^{0\mu}(H^2+(\phi^0)^2+2(2s_w^2-1)^2\phi^+\phi^-) --\frac{1}{2}g^2\frac{s_w}{c_w}Z^0_\mu\phi^0(W^{+\mu}\phi^-+W^{-\mu}\phi^+) --\frac{i}{2}g^2\frac{s_w}{c_w}Z^0_\mu H(W^{+\mu}\phi^- - W^{-\mu}\phi^+) -+\frac{1}{2}g^2s_wA_\mu\phi^0(W^{+\mu}\phi^-+W^{-\mu}\phi^+) -+\frac{i}{2}g^2s_wA_\mu H(W^{+\mu}\phi^- - W^{-\mu}\phi^+) --g^2\frac{s_w}{c_w}(2c_w^2-1)Z^0_\mu A^\mu\phi^+\phi^- - g^2s_w^2A_\mu A^\mu\phi^+\phi^- -+\frac{i}{2}g_s\lambda^a_{ij}(\bar{q}_i\gamma^\mu q_j)g^a_\mu --\bar{e}^\lambda(\gamma\partial+m_e^\lambda)e^\lambda-\bar{\nu}^\lambda(\gamma\partial+m_\nu^\lambda)\nu^\lambda --\bar{u}^\lambda_j(\gamma\partial+m_u^\lambda)u^\lambda_j-\bar{d}^\lambda_j(\gamma\partial+m_d^\lambda)d^\lambda_j -+igs_wA_\mu(-(\bar{e}^\lambda\gamma^\mu e^\lambda) -+\frac{2}{3}(\bar{u}^\lambda_j\gamma^\mu u^\lambda_j)-\frac{1}{3}(\bar{d}^\lambda_j\gamma^\mu d^\lambda_j)) -+\frac{ig}{4c_w}Z^0_\mu(\bar{\nu}^\lambda\gamma^\mu(1+\gamma^5)\nu^\lambda -+\bar{e}^\lambda\gamma^\mu(4s_w^2-1-\gamma^5)e^\lambda -+\bar{d}^\lambda_j\gamma^\mu(\frac{4}{3}s_w^2-1-\gamma^5)d^\lambda_j -+\bar{u}^\lambda_j\gamma^\mu(1-\frac{8}{3}s_w^2+\gamma^5)u^\lambda_j) -+\frac{ig}{2\sqrt{2}}W^+_\mu((\bar{\nu}^\lambda\gamma^\mu(1+\gamma^5)U^{lep}_{\lambda\kappa}e^\kappa) -+(\bar{u}^\lambda_j\gamma^\mu(1+\gamma^5)C_{\lambda\kappa}d^\kappa_j)) -+\frac{ig}{2\sqrt{2}}W^-_\mu((\bar{e}^\kappa U^{lep\dagger}_{\kappa\lambda}\gamma^\mu(1+\gamma^5)\nu^\lambda) -+(\bar{d}^\kappa_jC^\dagger_{\kappa\lambda}\gamma^\mu(1+\gamma^5)u^\lambda_j)) -+\frac{ig}{2M\sqrt{2}}\phi^+(-m_e^\kappa\bar{\nu}^\lambda U^{lep}_{\lambda\kappa}(1-\gamma^5)e^\kappa -+m_\nu^\lambda\bar{\nu}^\lambda U^{lep}_{\lambda\kappa}(1+\gamma^5)e^\kappa) -+\frac{ig}{2M\sqrt{2}}\phi^-(m_e^\lambda\bar{e}^\lambda U^{lep\dagger}_{\lambda\kappa}(1+\gamma^5)\nu^\kappa --m_\nu^\kappa\bar{e}^\lambda U^{lep\dagger}_{\lambda\kappa}(1-\gamma^5)\nu^\kappa) --\frac{g m_\nu^\lambda}{2M}H(\bar{\nu}^\lambda\nu^\lambda)-\frac{g m_e^\lambda}{2M}H(\bar{e}^\lambda e^\lambda) -+\frac{ig m_\nu^\lambda}{2M}\phi^0(\bar{\nu}^\lambda\gamma^5\nu^\lambda) --\frac{ig m_e^\lambda}{2M}\phi^0(\bar{e}^\lambda\gamma^5e^\lambda) --\frac{1}{4}\bar{\nu}_\lambda M^R_{\lambda\kappa}(1-\gamma^5)\hat{\nu}_\kappa --\frac{1}{4}\bar{\nu}_\lambda M^R_{\lambda\kappa}(1-\gamma^5)\hat{\nu}_\kappa -+\frac{ig}{2M\sqrt{2}}\phi^+(-m_d^\kappa\bar{u}^\lambda_jC_{\lambda\kappa}(1-\gamma^5)d^\kappa_j -+m_u^\lambda\bar{u}^\lambda_jC_{\lambda\kappa}(1+\gamma^5)d^\kappa_j) -+\frac{ig}{2M\sqrt{2}}\phi^-(m_d^\lambda\bar{d}^\lambda_jC^\dagger_{\lambda\kappa}(1+\gamma^5)u^\kappa_j --m_u^\kappa\bar{d}^\lambda_jC^\dagger_{\lambda\kappa}(1-\gamma^5)u^\kappa_j) --\frac{g m_u^\lambda}{2M}H(\bar{u}^\lambda_j u^\lambda_j)-\frac{g m_d^\lambda}{2M}H(\bar{d}^\lambda_jd^\lambda_j) -+\frac{ig m_u^\lambda}{2M}\phi^0(\bar{u}^\lambda_j\gamma^5u^\lambda_j) --\frac{ig m_d^\lambda}{2M}\phi^0(\bar{d}^\lambda_j\gamma^5d^\lambda_j) -+\bar{G}^a\partial^2G^a+g_sf^{abc}\partial_\mu\bar{G}^aG^bg^c_\mu -+X^+(\partial^2-M^2)X^+ + X^-(\partial^2-M^2)X^- + X^0(\partial^2-\frac{M^2}{c_w^2})X^0+\bar{Y}\partial^2Y -+igc_wW^+_\mu(\partial^\mu X^0X^- - \partial^\mu X^+X^0) -+igs_wW^+_\mu(\partial^\mu YX^- - \partial^\mu X^+Y) -+igc_wW^-_\mu(\partial^\mu X^-X^0 - \partial^\mu X^0X^+) -+igs_wW^-_\mu(\partial^\mu X^-Y - \partial^\mu YX^+) -+igc_wZ^0_\mu(\partial^\mu X^+X^+ - \partial^\mu X^-X^-) -+igs_wA_\mu(\partial^\mu X^+X^+ - \partial^\mu X^-X^-) --\frac{1}{2}gM(\bar{X}^+X^+H+\bar{X}^-X^-H+\frac{1}{c_w^2}\bar{X}^0X^0H) -+\frac{1-2c_w^2}{2c_w}igM(\bar{X}^+X^0\phi^- - \bar{X}^-X^0\phi^+) -+\frac{1}{2c_w}igM(\bar{X}^0X^-\phi^+ - \bar{X}^0X^+\phi^-) -+igMs_w(\bar{X}^0X^-\phi^+ - \bar{X}^0X^+\phi^-) -+\frac{i}{2}gM(\bar{X}^+X^+\phi^0 - \bar{X}^-X^-\phi^0). -``` diff --git a/examples/standard-model-lagrangian-selectable-a4.md b/examples/standard-model-lagrangian-selectable-a4.md deleted file mode 100644 index a418c74..0000000 --- a/examples/standard-model-lagrangian-selectable-a4.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -theme: light -layout: clean -aspect: a4 -break_mode: off ---- - - - -```text -L_SM = -1/2 ∂_ν g^a_μ ∂^ν g^{aμ} - g_s f^{abc} ∂_μ g^a_ν g^{bμ}g^{cν} - - 1/4 g_s² f^{abc}f^{ade} g^b_μg^c_νg^{dμ}g^{eν} - - ∂_ν W^+_μ∂^ν W^{-μ} - M² W^+_μW^{-μ} - - 1/2 ∂_ν Z^0_μ∂^ν Z^{0μ} - 1/(2c_w²) M² Z^0_μZ^{0μ} - - 1/2 ∂_μA_ν∂^μA^ν - i g c_w(∂_νZ^0_μ(W^{+μ}W^{-ν}-W^{+ν}W^{-μ}) - - Z^0_ν(W^+_μ∂^νW^{-μ}-W^-_μ∂^νW^{+μ}) - + Z^0_μ(W^+_ν∂^νW^{-μ}-W^-_ν∂^νW^{+μ})) - - i g s_w(∂_νA_μ(W^{+μ}W^{-ν}-W^{+ν}W^{-μ}) - - A_ν(W^+_μ∂^νW^{-μ}-W^-_μ∂^νW^{+μ}) - + A_μ(W^+_ν∂^νW^{-μ}-W^-_ν∂^νW^{+μ})) - - 1/2 g² W^+_μW^{-μ}W^+_νW^{-ν} + 1/2 g² W^+_μW^{+μ}W^-_νW^{-ν} - + g² c_w²(Z^0_μW^+_νZ^{0μ}W^{-ν}-Z^0_μZ^{0μ}W^+_νW^{-ν}) - + g² s_w²(A_μW^+_νA^μW^{-ν}-A_μA^μW^+_νW^{-ν}) - + g² s_wc_w(A_μZ^0_ν(W^{+μ}W^{-ν}+W^{-μ}W^{+ν})-2A_μZ^{0μ}W^+_νW^{-ν}) - - 2A_μZ^0_νW^{+μ}W^{-ν} - 1/2 ∂_μH∂^μH - 2M²α_hH² - - ∂_μφ^+∂^μφ^- - 1/2 ∂_μφ^0∂^μφ^0 - - β_h(2M²/g² + 2MH/g + 1/2(H² + φ^0φ^0 + 2φ^+φ^-)) + 2M⁴α_h/g² - - gα_hM(H³ + Hφ^0φ^0 + 2Hφ^+φ^-) - - 1/8 g²α_h(H⁴ + (φ^0)⁴ + 4(φ^+φ^-)² + 4(φ^0)²φ^+φ^- - + 4H²φ^+φ^- + 2(φ^0)²H²) - - gMW^+_μW^{-μ}H - 1/2 gM/c_w² Z^0_μZ^{0μ}H - - 1/2 i g(W^+_μ(φ^0∂^μφ^- - φ^-∂^μφ^0) - - W^-_μ(φ^0∂^μφ^+ - φ^+∂^μφ^0)) - + 1/2 g(W^+_μ(H∂^μφ^- - φ^-∂^μH) + W^-_μ(H∂^μφ^+ - φ^+∂^μH)) - + 1/2 g/c_w Z^0_μ(H∂^μφ^0 - φ^0∂^μH) - + M(1/c_w Z^0_μ∂^μφ^0 + W^+_μ∂^μφ^- + W^-_μ∂^μφ^+) - - i g s_w²/c_w M Z^0_μ(W^{+μ}φ^- - W^{-μ}φ^+) - + i g s_w M A_μ(W^{+μ}φ^- - W^{-μ}φ^+) - - i g(1-2c_w²)/(2c_w) Z^0_μ(φ^+∂^μφ^- - φ^-∂^μφ^+) - + i g s_w A_μ(φ^+∂^μφ^- - φ^-∂^μφ^+) - - 1/4 g²W^+_μW^{-μ}(H² + (φ^0)² + 2φ^+φ^-) - - 1/8 g²/c_w² Z^0_μZ^{0μ}(H² + (φ^0)² + 2(2s_w²-1)²φ^+φ^-) - - 1/2 g²s_w/c_w Z^0_μφ^0(W^{+μ}φ^- + W^{-μ}φ^+) - - 1/2 i g²s_w/c_w Z^0_μH(W^{+μ}φ^- - W^{-μ}φ^+) - + 1/2 g²s_w A_μφ^0(W^{+μ}φ^- + W^{-μ}φ^+) - + 1/2 i g²s_w A_μH(W^{+μ}φ^- - W^{-μ}φ^+) - - g²s_w/c_w(2c_w²-1)Z^0_μA^μφ^+φ^- - g²s_w²A_μA^μφ^+φ^- - + 1/2 i g_sλ^a_{ij}(q̄_iγ^μq_j)g^a_μ - - eλ̄(γ∂ + m_e)λ - ν̄λ(γ∂ + m_ν)νλ - ū^λ_j(γ∂ + m_u^λ)u^λ_j - - d̄^λ_j(γ∂ + m_d^λ)d^λ_j - + i g s_w A_μ(-(ē^λγ^μe^λ) + 2/3(ū^λ_jγ^μu^λ_j) - 1/3(d̄^λ_jγ^μd^λ_j)) - + i g/(4c_w)Z^0_μ(ν̄^λγ^μ(1+γ^5)ν^λ - + ē^λγ^μ(4s_w²-1-γ^5)e^λ + d̄^λ_jγ^μ(4/3s_w²-1-γ^5)d^λ_j - + ū^λ_jγ^μ(1-8/3s_w²+γ^5)u^λ_j) - + i g/(2√2)W^+_μ(ν̄^λγ^μ(1+γ^5)U^lep_{λκ}e^κ - + ū^λ_jγ^μ(1+γ^5)C_{λκ}d^κ_j) - + i g/(2√2)W^-_μ(ē^κU^{lep†}_{κλ}γ^μ(1+γ^5)ν^λ - + d̄^κ_jC†_{κλ}γ^μ(1+γ^5)u^λ_j) - + i g/(2M√2)φ^+(-m_e^κν̄^λU^lep_{λκ}(1-γ^5)e^κ - + m_ν^λν̄^λU^lep_{λκ}(1+γ^5)e^κ) - + i g/(2M√2)φ^- (m_e^λē^λU^{lep†}_{λκ}(1+γ^5)ν^κ - - m_ν^κē^λU^{lep†}_{λκ}(1-γ^5)ν^κ) - - g m_ν^λ/(2M)H(ν̄^λν^λ) - g m_e^λ/(2M)H(ē^λe^λ) - + i g m_ν^λ/(2M)φ^0(ν̄^λγ^5ν^λ) - i g m_e^λ/(2M)φ^0(ē^λγ^5e^λ) - - 1/4 ν̄_λM^R_{λκ}(1-γ^5)ν̂_κ - 1/4 ν̄_λM^R_{λκ}(1-γ^5)ν̂_κ - + i g/(2M√2)φ^+(-m_d^κū^λ_jC_{λκ}(1-γ^5)d^κ_j - + m_u^λū^λ_jC_{λκ}(1+γ^5)d^κ_j) - + i g/(2M√2)φ^-(m_d^λd̄^λ_jC†_{λκ}(1+γ^5)u^κ_j - - m_u^κd̄^λ_jC†_{λκ}(1-γ^5)u^κ_j) - - g m_u^λ/(2M)H(ū^λ_ju^λ_j) - g m_d^λ/(2M)H(d̄^λ_jd^λ_j) - + i g m_u^λ/(2M)φ^0(ū^λ_jγ^5u^λ_j) - i g m_d^λ/(2M)φ^0(d̄^λ_jγ^5d^λ_j) - + Ḡ^a∂²G^a + g_s f^{abc}∂_μḠ^aG^bg^{cμ} - + X^+(∂²-M²)X^+ + X^-(∂²-M²)X^- + X^0(∂²-M²/c_w²)X^0 + Ȳ∂²Y - + i g c_w W^+_μ(∂^μX^0X^- - ∂^μX^+X^0) - + i g s_w W^+_μ(∂^μY X^- - ∂^μX^+Y) - + i g c_w W^-_μ(∂^μX^-X^0 - ∂^μX^0X^+) - + i g s_w W^-_μ(∂^μX^-Y - ∂^μY X^+) - + i g c_w Z^0_μ(∂^μX^+X^+ - ∂^μX^-X^-) - + i g s_w A_μ(∂^μX^+X^+ - ∂^μX^-X^-) - - 1/2 gM(X̄^+X^+H + X̄^-X^-H + 1/c_w² X̄^0X^0H) - + (1-2c_w²)/(2c_w) i gM(X̄^+X^0φ^- - X̄^-X^0φ^+) - + 1/(2c_w) i gM(X̄^0X^-φ^+ - X̄^0X^+φ^-) - + i gM s_w(X̄^0X^-φ^+ - X̄^0X^+φ^-) - + 1/2 i gM(X̄^+X^+φ^0 - X̄^-X^-φ^0). -``` diff --git a/examples/standard-model-lagrangian.md b/examples/standard-model-lagrangian.md deleted file mode 100644 index 559ab56..0000000 --- a/examples/standard-model-lagrangian.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Standard Model Lagrangian Stress -subtitle: Dense native SVG math without a TeX dependency -author: md2any -theme: light -layout: clean -math: svg -math_scale: 0.56 -math_block_align: center -math_max_height: 360 ---- - -# Standard Model Stress - -## Reference Image - -![Reference rendering of the Standard Model Lagrangian](assets/standard-model-lagrangian.png){width=100%} - -## Gauge, Higgs, And Potential - -$$ -\begin{aligned} -\mathcal{L}_{gauge} =& -\frac{1}{4}G_{\mu\nu}^{a}G^{a\mu\nu} --\frac{1}{4}W_{\mu\nu}^{i}W^{i\mu\nu} --\frac{1}{4}B_{\mu\nu}B^{\mu\nu} \\ -&+ (D_{\mu}\Phi)^{\ast}(D^{\mu}\Phi) -+\mu^{2}\Phi^{\ast}\Phi --\lambda(\Phi^{\ast}\Phi)^{2} -\end{aligned} -$$ - -$$ -V(\Phi)= -\left[ -\begin{array}{cc} --\mu^{2}\Phi^{\ast}\Phi & \lambda(\Phi^{\ast}\Phi)^{2} \\ -M_{W}^{2}W_{\mu}^{+}W^{-\mu} & \frac{1}{2}M_{Z}^{2}Z_{\mu}Z^{\mu} -\end{array} -\right] -$$ - -## Fermion Kinetic Terms - -$$ -\begin{aligned} -\mathcal{L}_{fermion} =& -i\bar{q}_{L}\gamma^{\mu}D_{\mu}q_{L} -+i\bar{u}_{R}\gamma^{\mu}D_{\mu}u_{R} -+i\bar{d}_{R}\gamma^{\mu}D_{\mu}d_{R} \\ -&+i\bar{\ell}_{L}\gamma^{\mu}D_{\mu}\ell_{L} -+i\bar{e}_{R}\gamma^{\mu}D_{\mu}e_{R} -+i\bar{\nu}_{L}\gamma^{\mu}D_{\mu}\nu_{L} -\end{aligned} -$$ - -$$ -\begin{aligned} -\mathcal{L}_{Yukawa} =& --y_{u}\bar{q}_{L}\tilde{\Phi}u_{R} --y_{d}\bar{q}_{L}\Phi d_{R} --y_{e}\bar{\ell}_{L}\Phi e_{R} \\ -&-y_{\nu}\bar{\ell}_{L}\tilde{\Phi}\nu_{R} -+h.c. -\end{aligned} -$$ - -## Interaction Pressure - -$$ -\begin{aligned} -\mathcal{L}_{int} =& -g_{s}f^{abc}(\partial_{\mu}G_{\nu}^{a})G^{b\mu}G^{c\nu} -+g\epsilon^{ijk}(\partial_{\mu}W_{\nu}^{i})W^{j\mu}W^{k\nu} \\ -&+\frac{g}{\sqrt{2}}\left(W_{\mu}^{+}J_{W}^{\mu}+W_{\mu}^{-}\bar{J}_{W}^{\mu}\right) -+\frac{g}{c_{w}}Z_{\mu}J_{Z}^{\mu} -+eA_{\mu}J_{em}^{\mu} \\ -&+g^{2}W_{\mu}^{+}W^{-\mu}H^{2} -+\frac{g^{2}}{4c_{w}^{2}}Z_{\mu}Z^{\mu}H^{2} -+\lambda vH^{3} -+\frac{\lambda}{4}H^{4} -\end{aligned} -$$ - -## Mass And Mixing Summary - -$$ -\begin{cases} -M_{W}=\frac{1}{2}gv & charged\ vector \\ -M_{Z}=\frac{1}{2}\sqrt{g^{2}+g'^{2}}v & neutral\ vector \\ -m_{f}=\frac{y_{f}v}{\sqrt{2}} & fermion -\end{cases} -$$ - -$$ -\left[ -\begin{array}{ccc} -G_{\mu}^{1} & G_{\mu}^{2} & G_{\mu}^{3} \\ -W_{\mu}^{+} & Z_{\mu}^{0} & A_{\mu} \\ -\phi^{+} & H & \phi^{0} -\end{array} -\right] -\quad -\binom{\nu_{e}}{e^{-}} -\quad -\left\lVert D_{\mu}\Phi \right\rVert^{2} -$$ diff --git a/src/docx.rs b/src/docx.rs index 854c80e..41fdb98 100644 --- a/src/docx.rs +++ b/src/docx.rs @@ -948,7 +948,7 @@ fn render_block( )); } } - Block::Table { headers, rows } => render_table(out, headers, rows, theme, rels), + Block::Table { headers, rows, .. } => render_table(out, headers, rows, theme, rels), Block::Columns { left, right } => { render_blocks(out, left, theme, by_src, image_rels, rels); render_blocks(out, right, theme, by_src, image_rels, rels); diff --git a/src/font.rs b/src/font.rs index 58d278c..1edfd23 100644 --- a/src/font.rs +++ b/src/font.rs @@ -295,7 +295,7 @@ impl AuditBuilder<'_> { self.runs(slide, "quote", runs, FaceKind::SansRegular.index()); } } - Block::Table { headers, rows } => { + Block::Table { headers, rows, .. } => { for cell in headers.iter().chain(rows.iter().flatten()) { self.runs(slide, "table cell", cell, FaceKind::SansRegular.index()); } diff --git a/src/html.rs b/src/html.rs index 4278d39..5a17add 100644 --- a/src/html.rs +++ b/src/html.rs @@ -6,7 +6,7 @@ //! PDF. use crate::image; -use crate::ir::{Block, ListItem, Run, Slide, SlideKind}; +use crate::ir::{Block, ColumnAlign, ListItem, Run, Slide, SlideKind}; use crate::layout::Layout; use crate::syntax::{self, TokenKind}; use crate::theme::Theme; @@ -320,7 +320,11 @@ fn render_block(out: &mut String, block: &Block, theme: &Theme, base_dir: &Path) } out.push_str("\n"); } - Block::Table { headers, rows } => render_table(out, headers, rows)?, + Block::Table { + headers, + rows, + aligns, + } => render_table(out, headers, rows, aligns)?, Block::ColumnBreak => {} Block::Columns { left, right } => { out.push_str("
\n
\n"); @@ -414,7 +418,11 @@ fn render_list(out: &mut String, items: &[ListItem]) -> Result<()> { for counter in counters.iter_mut().skip(level + 1) { *counter = 0; } - let marker = if item.ordered { + // Task-list items carry their own ☐/☑ marker in the runs, so leave the + // bullet slot empty to avoid showing both a bullet and a checkbox. + let marker = if item.is_task() { + String::new() + } else if item.ordered { format!("{}.", counters[level].max(1)) } else { "•".to_string() @@ -500,18 +508,30 @@ fn render_code_block( Ok(()) } -fn render_table(out: &mut String, headers: &[Vec], rows: &[Vec>]) -> Result<()> { +fn render_table( + out: &mut String, + headers: &[Vec], + rows: &[Vec>], + aligns: &[ColumnAlign], +) -> Result<()> { + let align_attr = |col: usize| -> &'static str { + match aligns.get(col) { + Some(ColumnAlign::Center) => " style=\"text-align:center\"", + Some(ColumnAlign::Right) => " style=\"text-align:right\"", + _ => "", + } + }; out.push_str("
\n"); - for cell in headers { - out.push_str(""); } out.push_str("\n\n"); for row in rows { out.push_str(""); - for cell in row { - out.push_str(""); } @@ -613,7 +633,7 @@ fn css(theme: &Theme, layout: &Layout, rtl: bool) -> String { }} * {{ box-sizing: border-box; }} html, body {{ margin: 0; min-height: 100%; background: #111827; color: var(--body); }} -body {{ font-family: var(--body-font); overflow: hidden; }} +body {{ font-family: var(--body-font); overflow: hidden; overflow-wrap: anywhere; }} .deck {{ min-height: 100vh; display: grid; place-items: center; padding: 2.25vh 2.25vw; }} .slide {{ display: none; diff --git a/src/ir.rs b/src/ir.rs index 1e4e4f5..d2cb1cd 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -100,6 +100,15 @@ impl Run { } } +/// Per-column text alignment from a GFM table delimiter row (`:---`, `:---:`, +/// `---:`). pulldown-cmark's "no alignment" maps to `Left` (the default). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColumnAlign { + Left, + Center, + Right, +} + /// One entry inside a [`Block::List`]. Nesting is encoded with `level` (0 = top) /// rather than a recursive `Vec` because that mirrors how /// pulldown-cmark emits list events and keeps the IR cheap to pattern-match @@ -111,6 +120,19 @@ pub struct ListItem { pub ordered: bool, } +impl ListItem { + /// True when this item is a GFM task-list entry (`- [ ]` / `- [x]`). The + /// parser renders the checkbox as a leading `☐`/`☑` run, which stands in + /// for the list bullet — so renderers should suppress their normal bullet + /// glyph and not draw both. + pub fn is_task(&self) -> bool { + self.runs + .first() + .map(|r| r.text.starts_with('☐') || r.text.starts_with('☑')) + .unwrap_or(false) + } +} + /// A single block-level element on a slide. The renderer's job is to take a /// flat sequence of these and place them inside the slide area. Layout-aware /// helpers in each writer (height estimation, column packing) consume the @@ -145,9 +167,13 @@ pub enum Block { Quote(Vec>), /// GitHub-flavoured table. `headers` is the first row; `rows` are the /// data rows. Cells are run lists so inline formatting works inside them. + /// `aligns` carries the per-column alignment from the GFM delimiter row + /// (`:---`, `:---:`, `---:`); it has one entry per column (may be shorter + /// than the widest row — renderers default missing entries to `Left`). Table { headers: Vec>, rows: Vec>>, + aligns: Vec, }, /// Sentinel emitted by the `:::` marker. Consumed by [`crate::paginate`] /// when it folds the block list into a `Columns` block; renderers should diff --git a/src/lib.rs b/src/lib.rs index 1508d2a..27d5bba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -176,7 +176,7 @@ fn block_snapshot(b: &ir::Block, depth: usize, out: &mut String) { out.push_str(&format!("{} > {}\n", pad, runs_repr(para))); } } - Block::Table { headers, rows } => { + Block::Table { headers, rows, .. } => { out.push_str(&format!( "{}Table(cols={}, rows={}):\n", pad, diff --git a/src/lint.rs b/src/lint.rs index 189beea..3e3aa7c 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -147,7 +147,7 @@ fn check_theme_contrast(theme: &Theme, out: &mut Vec) { fn check_blocks(num: usize, blocks: &[Block], theme: &Theme, out: &mut Vec) { for b in blocks { match b { - Block::Table { headers, rows } => { + Block::Table { headers, rows, .. } => { let cols = headers .len() .max(rows.iter().map(|r| r.len()).max().unwrap_or(0)); @@ -316,7 +316,7 @@ fn block_weight(block: &Block) -> f32 { .sum::() as f32 / 100.0 } - Block::Table { headers, rows } => { + Block::Table { headers, rows, .. } => { let cols = headers .len() .max(rows.iter().map(|r| r.len()).max().unwrap_or(0)); diff --git a/src/math.rs b/src/math.rs index 9fdda73..ebd5dd7 100644 --- a/src/math.rs +++ b/src/math.rs @@ -243,6 +243,7 @@ pub enum MathLayoutDraw { y: f32, size: f32, text: String, + bold: bool, }, Line { x1: f32, @@ -266,10 +267,26 @@ pub enum MathLayoutDraw { } pub fn layout_markup_text(src: &str, scale_percent: u16) -> MathTextLayout { + layout_markup_text_with(src, scale_percent, &DejaVuMetrics) +} + +/// Lay out a markup-math line, measuring glyph advances against `metrics`. +/// Renderers that typeset math in a face other than the bundled DejaVu Sans +/// (e.g. the PDF writer under `--font`) pass a provider backed by that face +/// so the reserved widths match the glyphs actually drawn. +pub fn layout_markup_text_with( + src: &str, + scale_percent: u16, + metrics: &dyn GlyphMetrics, +) -> MathTextLayout { let expanded = apply_user_macros(src.trim(), &[]); let node = MathParser::new(&expanded).parse_root(); let base_size = 28.0 * (scale_percent as f32 / 100.0).clamp(0.25, 4.0); - publish_math_box(layout_math(&node, base_size)) + let ctx = LayoutCtx { + metrics, + bold: false, + }; + publish_math_box(ctx.layout_math(&node, base_size)) } pub fn decode_generated_math_svg(src: &str) -> Option { @@ -496,7 +513,13 @@ fn render_math_svg(src: &str, macros: &[(String, String)], options: MathSvgOptio let expanded = apply_user_macros(src.trim(), macros); let node = MathParser::new(&expanded).parse_root(); let base_size = 28.0 * options.scale_factor(); - let layout = layout_math(&node, base_size); + // The SVG-image path always renders math in the bundled DejaVu face + // (see the below), so DejaVu metrics are exact here. + let layout = LayoutCtx { + metrics: &DejaVuMetrics, + bold: false, + } + .layout_math(&node, base_size); let pad_x = base_size; let pad_y = base_size * 0.85; let natural_width = (layout.width + pad_x * 2.0).ceil().clamp(96.0, 2200.0); @@ -546,6 +569,17 @@ enum MathNode { }, Cases(Vec<(MathNode, Option)>), Lines(Vec), + /// Bold weight applied to everything inside (`\mathbf`, `\boldsymbol`, …). + Bold(Box), + /// An accent (`\bar`, `\hat`, `\vec`, …) drawn centred over `base`. `mark` + /// is the Unicode combining char that names the accent; the layout engine + /// renders it as geometry rather than a font combining glyph, because + /// zero-advance combining marks land at the base's right edge instead of + /// centred over it (and differently in every font). + Accent { + base: Box, + mark: char, + }, } struct MathParser<'a> { @@ -673,7 +707,9 @@ impl<'a> MathParser<'a> { false }; match name { - "frac" => { + // `\dfrac`/`\tfrac`/`\cfrac` differ from `\frac` only in display + // style, which this engine already approximates per nesting depth. + "frac" | "dfrac" | "tfrac" | "cfrac" => { let num = self.parse_required_group_node(); let den = self.parse_required_group_node(); MathNode::Fraction(Box::new(num), Box::new(den)) @@ -700,8 +736,12 @@ impl<'a> MathParser<'a> { "big" | "Big" | "bigg" | "Bigg" | "bigl" | "bigr" | "Bigl" | "Bigr" | "biggl" | "biggr" | "Biggl" | "Biggr" => MathNode::Text(self.parse_delimiter_token()), "limits" | "nolimits" => MathNode::Text(String::new()), - "text" | "textrm" | "textit" | "textbf" | "mathrm" | "mathbf" | "mathit" | "mathsf" - | "mathtt" => MathNode::Text(self.parse_required_group_raw()), + "mathbf" | "textbf" | "boldsymbol" | "bm" | "pmb" | "mathbfit" => { + MathNode::Bold(Box::new(self.parse_required_group_node())) + } + "text" | "textrm" | "textit" | "mathrm" | "mathit" | "mathsf" | "mathtt" => { + MathNode::Text(self.parse_required_group_raw()) + } "operatorname" => { let mut name = self.parse_required_group_raw(); if starred && !name.is_empty() { @@ -720,7 +760,10 @@ impl<'a> MathParser<'a> { _ if accent_mark(name).is_some() => { let mark = accent_mark(name).unwrap_or('\u{0302}'); let arg = self.parse_required_group_node(); - MathNode::Text(apply_combining_mark(&node_plain_text(&arg), mark)) + MathNode::Accent { + base: Box::new(arg), + mark, + } } _ => { if let Some(rep) = greek_or_symbol(name) { @@ -974,6 +1017,14 @@ impl<'a> MathParser<'a> { } self.pos = utf8_char_end(self.bytes, self.pos); } + if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'^' | b'_') { + let run = &self.src[start..self.pos]; + if let Some((last_offset, _)) = run.char_indices().last() { + if last_offset > 0 { + self.pos = start + last_offset; + } + } + } MathNode::Text(self.src[start..self.pos].into()) } } @@ -1130,59 +1181,6 @@ fn delimiter_name(token: &str) -> &str { } } -fn node_plain_text(node: &MathNode) -> String { - match node { - MathNode::Row(nodes) | MathNode::Lines(nodes) => { - nodes.iter().map(node_plain_text).collect() - } - MathNode::Text(text) => text.clone(), - MathNode::Fraction(num, den) => { - format!("({})/({})", node_plain_text(num), node_plain_text(den)) - } - MathNode::Sqrt(inner) => format!("√({})", node_plain_text(inner)), - MathNode::Delimited { left, body, right } => { - format!("{left}{}{right}", node_plain_text(body)) - } - MathNode::Script { base, sub, sup } => { - let mut out = node_plain_text(base); - if let Some(sub) = sub { - out.push('_'); - out.push_str(&node_plain_text(sub)); - } - if let Some(sup) = sup { - out.push('^'); - out.push_str(&node_plain_text(sup)); - } - out - } - MathNode::Matrix { rows, .. } => rows - .iter() - .map(|row| { - row.iter() - .map(node_plain_text) - .collect::>() - .join(" ") - }) - .collect::>() - .join("\n"), - MathNode::Cases(rows) => rows - .iter() - .map(|(expr, condition)| { - if let Some(condition) = condition { - format!( - "{} if {}", - node_plain_text(expr), - node_plain_text(condition) - ) - } else { - node_plain_text(expr) - } - }) - .collect::>() - .join("\n"), - } -} - #[derive(Debug, Clone)] struct MathBox { width: f32, @@ -1198,6 +1196,7 @@ enum Draw { y: f32, size: f32, text: String, + bold: bool, }, Line { x1: f32, @@ -1220,209 +1219,556 @@ enum Draw { }, } -fn layout_math(node: &MathNode, size: f32) -> MathBox { - match node { - MathNode::Text(text) => layout_text(text, size), - MathNode::Row(nodes) => layout_row(nodes, size), - MathNode::Fraction(num, den) => layout_fraction(num, den, size), - MathNode::Sqrt(inner) => layout_sqrt(inner, size), - MathNode::Delimited { left, body, right } => layout_delimited(left, body, right, size), - MathNode::Script { base, sub, sup } => { - layout_script(base, sub.as_deref(), sup.as_deref(), size) - } - MathNode::Matrix { rows, left, right } => layout_matrix(rows, left, right, size), - MathNode::Cases(rows) => layout_cases(rows, size), - MathNode::Lines(lines) => layout_lines(lines, size), - } -} - -fn layout_text(text: &str, size: f32) -> MathBox { - if text.is_empty() { - return MathBox { - width: 0.0, - height: size * 1.15, - baseline: size * 0.82, - draws: Vec::new(), - }; - } - let width = text.chars().map(char_width_factor).sum::() * size; - let height = size * 1.15; - let baseline = size * 0.82; - MathBox { - width, - height, - baseline, - draws: vec![Draw::Text { - x: 0.0, - y: baseline, - size, - text: text.into(), - }], +/// Source of glyph advance widths for the layout engine. A provider reports +/// each glyph's advance as a fraction of the em **in the face that will +/// actually render it**. Because the layout reserves horizontal space here +/// while the renderer advances glyphs with the font's own metrics, the two +/// only stay aligned when the provider matches the rendering face. The +/// SVG/PNG paths always typeset math in the bundled DejaVu Sans and so use +/// [`DejaVuMetrics`]; the PDF path supplies a provider backed by its embedded +/// face, so a `--font` replacement stays pixel-aligned instead of drifting. +pub trait GlyphMetrics { + /// Advance width of `ch` as a fraction of the em, at the given weight. + /// `bold` selects the bold face's metrics — bold glyphs are ~12% wider + /// in DejaVu, so a bold run reserves its own space. + fn advance_em(&self, ch: char, bold: bool) -> f32; +} + +/// Built-in metrics for the bundled DejaVu Sans face. Also the sensible +/// fallback for any glyph a custom face cannot supply, since unmapped glyphs +/// degrade to DejaVu (or a fallback font) at render time anyway. +pub struct DejaVuMetrics; + +/// Mean advance ratio of DejaVu Sans **Bold** over Regular (measured across +/// the math glyph set). Bold runs in the SVG/PNG paths — which always render +/// in DejaVu — reserve width with this scale; the PDF path measures the real +/// bold face instead, so this approximation only affects short SVG bold runs. +const DEJAVU_BOLD_WIDTH_SCALE: f32 = 1.12; + +impl GlyphMetrics for DejaVuMetrics { + fn advance_em(&self, ch: char, bold: bool) -> f32 { + let w = char_width_factor(ch); + if bold { + w * DEJAVU_BOLD_WIDTH_SCALE + } else { + w + } + } +} + +/// Carries the active [`GlyphMetrics`] and weight through the recursive +/// layout pass so every `layout_*` step measures text against the same face. +struct LayoutCtx<'m> { + metrics: &'m dyn GlyphMetrics, + bold: bool, +} + +impl<'m> LayoutCtx<'m> { + /// A child context with bold weight enabled for everything it lays out. + fn bolded(&self) -> LayoutCtx<'m> { + LayoutCtx { + metrics: self.metrics, + bold: true, + } + } + + fn layout_math(&self, node: &MathNode, size: f32) -> MathBox { + match node { + MathNode::Text(text) => self.layout_text(text, size), + MathNode::Row(nodes) => self.layout_row(nodes, size), + MathNode::Fraction(num, den) => self.layout_fraction(num, den, size), + MathNode::Sqrt(inner) => self.layout_sqrt(inner, size), + MathNode::Delimited { left, body, right } => { + self.layout_delimited(left, body, right, size) + } + MathNode::Script { base, sub, sup } => { + self.layout_script(base, sub.as_deref(), sup.as_deref(), size) + } + MathNode::Matrix { rows, left, right } => self.layout_matrix(rows, left, right, size), + MathNode::Cases(rows) => self.layout_cases(rows, size), + MathNode::Lines(lines) => self.layout_lines(lines, size), + MathNode::Bold(inner) => self.bolded().layout_math(inner, size), + MathNode::Accent { base, mark } => self.layout_accent(base, *mark, size), + } + } + + /// Lay out an accent (`\bar`, `\hat`, …) by drawing it as geometry centred + /// over the base. This sidesteps font combining-mark placement, which puts + /// zero-advance marks at the base's right edge rather than over it. + fn layout_accent(&self, base: &MathNode, mark: char, size: f32) -> MathBox { + let base_box = self.layout_math(base, size); + let stroke = (size * 0.05).max(1.2); + // Vertical room added above the base for the accent. + let pad = size * 0.20; + let w = base_box.width; + let cx = w / 2.0; + // Accent baseline within the padding band, a little above the base ink. + let ay = pad * 0.45; + let mut draws = Vec::new(); + append_draws(&mut draws, &base_box, 0.0, pad); + match mark { + // bar / overline — a horizontal rule spanning the base. + '\u{0304}' | '\u{0305}' => { + let half = (w * 0.5 - size * 0.03).max(size * 0.14); + draws.push(Draw::Line { + x1: cx - half, + y1: ay, + x2: cx + half, + y2: ay, + stroke_width: stroke, + }); + } + // hat — a caret. + '\u{0302}' => { + let hw = (w * 0.32).clamp(size * 0.14, size * 0.30); + draws.push(Draw::Polyline { + points: vec![ + (cx - hw, ay + size * 0.10), + (cx, ay - size * 0.03), + (cx + hw, ay + size * 0.10), + ], + stroke_width: stroke, + }); + } + // tilde — a shallow wave. + '\u{0303}' => { + let hw = (w * 0.32).clamp(size * 0.14, size * 0.30); + let a = size * 0.05; + draws.push(Draw::Polyline { + points: vec![ + (cx - hw, ay + a), + (cx - hw * 0.3, ay - a), + (cx + hw * 0.3, ay + a), + (cx + hw, ay - a), + ], + stroke_width: stroke, + }); + } + // vec — a right arrow. + '\u{20d7}' => { + let half = (w * 0.5).max(size * 0.16); + draws.push(Draw::Line { + x1: cx - half, + y1: ay, + x2: cx + half, + y2: ay, + stroke_width: stroke, + }); + let hh = size * 0.07; + draws.push(Draw::Polyline { + points: vec![ + (cx + half - size * 0.11, ay - hh), + (cx + half, ay), + (cx + half - size * 0.11, ay + hh), + ], + stroke_width: stroke, + }); + } + // dot / ddot — round caps make a short stroke read as a dot. + '\u{0307}' => { + draws.push(dot_draw(cx, ay, size)); + } + '\u{0308}' => { + let dx = size * 0.13; + draws.push(dot_draw(cx - dx, ay, size)); + draws.push(dot_draw(cx + dx, ay, size)); + } + _ => {} + } + MathBox { + width: w, + height: pad + base_box.height, + baseline: pad + base_box.baseline, + draws, + } } -} -fn char_width_factor(ch: char) -> f32 { - if ch.is_whitespace() { - 0.34 - } else if ('\u{0300}'..='\u{036f}').contains(&ch) { - 0.0 - } else if ch.is_ascii_punctuation() { - 0.36 - } else if ch.is_ascii_digit() || ch.is_ascii_alphabetic() { - 0.58 - } else { - 0.66 + fn layout_text(&self, text: &str, size: f32) -> MathBox { + if text.is_empty() { + return MathBox { + width: 0.0, + height: size * 1.15, + baseline: size * 0.82, + draws: Vec::new(), + }; + } + let width = text + .chars() + .map(|ch| self.metrics.advance_em(ch, self.bold)) + .sum::() + * size; + let height = size * 1.15; + let baseline = size * 0.82; + MathBox { + width, + height, + baseline, + draws: vec![Draw::Text { + x: 0.0, + y: baseline, + size, + text: text.into(), + bold: self.bold, + }], + } } } -fn layout_row(nodes: &[MathNode], size: f32) -> MathBox { - let children = nodes - .iter() - .map(|node| layout_math(node, size)) - .collect::>(); - let baseline = children - .iter() - .map(|b| b.baseline) - .fold(size * 0.82, f32::max); - let below = children - .iter() - .map(|b| b.height - b.baseline) - .fold(size * 0.33, f32::max); - let mut draws = Vec::new(); - let mut x = 0.0; - for child in children { - append_draws(&mut draws, &child, x, baseline - child.baseline); - x += child.width; - } - MathBox { - width: x, - height: baseline + below, - baseline, - draws, - } -} +/// Advance width of `ch` as a fraction of the em, taken from the real glyph +/// metrics of **DejaVu Sans** — the face both the PDF and the SVG/PNG paths +/// use to draw math text. The layout engine reserves horizontal space using +/// these factors while the renderer advances the glyphs with the font's own +/// widths, so the two only stay in lockstep if the factors match the font. +/// Hand-rounded guesses (the old table) drifted by up to 0.28 em on `+`/`-`, +/// which a 90-term Lagrangian — built almost entirely of those signs — +/// accumulates into visible overlap. Values below are `advanceWidth / +/// unitsPerEm` straight from `DejaVuSans.ttf` (unitsPerEm = 2048). +// 0.318 (the comma/period/`·` advance) is close to 1/π, which trips the +// `approx_constant` lint — but these are real DejaVu advance widths, not the +// constant. +#[allow(clippy::approx_constant)] +fn char_width_factor(ch: char) -> f32 { + // Combining diacritics (\bar, \hat, …) stack over the previous glyph and + // therefore advance nothing. + if ('\u{0300}'..='\u{036f}').contains(&ch) { + return 0.0; + } + match ch { + ' ' | '\t' | '\n' | '\r' => 0.32, + '0'..='9' => 0.636, + + // ASCII lowercase + 'a' => 0.613, + 'b' => 0.635, + 'c' => 0.550, + 'd' => 0.635, + 'e' => 0.615, + 'f' => 0.352, + 'g' => 0.635, + 'h' => 0.634, + 'i' => 0.278, + 'j' => 0.278, + 'k' => 0.579, + 'l' => 0.278, + 'm' => 0.974, + 'n' => 0.634, + 'o' => 0.612, + 'p' => 0.635, + 'q' => 0.635, + 'r' => 0.411, + 's' => 0.521, + 't' => 0.392, + 'u' => 0.634, + 'v' => 0.592, + 'w' => 0.818, + 'x' => 0.592, + 'y' => 0.592, + 'z' => 0.525, + + // ASCII uppercase + 'A' => 0.684, + 'B' => 0.686, + 'C' => 0.698, + 'D' => 0.770, + 'E' => 0.632, + 'F' => 0.575, + 'G' => 0.775, + 'H' => 0.752, + 'I' => 0.295, + 'J' => 0.295, + 'K' => 0.656, + 'L' => 0.557, + 'M' => 0.863, + 'N' => 0.748, + 'O' => 0.787, + 'P' => 0.603, + 'Q' => 0.787, + 'R' => 0.695, + 'S' => 0.635, + 'T' => 0.611, + 'U' => 0.732, + 'V' => 0.684, + 'W' => 0.989, + 'X' => 0.685, + 'Y' => 0.611, + 'Z' => 0.685, + + // ASCII operators, relations and punctuation + '+' | '=' | '<' | '>' | '~' | '#' => 0.838, + '-' => 0.361, + '*' => 0.500, + '/' | '\\' | ':' | ';' | '|' => 0.337, + '(' | ')' | '[' | ']' => 0.390, + '{' | '}' => 0.636, + ',' | '.' => 0.318, + '!' => 0.401, + '?' => 0.531, + '\'' => 0.275, + '"' => 0.460, + '&' => 0.780, + '%' => 0.950, + '@' => 1.000, -fn layout_fraction(num: &MathNode, den: &MathNode, size: f32) -> MathBox { - let child_size = (size * 0.82).max(12.0); - let num_box = layout_math(num, child_size); - let den_box = layout_math(den, child_size); - let pad = size * 0.28; - let gap = size * 0.16; - let line_y = num_box.height + gap; - let width = num_box.width.max(den_box.width) + pad * 2.0; - let mut draws = Vec::new(); - append_draws(&mut draws, &num_box, (width - num_box.width) / 2.0, 0.0); - draws.push(Draw::Line { - x1: 0.0, - y1: line_y, - x2: width, - y2: line_y, - stroke_width: (size * 0.045).max(1.2), - }); - let den_y = line_y + gap; - append_draws(&mut draws, &den_box, (width - den_box.width) / 2.0, den_y); - MathBox { - width, - height: den_y + den_box.height, - baseline: line_y + gap + den_box.baseline, - draws, - } -} + // Greek lowercase + 'α' => 0.659, + 'β' => 0.638, + 'γ' => 0.592, + 'δ' => 0.612, + 'ε' => 0.541, + 'ζ' => 0.544, + 'η' => 0.634, + 'θ' => 0.612, + 'ϑ' => 0.619, + 'ι' => 0.338, + 'κ' => 0.589, + 'λ' => 0.592, + 'μ' => 0.636, + 'ν' => 0.559, + 'ξ' => 0.558, + 'π' => 0.602, + 'ϖ' => 0.837, + 'ρ' => 0.635, + 'ϱ' => 0.635, + 'σ' => 0.634, + 'ς' => 0.587, + 'τ' => 0.602, + 'υ' => 0.579, + 'φ' => 0.660, + 'ϕ' => 0.660, + 'χ' => 0.578, + 'ψ' => 0.660, + 'ω' => 0.837, -fn layout_sqrt(inner: &MathNode, size: f32) -> MathBox { - let inner_box = layout_math(inner, size * 0.94); - let left = size * 0.62; - let top = size * 0.14; - let pad = size * 0.18; - let width = left + inner_box.width + pad; - let height = inner_box.height + top + size * 0.10; - let baseline = top + inner_box.baseline; - let mut draws = Vec::new(); - let y_mid = top + inner_box.height * 0.58; - let y_base = top + inner_box.height * 0.86; - draws.push(Draw::Polyline { - points: vec![ - (size * 0.08, y_mid), - (size * 0.22, y_base), - (size * 0.42, top + inner_box.height), - (left * 0.92, top + size * 0.06), - (width, top + size * 0.06), - ], - stroke_width: (size * 0.045).max(1.2), - }); - append_draws(&mut draws, &inner_box, left, top); - MathBox { - width, - height, - baseline, - draws, + // Greek uppercase + 'Α' => 0.684, + 'Β' => 0.686, + 'Γ' => 0.557, + 'Δ' => 0.684, + 'Ε' => 0.632, + 'Ζ' => 0.685, + 'Η' => 0.752, + 'Θ' => 0.787, + 'Ι' => 0.295, + 'Κ' => 0.656, + 'Λ' => 0.684, + 'Μ' => 0.863, + 'Ν' => 0.748, + 'Ξ' => 0.632, + 'Π' => 0.752, + 'Ρ' => 0.603, + 'Σ' => 0.632, + 'Τ' => 0.611, + 'Υ' => 0.611, + 'Φ' => 0.787, + 'Χ' => 0.685, + 'Ψ' => 0.787, + 'Ω' => 0.764, + + // Big operators, relations, arrows and symbols + '∑' => 0.674, + '∏' => 0.757, + '∫' | '∮' => 0.521, + '⋃' | '⋂' => 0.820, + '≤' | '≥' | '≠' | '≈' | '≡' | '∼' | '≃' | '≅' | '≐' | '≺' | '≻' | '≼' | '≽' => { + 0.838 + } + '≪' | '≫' => 1.047, + '∝' => 0.714, + '→' | '←' | '⇒' | '⇐' | '↔' | '⇔' | '↦' | '↑' | '↓' | '⇑' | '⇓' => { + 0.838 + } + '⟶' | '⟵' | '⟹' | '⟸' | '⟷' | '⟺' => 1.434, + '±' | '∓' | '×' | '÷' | '⊕' | '⊗' | '∗' | '¬' => 0.838, + '·' => 0.318, + '∘' => 0.626, + '•' => 0.590, + '⋆' => 0.626, + '∧' | '∨' | '∪' | '∩' => 0.732, + '∖' => 0.637, + '∀' => 0.684, + '∃' | '∄' => 0.632, + '∈' | '∉' | '∌' | '∅' => 0.871, + '⊂' | '⊃' | '⊆' | '⊇' | '⊈' | '⊉' => 0.838, + '∴' | '∵' => 0.636, + '∞' => 0.833, + '∂' => 0.517, + '∇' => 0.669, + 'ℏ' => 0.634, + 'ℓ' => 0.413, + 'ℜ' => 0.814, + 'ℑ' => 0.697, + 'ℵ' => 0.745, + '∠' => 0.896, + '△' => 0.769, + '°' => 0.500, + '′' => 0.227, + '†' => 0.500, + '⟨' | '⟩' | '⌊' | '⌋' | '⌈' | '⌉' => 0.390, + '‖' => 0.500, + '…' | '⋯' | '⋮' | '⋱' => 1.000, + + // Anything else: blackboard/script letters, CJK, etc. A middling + // advance is the safest guess and rarely appears in equations. + _ => 0.620, + } +} + +impl<'m> LayoutCtx<'m> { + fn layout_row(&self, nodes: &[MathNode], size: f32) -> MathBox { + let children = nodes + .iter() + .map(|node| self.layout_math(node, size)) + .collect::>(); + let baseline = children + .iter() + .map(|b| b.baseline) + .fold(size * 0.82, f32::max); + let below = children + .iter() + .map(|b| b.height - b.baseline) + .fold(size * 0.33, f32::max); + let mut draws = Vec::new(); + let mut x = 0.0; + for child in children { + append_draws(&mut draws, &child, x, baseline - child.baseline); + x += child.width; + } + MathBox { + width: x, + height: baseline + below, + baseline, + draws, + } + } + + fn layout_fraction(&self, num: &MathNode, den: &MathNode, size: f32) -> MathBox { + let child_size = (size * 0.82).max(12.0); + let num_box = self.layout_math(num, child_size); + let den_box = self.layout_math(den, child_size); + let pad = size * 0.28; + let gap = size * 0.16; + let line_y = num_box.height + gap; + let width = num_box.width.max(den_box.width) + pad * 2.0; + let mut draws = Vec::new(); + append_draws(&mut draws, &num_box, (width - num_box.width) / 2.0, 0.0); + draws.push(Draw::Line { + x1: 0.0, + y1: line_y, + x2: width, + y2: line_y, + stroke_width: (size * 0.045).max(1.2), + }); + let den_y = line_y + gap; + append_draws(&mut draws, &den_box, (width - den_box.width) / 2.0, den_y); + MathBox { + width, + height: den_y + den_box.height, + baseline: line_y + gap + den_box.baseline, + draws, + } + } + + fn layout_sqrt(&self, inner: &MathNode, size: f32) -> MathBox { + let inner_box = self.layout_math(inner, size * 0.94); + let left = size * 0.62; + let top = size * 0.14; + let pad = size * 0.18; + let width = left + inner_box.width + pad; + let height = inner_box.height + top + size * 0.10; + let baseline = top + inner_box.baseline; + let mut draws = Vec::new(); + let y_mid = top + inner_box.height * 0.58; + let y_base = top + inner_box.height * 0.86; + draws.push(Draw::Polyline { + points: vec![ + (size * 0.08, y_mid), + (size * 0.22, y_base), + (size * 0.42, top + inner_box.height), + (left * 0.92, top + size * 0.06), + (width, top + size * 0.06), + ], + stroke_width: (size * 0.045).max(1.2), + }); + append_draws(&mut draws, &inner_box, left, top); + MathBox { + width, + height, + baseline, + draws, + } } -} -fn layout_delimited(left: &str, body: &MathNode, right: &str, size: f32) -> MathBox { - let body_box = layout_math(body, size); - layout_delimited_box(left, body_box, right, size) -} - -fn layout_delimited_box(left: &str, body_box: MathBox, right: &str, size: f32) -> MathBox { - let delimiter_height = body_box.height.max(size * 1.25).min(size * 6.0); - let left_box = layout_delimiter(left, delimiter_height, size); - let right_box = layout_delimiter(right, delimiter_height, size); - let gap = if left.is_empty() && right.is_empty() { - 0.0 - } else { - size * 0.12 - }; - let body_y = (delimiter_height - body_box.height) / 2.0; - let baseline = body_y + body_box.baseline; - let mut draws = Vec::new(); - let mut x = 0.0; - if !left.is_empty() { - append_draws(&mut draws, &left_box, x, 0.0); - x += left_box.width + gap; - } - append_draws(&mut draws, &body_box, x, body_y); - x += body_box.width; - if !right.is_empty() { - x += gap; - append_draws(&mut draws, &right_box, x, 0.0); - x += right_box.width; - } - MathBox { - width: x, - height: delimiter_height, - baseline, - draws, + fn layout_delimited(&self, left: &str, body: &MathNode, right: &str, size: f32) -> MathBox { + let body_box = self.layout_math(body, size); + self.layout_delimited_box(left, body_box, right, size) } -} -fn layout_delimiter(token: &str, height: f32, size: f32) -> MathBox { - if token.is_empty() { - return MathBox { - width: 0.0, - height, - baseline: height * 0.5, - draws: Vec::new(), + fn layout_delimited_box( + &self, + left: &str, + body_box: MathBox, + right: &str, + size: f32, + ) -> MathBox { + let delimiter_height = body_box.height.max(size * 1.25).min(size * 6.0); + let left_box = self.layout_delimiter(left, delimiter_height, size); + let right_box = self.layout_delimiter(right, delimiter_height, size); + let gap = if left.is_empty() && right.is_empty() { + 0.0 + } else { + size * 0.12 }; + let body_y = (delimiter_height - body_box.height) / 2.0; + let baseline = body_y + body_box.baseline; + let mut draws = Vec::new(); + let mut x = 0.0; + if !left.is_empty() { + append_draws(&mut draws, &left_box, x, 0.0); + x += left_box.width + gap; + } + append_draws(&mut draws, &body_box, x, body_y); + x += body_box.width; + if !right.is_empty() { + x += gap; + append_draws(&mut draws, &right_box, x, 0.0); + x += right_box.width; + } + MathBox { + width: x, + height: delimiter_height, + baseline, + draws, + } } - if scalable_delimiter(token) { - let width = delimiter_width(token, height, size); - return MathBox { - width, - height, - baseline: height * 0.5, - draws: vec![Draw::Delimiter { - x: 0.0, - y: 0.0, + + fn layout_delimiter(&self, token: &str, height: f32, size: f32) -> MathBox { + if token.is_empty() { + return MathBox { + width: 0.0, + height, + baseline: height * 0.5, + draws: Vec::new(), + }; + } + if scalable_delimiter(token) { + let width = delimiter_width(token, height, size); + return MathBox { width, height, - token: token.to_string(), - stroke_width: (size * 0.055).max(1.35), - }], - }; + baseline: height * 0.5, + draws: vec![Draw::Delimiter { + x: 0.0, + y: 0.0, + width, + height, + token: token.to_string(), + stroke_width: (size * 0.055).max(1.35), + }], + }; + } + let font_size = height.min(size * 3.2).max(size * 1.15); + self.layout_text(token, font_size) } - let font_size = height.min(size * 3.2).max(size * 1.15); - layout_text(token, font_size) } fn scalable_delimiter(token: &str) -> bool { @@ -1441,135 +1787,138 @@ fn delimiter_width(token: &str, height: f32, size: f32) -> f32 { } } -fn layout_script( - base: &MathNode, - sub: Option<&MathNode>, - sup: Option<&MathNode>, - size: f32, -) -> MathBox { - let base_box = layout_math(base, size); - let script_size = (size * 0.62).max(10.0); - let sup_box = sup.map(|node| layout_math(node, script_size)); - let sub_box = sub.map(|node| layout_math(node, script_size)); - let gap = size * 0.08; - let above = sup_box - .as_ref() - .map(|b| (b.height * 0.72).max(size * 0.15)) - .unwrap_or(0.0); - let below = sub_box - .as_ref() - .map(|b| (b.height * 0.72).max(size * 0.15)) - .unwrap_or(0.0); - let baseline = above + base_box.baseline; - let height = above + base_box.height + below; - let script_width = sup_box - .as_ref() - .map(|b| b.width) - .unwrap_or(0.0) - .max(sub_box.as_ref().map(|b| b.width).unwrap_or(0.0)); - let mut draws = Vec::new(); - append_draws(&mut draws, &base_box, 0.0, above); - if let Some(sup_box) = sup_box.as_ref() { - append_draws( - &mut draws, - sup_box, - base_box.width + gap, - (above - sup_box.height * 0.70).max(0.0), - ); - } - if let Some(sub_box) = sub_box.as_ref() { - append_draws( - &mut draws, - sub_box, - base_box.width + gap, - baseline + size * 0.10, - ); - } - MathBox { - width: base_box.width + gap + script_width, - height, - baseline, - draws, +impl<'m> LayoutCtx<'m> { + fn layout_script( + &self, + base: &MathNode, + sub: Option<&MathNode>, + sup: Option<&MathNode>, + size: f32, + ) -> MathBox { + let base_box = self.layout_math(base, size); + let script_size = (size * 0.62).max(10.0); + let sup_box = sup.map(|node| self.layout_math(node, script_size)); + let sub_box = sub.map(|node| self.layout_math(node, script_size)); + let gap = size * 0.08; + let above = sup_box + .as_ref() + .map(|b| (b.height * 0.72).max(size * 0.15)) + .unwrap_or(0.0); + let below = sub_box + .as_ref() + .map(|b| (b.height * 0.72).max(size * 0.15)) + .unwrap_or(0.0); + let baseline = above + base_box.baseline; + let height = above + base_box.height + below; + let script_width = sup_box + .as_ref() + .map(|b| b.width) + .unwrap_or(0.0) + .max(sub_box.as_ref().map(|b| b.width).unwrap_or(0.0)); + let mut draws = Vec::new(); + append_draws(&mut draws, &base_box, 0.0, above); + if let Some(sup_box) = sup_box.as_ref() { + append_draws( + &mut draws, + sup_box, + base_box.width + gap, + (above - sup_box.height * 0.70).max(0.0), + ); + } + if let Some(sub_box) = sub_box.as_ref() { + append_draws( + &mut draws, + sub_box, + base_box.width + gap, + baseline + size * 0.10, + ); + } + MathBox { + width: base_box.width + gap + script_width, + height, + baseline, + draws, + } } -} -fn layout_lines(lines: &[MathNode], size: f32) -> MathBox { - let rows = lines - .iter() - .map(|line| layout_math(line, size)) - .collect::>(); - stack_rows(&rows, size * 0.38, false) -} + fn layout_lines(&self, lines: &[MathNode], size: f32) -> MathBox { + let rows = lines + .iter() + .map(|line| self.layout_math(line, size)) + .collect::>(); + stack_rows(&rows, size * 0.38, false) + } -fn layout_matrix(rows: &[Vec], left: &str, right: &str, size: f32) -> MathBox { - let cell_size = (size * 0.86).max(12.0); - let laid_rows = rows - .iter() - .map(|row| { - row.iter() - .map(|cell| layout_math(cell, cell_size)) - .collect::>() - }) - .collect::>(); - let cols = laid_rows.iter().map(|row| row.len()).max().unwrap_or(0); - let mut col_widths = vec![0.0; cols]; - for row in &laid_rows { - for (idx, cell) in row.iter().enumerate() { - if cell.width > col_widths[idx] { - col_widths[idx] = cell.width; + fn layout_matrix(&self, rows: &[Vec], left: &str, right: &str, size: f32) -> MathBox { + let cell_size = (size * 0.86).max(12.0); + let laid_rows = rows + .iter() + .map(|row| { + row.iter() + .map(|cell| self.layout_math(cell, cell_size)) + .collect::>() + }) + .collect::>(); + let cols = laid_rows.iter().map(|row| row.len()).max().unwrap_or(0); + let mut col_widths = vec![0.0; cols]; + for row in &laid_rows { + for (idx, cell) in row.iter().enumerate() { + if cell.width > col_widths[idx] { + col_widths[idx] = cell.width; + } } } - } - let col_gap = size * 0.65; - let row_gap = size * 0.28; - let body_width = col_widths.iter().sum::() + col_gap * cols.saturating_sub(1) as f32; - let mut y = 0.0; - let mut draws = Vec::new(); - for row in &laid_rows { - let baseline = row - .iter() - .map(|cell| cell.baseline) - .fold(cell_size * 0.82, f32::max); - let below = row - .iter() - .map(|cell| cell.height - cell.baseline) - .fold(cell_size * 0.33, f32::max); - let mut x = 0.0; - for (idx, cell) in row.iter().enumerate() { - let cell_x = x + (col_widths[idx] - cell.width) / 2.0; - append_draws(&mut draws, cell, cell_x, y + baseline - cell.baseline); - x += col_widths[idx] + col_gap; + let col_gap = size * 0.65; + let row_gap = size * 0.28; + let body_width = col_widths.iter().sum::() + col_gap * cols.saturating_sub(1) as f32; + let mut y = 0.0; + let mut draws = Vec::new(); + for row in &laid_rows { + let baseline = row + .iter() + .map(|cell| cell.baseline) + .fold(cell_size * 0.82, f32::max); + let below = row + .iter() + .map(|cell| cell.height - cell.baseline) + .fold(cell_size * 0.33, f32::max); + let mut x = 0.0; + for (idx, cell) in row.iter().enumerate() { + let cell_x = x + (col_widths[idx] - cell.width) / 2.0; + append_draws(&mut draws, cell, cell_x, y + baseline - cell.baseline); + x += col_widths[idx] + col_gap; + } + y += baseline + below + row_gap; + } + let height = (y - row_gap).max(size * 1.2); + let baseline = height / 2.0 + size * 0.28; + let body = MathBox { + width: body_width, + height, + baseline, + draws, + }; + if left.is_empty() && right.is_empty() { + body + } else { + self.layout_delimited_box(left, body, right, size) } - y += baseline + below + row_gap; } - let height = (y - row_gap).max(size * 1.2); - let baseline = height / 2.0 + size * 0.28; - let body = MathBox { - width: body_width, - height, - baseline, - draws, - }; - if left.is_empty() && right.is_empty() { - body - } else { - layout_delimited_box(left, body, right, size) - } -} -fn layout_cases(rows: &[(MathNode, Option)], size: f32) -> MathBox { - let matrix_rows = rows - .iter() - .map(|(expr, condition)| { - let mut row = vec![expr.clone()]; - if let Some(condition) = condition { - row.push(MathNode::Text("if ".into())); - row.push(condition.clone()); - } - row - }) - .collect::>(); - layout_matrix(&matrix_rows, "{", "", size) + fn layout_cases(&self, rows: &[(MathNode, Option)], size: f32) -> MathBox { + let matrix_rows = rows + .iter() + .map(|(expr, condition)| { + let mut row = vec![expr.clone()]; + if let Some(condition) = condition { + row.push(MathNode::Text("if ".into())); + row.push(condition.clone()); + } + row + }) + .collect::>(); + self.layout_matrix(&matrix_rows, "{", "", size) + } } fn stack_rows(rows: &[MathBox], gap: f32, center: bool) -> MathBox { @@ -1596,6 +1945,19 @@ fn stack_rows(rows: &[MathBox], gap: f32, center: bool) -> MathBox { } } +/// A dot accent: a near-zero-length stroke whose round line cap renders as a +/// filled disc of diameter ≈ the stroke width. +fn dot_draw(cx: f32, cy: f32, size: f32) -> Draw { + let r = (size * 0.06).max(1.4); + Draw::Line { + x1: cx, + y1: cy, + x2: cx + 0.01, + y2: cy, + stroke_width: r * 2.0, + } +} + fn append_draws(draws: &mut Vec, child: &MathBox, dx: f32, dy: f32) { for draw in &child.draws { draws.push(offset_draw(draw, dx, dy)); @@ -1613,7 +1975,19 @@ fn publish_math_box(layout: MathBox) -> MathTextLayout { fn publish_draw(draw: Draw) -> MathLayoutDraw { match draw { - Draw::Text { x, y, size, text } => MathLayoutDraw::Text { x, y, size, text }, + Draw::Text { + x, + y, + size, + text, + bold, + } => MathLayoutDraw::Text { + x, + y, + size, + text, + bold, + }, Draw::Line { x1, y1, @@ -1654,11 +2028,18 @@ fn publish_draw(draw: Draw) -> MathLayoutDraw { fn offset_draw(draw: &Draw, dx: f32, dy: f32) -> Draw { match draw { - Draw::Text { x, y, size, text } => Draw::Text { + Draw::Text { + x, + y, + size, + text, + bold, + } => Draw::Text { x: x + dx, y: y + dy, size: *size, text: text.clone(), + bold: *bold, }, Draw::Line { x1, @@ -1701,12 +2082,19 @@ fn offset_draw(draw: &Draw, dx: f32, dy: f32) -> Draw { fn render_box(layout: &MathBox, dx: f32, dy: f32, out: &mut String) { for draw in &layout.draws { match draw { - Draw::Text { x, y, size, text } => { + Draw::Text { + x, + y, + size, + text, + bold, + } => { if text.is_empty() { continue; } + let weight = if *bold { r#" font-weight="bold""# } else { "" }; out.push_str(&format!( - r#" {}"#, + r#" {}"#, x + dx, y + dy, size, @@ -1923,7 +2311,7 @@ fn escape_xml(s: &str) -> String { .replace('"', """) } -fn base64(bytes: &[u8]) -> String { +pub(crate) fn base64(bytes: &[u8]) -> String { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { @@ -2410,6 +2798,10 @@ fn is_text_like_group_command(name: &str) -> bool { | "mathsf" | "mathtt" | "operatorname" + | "boldsymbol" + | "bm" + | "pmb" + | "mathbfit" ) } @@ -2742,6 +3134,9 @@ fn supported_macro(name: &str) -> bool { matches!( name, "frac" + | "dfrac" + | "tfrac" + | "cfrac" | "sqrt" | "binom" | "begin" @@ -2760,6 +3155,10 @@ fn supported_macro(name: &str) -> bool { | "mathsf" | "mathtt" | "operatorname" + | "boldsymbol" + | "bm" + | "pmb" + | "mathbfit" | "vec" | "bar" | "overline" @@ -3100,6 +3499,9 @@ fn greek_or_symbol(name: &str) -> Option<&'static str> { "rangle" => "⟩", "lvert" => "|", "rvert" => "|", + "mid" => "|", + "vert" => "|", + "Vert" => "‖", "lVert" => "‖", "rVert" => "‖", "lfloor" => "⌊", @@ -3208,4 +3610,111 @@ mod tests { assert!(rendered.contains("ℝⁿ"), "{rendered}"); } + + #[test] + fn markup_scripts_bind_to_last_plain_atom() { + let layout = layout_markup_text("MZ^0_\\mu", 100); + let texts = layout + .draws + .iter() + .filter_map(|draw| match draw { + MathLayoutDraw::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>(); + + assert!(texts.contains(&"M"), "{texts:?}"); + assert!(texts.contains(&"Z"), "{texts:?}"); + assert!(!texts.contains(&"MZ"), "{texts:?}"); + } + + #[test] + fn markup_width_model_leaves_room_for_wide_fields() { + let wide = layout_markup_text(r"W^+_\mu W^{-\mu} MZ^0_\mu", 100); + let narrow = layout_markup_text(r"i^+_\mu i^{-\mu} lZ^0_\mu", 100); + + assert!( + wide.width > narrow.width * 1.35, + "wide glyphs should reserve more horizontal layout room: wide={}, narrow={}", + wide.width, + narrow.width + ); + } + + #[test] + fn layout_reserves_width_from_the_supplied_metrics() { + // A provider reporting a fixed advance per glyph must drive the + // reserved width directly — proving the layout measures the active + // face rather than a baked-in table. This is what keeps PDF `--font` + // output aligned. + struct Fixed(f32); + impl GlyphMetrics for Fixed { + fn advance_em(&self, _ch: char, _bold: bool) -> f32 { + self.0 + } + } + + let wide = layout_markup_text_with("abcd", 100, &Fixed(1.0)); + let narrow = layout_markup_text_with("abcd", 100, &Fixed(0.5)); + + // 4 glyphs × 1.0 em × 28 px base size. + assert!((wide.width - 112.0).abs() < 0.5, "wide={}", wide.width); + assert!( + (wide.width - narrow.width * 2.0).abs() < 0.5, + "halving the advance must halve the width: wide={}, narrow={}", + wide.width, + narrow.width + ); + } + + #[test] + fn accents_render_as_centred_geometry_not_combining_glyphs() { + // `\bar{X}` must draw a horizontal rule over the base and keep the + // base text as a bare "X" — never an "X\u{0304}" combining run, whose + // zero-advance mark the renderer would mis-place at the base's edge + // (font-dependently). This is what makes the accent font-independent. + let layout = layout_markup_text(r"\bar{X}", 100); + let has_rule = layout + .draws + .iter() + .any(|d| matches!(d, MathLayoutDraw::Line { .. })); + assert!(has_rule, "\\bar must draw a rule: {:?}", layout.draws); + for draw in &layout.draws { + if let MathLayoutDraw::Text { text, .. } = draw { + assert!( + !text.chars().any(|c| ('\u{0300}'..='\u{036f}').contains(&c)), + "base text must not carry a combining mark: {text:?}" + ); + } + } + } + + #[test] + fn bold_command_marks_text_runs_bold_and_widens_them() { + let layout = layout_markup_text(r"a\mathbf{b}\boldsymbol{c}", 100); + let bold_texts: Vec<&str> = layout + .draws + .iter() + .filter_map(|draw| match draw { + MathLayoutDraw::Text { + text, bold: true, .. + } => Some(text.as_str()), + _ => None, + }) + .collect(); + // `b` (\mathbf) and `c` (\boldsymbol) are bold; the leading `a` is not. + assert!(bold_texts.contains(&"b"), "{bold_texts:?}"); + assert!(bold_texts.contains(&"c"), "{bold_texts:?}"); + assert!(!bold_texts.contains(&"a"), "{bold_texts:?}"); + + // Bold reserves more width than the same glyph in regular weight. + let regular = layout_markup_text("x", 100); + let bold = layout_markup_text(r"\mathbf{x}", 100); + assert!( + bold.width > regular.width, + "bold should be wider: bold={}, regular={}", + bold.width, + regular.width + ); + } } diff --git a/src/odp.rs b/src/odp.rs index 05d097f..56b1ee6 100644 --- a/src/odp.rs +++ b/src/odp.rs @@ -1553,7 +1553,7 @@ fn render_blocks( &theme.body_font, )); } - Block::Table { headers, rows } => { + Block::Table { headers, rows, .. } => { out.push_str(&render_table(reg, x, y, w, h, headers, rows, theme)); } Block::Columns { left, right } => { @@ -1694,7 +1694,7 @@ fn block_height_emu(b: &Block, w: u32, theme: &Theme, imgs: &Imgs) -> u32 { .sum::() + 120000 } - Block::Table { headers, rows } => table_height_emu(headers, rows, w, theme), + Block::Table { headers, rows, .. } => table_height_emu(headers, rows, w, theme), Block::Columns { left, right } => { let gap: u32 = 280000; let half = w.saturating_sub(gap) / 2; diff --git a/src/odt.rs b/src/odt.rs index fcb3203..231485f 100644 --- a/src/odt.rs +++ b/src/odt.rs @@ -650,7 +650,7 @@ fn render_block(out: &mut String, b: &Block, theme: &Theme, by_src: &HashMap render_table(out, headers, rows, theme), + Block::Table { headers, rows, .. } => render_table(out, headers, rows, theme), Block::Columns { left, right } => { // Documents flow, so collapse columns into back-to-back blocks. render_blocks(out, left, theme, by_src); diff --git a/src/paginate.rs b/src/paginate.rs index bcdc517..93120a8 100644 --- a/src/paginate.rs +++ b/src/paginate.rs @@ -15,6 +15,7 @@ use crate::ir::*; use crate::layout::{Layout, LayoutKind}; +use crate::math; use crate::theme::Theme; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -465,9 +466,14 @@ fn paginate_inner( // Mirror of the code-block branch for GFM tables. The header // row is repeated on each continuation slide so a reader who // lands on page N still sees the column titles. - if matches!(&block, Block::Table { headers, rows } if table_weight(headers, rows, wscale) > budget + 1.0) + if matches!(&block, Block::Table { headers, rows, .. } if table_weight(headers, rows, wscale) > budget + 1.0) { - if let Block::Table { headers, rows } = block { + if let Block::Table { + headers, + rows, + aligns, + } = block + { let chunks = split_table_rows(headers.as_slice(), rows, budget, wscale); for (idx, chunk) in chunks.into_iter().enumerate() { if !current.blocks.is_empty() && idx > 0 { @@ -491,6 +497,7 @@ fn paginate_inner( current.blocks.push(Block::Table { headers: headers.clone(), rows: chunk, + aligns: aligns.clone(), }); weight += chunk_weight; } @@ -513,8 +520,12 @@ fn fit_tables(blocks: Vec, theme: &Theme, mode: TableFit) -> Vec { let mut out = Vec::new(); for block in blocks { match block { - Block::Table { headers, rows } => { - out.extend(fit_table(headers, rows, theme, mode)); + Block::Table { + headers, + rows, + aligns, + } => { + out.extend(fit_table(headers, rows, aligns, theme, mode)); } Block::Columns { left, right } => out.push(Block::Columns { left: fit_tables(left, theme, mode), @@ -612,19 +623,32 @@ fn should_code_two_up(mode: CodeColumns, lines: &[String], theme: &Theme) -> boo fn fit_table( headers: Vec>, rows: Vec>>, + aligns: Vec, theme: &Theme, mode: TableFit, ) -> Vec { if matches!(mode, TableFit::Off) { - return vec![Block::Table { headers, rows }]; + return vec![Block::Table { + headers, + rows, + aligns, + }]; } let cols = table_col_count(&headers, &rows); if cols <= 1 { - return vec![Block::Table { headers, rows }]; + return vec![Block::Table { + headers, + rows, + aligns, + }]; } let max_cols = if theme.portrait { 4 } else { 7 }; if cols <= max_cols { - return vec![Block::Table { headers, rows }]; + return vec![Block::Table { + headers, + rows, + aligns, + }]; } let use_transpose = match mode { @@ -633,9 +657,11 @@ fn fit_table( TableFit::Split | TableFit::Off => false, }; if use_transpose { + // Transposing swaps rows and columns, so the original per-column + // alignment no longer maps — fall back to the default. return vec![transpose_table(headers, rows, cols)]; } - split_table_columns(headers, rows, cols, max_cols) + split_table_columns(headers, rows, aligns, cols, max_cols) } fn table_col_count(headers: &[Vec], rows: &[Vec>]) -> usize { @@ -663,12 +689,14 @@ fn transpose_table(headers: Vec>, rows: Vec>>, cols: usize Block::Table { headers: out_headers, rows: out_rows, + aligns: Vec::new(), } } fn split_table_columns( headers: Vec>, rows: Vec>>, + aligns: Vec, cols: usize, max_cols: usize, ) -> Vec { @@ -680,17 +708,28 @@ fn split_table_columns( let mut col_indices = Vec::with_capacity(end - start + 1); col_indices.push(0); col_indices.extend(start..end); + // Each split keeps the leading key column plus a window of the rest; + // carry the matching per-column alignments. + let chunk_aligns = col_indices + .iter() + .map(|&i| aligns.get(i).copied().unwrap_or(ColumnAlign::Left)) + .collect(); out.push(Block::Table { headers: pick_header_cells(&headers, &col_indices), rows: rows .iter() .map(|row| pick_row_cells(row, &col_indices)) .collect(), + aligns: chunk_aligns, }); start = end; } if out.is_empty() { - out.push(Block::Table { headers, rows }); + out.push(Block::Table { + headers, + rows, + aligns, + }); } out } @@ -762,6 +801,26 @@ fn table_row_weight(cells: &[Vec], cols: usize, wscale: f32) -> f32 { 0.45 + lines * 0.95 } +/// Natural `(width, height)` in px of a generated math SVG data URI, read +/// from its `` tag. Used to weight display equations by how tall they +/// actually render rather than assuming a full-height image. +fn math_image_dims(src: &str) -> Option<(f32, f32)> { + let svg = math::decode_generated_math_svg(src)?; + let tag_end = svg.find('>')?; + let tag = &svg[..tag_end]; + let attr = |key: &str| -> Option { + let needle = format!("{key}=\""); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let end = rest.find('"')?; + rest[..end].parse::().ok() + }; + match (attr("width"), attr("height")) { + (Some(w), Some(h)) if w > 0.0 && h > 0.0 => Some((w, h)), + _ => None, + } +} + fn block_weight(b: &Block, wscale: f32, allow_auto_columns: bool) -> f32 { match b { Block::Paragraph(runs) => { @@ -780,7 +839,7 @@ fn block_weight(b: &Block, wscale: f32, allow_auto_columns: bool) -> f32 { .sum::() + 0.6 } - Block::Table { headers, rows } => table_weight(headers, rows, wscale), + Block::Table { headers, rows, .. } => table_weight(headers, rows, wscale), Block::ColumnBreak => 0.0, Block::Columns { left, right } => { // Two-column layout halves the per-column width, so each side @@ -803,7 +862,28 @@ fn block_weight(b: &Block, wscale: f32, allow_auto_columns: bool) -> f32 { // caption-style paragraph still triggers a split, keeping the // image on its own clean page. If you bump the renderer cap, // bump this value too. - Block::Image { .. } => 13.0, + // + // Generated display-math images are the exception: they scale to the + // content *width*, so their on-slide height is set by their aspect + // ratio, not the 65%-tall photo assumption. A wide one-line equation + // is short and should sit on the same slide as its heading + caption + // instead of being shoved onto a "(cont.)" page. Weight it by + // height/width; the constant maps a square equation (one that would + // fill the content column) to the full image weight. + Block::Image { src, alt, .. } => { + if math::math_image_meta(src, alt).is_some() { + // Equations render small (capped at `math_max_height`), so even + // a tall one leaves room for a heading and caption. Weight by + // aspect but cap well below the photo weight so a single + // equation never forces a "(cont.)" split on its own. + match math_image_dims(src) { + Some((w, h)) if w > 0.0 => ((h / w) * 16.0).clamp(1.0, 8.0), + _ => 2.5, + } + } else { + 13.0 + } + } Block::Footnotes(items) => { // Small text, so each line is ~half the weight of a normal list line. let total: f32 = items @@ -1217,19 +1297,32 @@ fn coalesce_columns(blocks: Vec) -> Vec { if !blocks.iter().any(|b| matches!(b, Block::ColumnBreak)) { return blocks; } - let mut left = Vec::new(); - let mut right = Vec::new(); - let mut break_seen = false; + // Split on every `:::` divider, then drop empty segments. This makes a + // leading, trailing, or duplicate divider — e.g. the natural fence form + // `::: … ::: … :::` — behave like a single divider instead of dumping all + // content into the right column and leaving the left blank. The IR has + // only two columns, so any segments past the second fold into the right. + let mut segments: Vec> = vec![Vec::new()]; for b in blocks { - match b { - Block::ColumnBreak => { - break_seen = true; - } - other if break_seen => right.push(other), - other => left.push(other), + if matches!(b, Block::ColumnBreak) { + segments.push(Vec::new()); + } else { + segments + .last_mut() + .expect("non-empty by construction") + .push(b); + } + } + let mut segments: Vec> = segments.into_iter().filter(|s| !s.is_empty()).collect(); + match segments.len() { + 0 => Vec::new(), + 1 => segments.pop().expect("len checked"), + _ => { + let left = segments.remove(0); + let right = segments.into_iter().flatten().collect(); + vec![Block::Columns { left, right }] } } - vec![Block::Columns { left, right }] } fn total_chars(runs: &[Run]) -> usize { diff --git a/src/parser.rs b/src/parser.rs index a44dec4..212bf61 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -18,7 +18,7 @@ use crate::ir::*; use crate::math::{MathMode, MathOptions, MathSvgOptions}; -use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] @@ -200,15 +200,32 @@ fn extract_image_attrs(line: &str) -> String { out } +/// Expand tab characters in a code line to spaces at 4-column tab stops. +fn expand_code_tabs(line: &str) -> String { + const TAB: usize = 4; + let mut out = String::with_capacity(line.len()); + let mut col = 0usize; + for ch in line.chars() { + if ch == '\t' { + let n = TAB - (col % TAB); + out.extend(std::iter::repeat(' ').take(n)); + col += n; + } else { + out.push(ch); + col += 1; + } + } + out +} + fn parse_width_pct(attr: &str) -> Option { let attr = attr.trim(); let rest = attr.strip_prefix("width=")?.trim(); let n = rest.strip_suffix('%')?.trim().parse::().ok()?; - if (1..=100).contains(&n) { - Some(n as u8) - } else { - None - } + // Clamp out-of-range percentages rather than rejecting them: a rejected + // attribute leaks its literal `{width=…}` text into the slide body (and + // forces a spurious continuation slide). Downstream renderers clamp too. + Some(n.clamp(1, 100) as u8) } struct State<'a> { @@ -240,6 +257,10 @@ struct State<'a> { code_start_line: usize, code_columns: Option, code_buf: String, + /// Accumulates an inline `` block (which can arrive across + /// several HTML events) until its closing tag, at which point it becomes a + /// rasterised image instead of being dropped. + svg_buf: Option, in_blockquote: u32, quote_paragraphs: Vec>, @@ -250,6 +271,7 @@ struct State<'a> { table_headers: Vec>, table_rows: Vec>>, table_row: Vec>, + table_aligns: Vec, cell_runs: Vec, in_image: bool, @@ -324,6 +346,7 @@ impl<'a> State<'a> { code_start_line: 1, code_columns: None, code_buf: String::new(), + svg_buf: None, in_blockquote: 0, quote_paragraphs: Vec::new(), in_table: false, @@ -332,6 +355,7 @@ impl<'a> State<'a> { table_headers: Vec::new(), table_rows: Vec::new(), table_row: Vec::new(), + table_aligns: Vec::new(), cell_runs: Vec::new(), in_image: false, image_src: String::new(), @@ -429,6 +453,30 @@ impl<'a> State<'a> { self.started_real_content = true; } + /// Turn an accumulated inline `` into a rasterisable image + /// block (base64 SVG data URI), routed through the normal image pipeline. + fn push_inline_svg(&mut self, svg: &str) { + let Some(start) = svg.find("") + .map(|i| i + "".len()) + .unwrap_or(svg.len()); + let markup = &svg[start..end]; + self.flush_paragraph(); + let uri = format!( + "data:image/svg+xml;base64,{}", + crate::math::base64(markup.as_bytes()) + ); + self.current.blocks.push(Block::Image { + src: uri, + alt: String::new(), + width_pct: None, + }); + self.started_real_content = true; + } + fn handle(&mut self, event: Event) { match event { Event::Start(tag) => self.start_tag(tag), @@ -446,6 +494,18 @@ impl<'a> State<'a> { self.push_text(&c, true); } Event::Html(c) | Event::InlineHtml(c) => { + // Inline `` arrives as one or more HTML events; + // accumulate it and rasterise on the closing tag rather than + // dropping it (markdown HTML is otherwise ignored). + if self.svg_buf.is_some() || c.contains("") { + let svg = self.svg_buf.take().unwrap_or_default(); + self.push_inline_svg(&svg); + } + return; + } let s = c.trim(); if s == "" { self.flush_paragraph(); @@ -522,8 +582,13 @@ impl<'a> State<'a> { } Tag::BlockQuote => { self.flush_paragraph(); + // Only reset the accumulator when entering the OUTERMOST quote; + // a nested `>>`/`>>>` must keep the outer levels' paragraphs + // (resetting here dropped everything but the deepest level). + if self.in_blockquote == 0 { + self.quote_paragraphs = Vec::new(); + } self.in_blockquote += 1; - self.quote_paragraphs = Vec::new(); } Tag::CodeBlock(kind) => { self.flush_paragraph(); @@ -569,11 +634,20 @@ impl<'a> State<'a> { self.image_src = dest_url.to_string(); self.image_alt.clear(); } - Tag::Table(_) => { + Tag::Table(aligns) => { self.flush_paragraph(); self.in_table = true; self.table_headers.clear(); self.table_rows.clear(); + self.table_aligns = aligns + .iter() + .map(|a| match a { + Alignment::Center => crate::ir::ColumnAlign::Center, + Alignment::Right => crate::ir::ColumnAlign::Right, + // None and Left both render left-aligned. + _ => crate::ir::ColumnAlign::Left, + }) + .collect(); } Tag::FootnoteDefinition(label) => { self.flush_paragraph(); @@ -676,10 +750,14 @@ impl<'a> State<'a> { } else { (fallback_code, self.code_start_line) }; + // Expand tabs to 4-column tab stops: the embedded fonts have no + // tab glyph (it renders as a notdef box) and renderers advance + // by glyph width, so a literal `\t` both tofus and breaks + // indentation. Doing it here fixes every backend at once. let lines: Vec = code .trim_end_matches('\n') .split('\n') - .map(|s| s.to_string()) + .map(expand_code_tabs) .collect(); let line_numbers = lines.len() > 5; self.current.blocks.push(Block::CodeBlock { @@ -742,7 +820,12 @@ impl<'a> State<'a> { self.in_table = false; let headers = std::mem::take(&mut self.table_headers); let rows = std::mem::take(&mut self.table_rows); - self.current.blocks.push(Block::Table { headers, rows }); + let aligns = std::mem::take(&mut self.table_aligns); + self.current.blocks.push(Block::Table { + headers, + rows, + aligns, + }); self.started_real_content = true; } TagEnd::TableHead => { diff --git a/src/pdf.rs b/src/pdf.rs index 6c65f04..6d99140 100644 --- a/src/pdf.rs +++ b/src/pdf.rs @@ -921,6 +921,38 @@ fn font_index(bold: bool, italic: bool, mono: bool) -> usize { /// via `units_per_em`. Codepoints with no glyph in the font contribute the /// .notdef advance (typically a small box), matching what the PDF will /// actually render. +/// Total advance of one wrapped line of runs, in EMU, honouring each run's +/// bold/italic. Used to offset table cells for center/right alignment. +fn runs_width_emu(fonts: &PdfFonts, line: &[Run], size_centipt: u32, base_bold: bool) -> u32 { + let size_pt = size_centipt as f32 / 100.0; + let w: f32 = line + .iter() + .map(|r| { + let idx = font_index(base_bold || r.bold, r.italic, r.code); + text_width_pt(fonts, &r.text, idx, size_pt) + }) + .sum(); + (w * EMU_PER_PT) as u32 +} + +/// Left x (EMU) at which to start a cell line of width `line_w` so it sits +/// left / centre / right within a column, per the GFM alignment. +fn aligned_cell_x( + align: Option, + col_left: u32, + col_w: u32, + pad_x: u32, + line_w: u32, +) -> u32 { + match align { + Some(crate::ir::ColumnAlign::Center) => col_left + col_w.saturating_sub(line_w) / 2, + Some(crate::ir::ColumnAlign::Right) => { + col_left + col_w.saturating_sub(pad_x).saturating_sub(line_w) + } + _ => col_left + pad_x, + } +} + fn text_width_pt(fonts: &PdfFonts, text: &str, font_idx: usize, size_pt: f32) -> f32 { let mut total = 0.0_f32; for c in text.chars() { @@ -1035,6 +1067,15 @@ fn record_glyphs_from_hex(set: &mut std::collections::HashSet, hex: &str) { /// needed by the /ToUnicode CMap builder. Returns `Err` for fonts the /// subsetter can't handle (e.g. TTC files with no face at index 0); /// callers should fall back to the original bytes + an identity remapper. +/// True when the sfnt carries PostScript/CFF outlines rather than TrueType +/// (`glyf`) ones. CFF-flavoured OpenType begins with the `OTTO` magic; plain +/// TrueType uses `0x00010000` or `true`. This decides the PDF font structure: +/// CFF needs a `CIDFontType0` descendant + `FontFile3`, while `glyf` uses +/// `CIDFontType2` + `FontFile2`. The bundled DejaVu faces are all `glyf`. +fn font_has_cff_outlines(bytes: &[u8]) -> bool { + bytes.starts_with(b"OTTO") +} + fn subset_font( ttf: &[u8], used: &std::collections::HashSet, @@ -1507,17 +1548,34 @@ impl PdfWriter { fn write_fonts(&mut self, fonts: &PdfFonts, used: &[std::collections::HashSet]) { for i in 0..fonts.face_count() { let face_metrics = &fonts.metrics[i]; + let is_cff = font_has_cff_outlines(&fonts.bytes[i]); let cidfont_id = self.alloc_id(); let descriptor_id = self.alloc_id(); let fontfile_id = self.alloc_id(); let tounicode_id = self.alloc_id(); - let cidtogid_id = self.alloc_id(); - let (subset, remapper) = match subset_font(&fonts.bytes[i], &used[i]) { - Ok(v) => v, - Err(_) => { - let all: Vec = (0..face_metrics.num_glyphs).collect(); - let identity = subsetter::GlyphRemapper::new_from_glyphs_sorted(&all); - (fonts.bytes[i].clone(), identity) + // CIDToGIDMap is a CIDFontType2-only construct; CFF descendants + // (CIDFontType0) resolve CID→glyph through the font itself, so we + // neither allocate nor emit the stream for them. Allocating it + // only on the glyf path keeps the default (DejaVu) output and + // object numbering byte-for-byte unchanged. + let cidtogid_id = if is_cff { None } else { Some(self.alloc_id()) }; + // glyf faces are subset to the glyphs actually used (dense GIDs + + // a CIDToGIDMap translating original→subset). For CFF/OpenType we + // embed the full program and keep CID == original GID: the + // Identity-H text streams already carry original GIDs and + // CIDFontType0 offers no map to translate them, so subsetting + // would require rewriting every Tj literal. The trade-off is a + // larger embed for custom OTF faces only. + let (subset, remapper): (Vec, Option) = if is_cff { + (fonts.bytes[i].clone(), None) + } else { + match subset_font(&fonts.bytes[i], &used[i]) { + Ok((bytes, remapper)) => (bytes, Some(remapper)), + Err(_) => { + let all: Vec = (0..face_metrics.num_glyphs).collect(); + let identity = subsetter::GlyphRemapper::new_from_glyphs_sorted(&all); + (fonts.bytes[i].clone(), Some(identity)) + } } }; let ttf_bytes: &[u8] = ⊂ @@ -1557,13 +1615,23 @@ impl PdfWriter { } widths.push(']'); self.start_object(cidfont_id); + let cid_subtype = if is_cff { + "CIDFontType0" + } else { + "CIDFontType2" + }; + let cidtogid_entry = match cidtogid_id { + Some(id) => format!(" /CIDToGIDMap {id} 0 R"), + None => String::new(), + }; let cid_body = format!( - "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} \ + "<< /Type /Font /Subtype /{subtype} /BaseFont /{name} \ /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \ - /FontDescriptor {desc} 0 R /CIDToGIDMap {ctgm} 0 R /W {w} >>", + /FontDescriptor {desc} 0 R{ctgm} /W {w} >>", + subtype = cid_subtype, name = ps_name, desc = descriptor_id, - ctgm = cidtogid_id, + ctgm = cidtogid_entry, w = widths, ); self.buf.extend_from_slice(cid_body.as_bytes()); @@ -1591,11 +1659,13 @@ impl PdfWriter { flags |= 64; // Italic } self.start_object(descriptor_id); + // CFF outlines are referenced via FontFile3; TrueType via FontFile2. + let fontfile_key = if is_cff { "FontFile3" } else { "FontFile2" }; let desc_body = format!( "<< /Type /FontDescriptor /FontName /{name} /Flags {flags} \ /FontBBox [{bx0} {by0} {bx1} {by1}] /ItalicAngle {ia} \ /Ascent {asc} /Descent {dsc} /CapHeight {cap} /StemV 80 \ - /FontFile2 {file} 0 R >>", + /{fontfile_key} {file} 0 R >>", name = ps_name, flags = flags, bx0 = bbox.0, @@ -1611,14 +1681,24 @@ impl PdfWriter { self.buf.extend_from_slice(desc_body.as_bytes()); self.end_object(); - // 4) FontFile2 stream — the .ttf bytes, FlateDecode compressed. + // 4) Embedded font program, FlateDecode compressed. TrueType goes + // in a FontFile2 stream carrying /Length1 (the uncompressed + // sfnt size); CFF/OpenType goes in a FontFile3 stream tagged + // /Subtype /OpenType (a complete OpenType program). let compressed = deflate(ttf_bytes); self.start_object(fontfile_id); - let header = format!( - "<< /Length {len} /Length1 {orig} /Filter /FlateDecode >>\nstream\n", - len = compressed.len(), - orig = ttf_bytes.len(), - ); + let header = if is_cff { + format!( + "<< /Length {len} /Subtype /OpenType /Filter /FlateDecode >>\nstream\n", + len = compressed.len(), + ) + } else { + format!( + "<< /Length {len} /Length1 {orig} /Filter /FlateDecode >>\nstream\n", + len = compressed.len(), + orig = ttf_bytes.len(), + ) + }; self.buf.extend_from_slice(header.as_bytes()); self.buf.extend_from_slice(&compressed); self.buf.extend_from_slice(b"\nendstream"); @@ -1648,27 +1728,30 @@ impl PdfWriter { // 6) CIDToGIDMap — binary stream of u16-BE entries, one per // possible CID. `map[orig_gid] = subset_gid`. PDF reads this // to know which glyph in the *subset* font corresponds to the - // CID it found in the Tj literal. - let max_orig = used[i].iter().copied().max().unwrap_or(0); - let map_len = (max_orig as usize + 1) * 2; - let mut map_bytes = vec![0u8; map_len]; - for &orig_gid in &used[i] { - if let Some(new_gid) = remapper.get(orig_gid) { - let off = orig_gid as usize * 2; - map_bytes[off] = (new_gid >> 8) as u8; - map_bytes[off + 1] = (new_gid & 0xFF) as u8; + // CID it found in the Tj literal. Emitted only on the glyf path; + // CFF descendants (CIDFontType0) have no CIDToGIDMap. + if let (Some(cidtogid_id), Some(remapper)) = (cidtogid_id, remapper.as_ref()) { + let max_orig = used[i].iter().copied().max().unwrap_or(0); + let map_len = (max_orig as usize + 1) * 2; + let mut map_bytes = vec![0u8; map_len]; + for &orig_gid in &used[i] { + if let Some(new_gid) = remapper.get(orig_gid) { + let off = orig_gid as usize * 2; + map_bytes[off] = (new_gid >> 8) as u8; + map_bytes[off + 1] = (new_gid & 0xFF) as u8; + } } + let map_compressed = deflate(&map_bytes); + self.start_object(cidtogid_id); + let header = format!( + "<< /Length {len} /Filter /FlateDecode >>\nstream\n", + len = map_compressed.len(), + ); + self.buf.extend_from_slice(header.as_bytes()); + self.buf.extend_from_slice(&map_compressed); + self.buf.extend_from_slice(b"\nendstream"); + self.end_object(); } - let map_compressed = deflate(&map_bytes); - self.start_object(cidtogid_id); - let header = format!( - "<< /Length {len} /Filter /FlateDecode >>\nstream\n", - len = map_compressed.len(), - ); - self.buf.extend_from_slice(header.as_bytes()); - self.buf.extend_from_slice(&map_compressed); - self.buf.extend_from_slice(b"\nendstream"); - self.end_object(); } } @@ -2007,12 +2090,18 @@ impl<'a> SlideRenderer<'a> { size_pt: f32, color_hex: &str, italic: bool, + bold: bool, ) { if text.trim().is_empty() || !x_pt.is_finite() || !baseline_pt.is_finite() { return; } let (r, g, b) = hex_to_rgb_f(color_hex); - let font_idx = if italic { FONT_HELV_OBL } else { FONT_HELV }; + let font_idx = match (italic, bold) { + (false, false) => FONT_HELV, + (true, false) => FONT_HELV_OBL, + (false, true) => FONT_HELV_BOLD, + (true, true) => FONT_HELV_BOLD_OBL, + }; let runs = glyph_hex_runs(self.fonts, text, font_idx, size_pt.max(1.0)); let mut cur_x = x_pt; for (face, hex, adv) in runs { @@ -2043,7 +2132,13 @@ impl<'a> SlideRenderer<'a> { ) { for draw in &layout.draws { match draw { - crate::math::MathLayoutDraw::Text { x, y, size, text } => { + crate::math::MathLayoutDraw::Text { + x, + y, + size, + text, + bold, + } => { let (x_pt, baseline_pt) = self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, *x, *y); self.text_at_baseline_pt( @@ -2053,6 +2148,7 @@ impl<'a> SlideRenderer<'a> { size * scale, color_hex, true, + *bold, ); } crate::math::MathLayoutDraw::Line { @@ -2292,7 +2388,15 @@ impl<'a> SlideRenderer<'a> { _ => { let (x_pt, baseline_pt) = self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, x, y + height * 0.82); - self.text_at_baseline_pt(x_pt, baseline_pt, token, height * scale, color_hex, true); + self.text_at_baseline_pt( + x_pt, + baseline_pt, + token, + height * scale, + color_hex, + true, + false, + ); } } } @@ -2306,6 +2410,36 @@ impl<'a> SlideRenderer<'a> { (pt * 1.25 * EMU_PER_PT) as u32 } + /// Largest title size (≤ `base`, in centipt) at which `text` wraps within + /// `max_w_emu` and the wrapped block fits inside `max_h_emu`. Stops long + /// hero titles / section dividers from overflowing the slide and footer. + fn fit_hero_size(&self, text: &str, base: u32, max_w_emu: u32, max_h_emu: u32) -> u32 { + let font_idx = font_index(true, false, false); + let max_w_pt = self.pt(max_w_emu); + let mut size = base; + for _ in 0..12 { + let pt = size as f32 / 100.0; + let lines = + if max_w_pt <= 0.0 || text_width_pt(self.fonts, text, font_idx, pt) <= max_w_pt { + 1 + } else { + wrap_text_simple(self.fonts, text, font_idx, pt, max_w_pt) + .len() + .max(1) + }; + let block_h = lines as u32 * Self::line_h_emu(size); + if block_h <= max_h_emu || size <= 1400 { + break; + } + // Shrink toward the height target; sqrt because a smaller font both + // shortens each line and fits more words per line. + let ratio = (max_h_emu as f32 / block_h as f32).sqrt(); + let next = ((size as f32 * ratio).floor() as u32).max(1400); + size = if next >= size { size - 100 } else { next }; + } + size + } + /// Wrap a vector of runs into lines for the given width and emit them. /// Returns the y position after the last line. fn paragraph( @@ -2582,15 +2716,23 @@ impl<'a> SlideRenderer<'a> { let margin_x_pt = self.pt(margin_x); let margin_y_pt = self.pt(margin_y); let base_size = 28.0_f32; - let gap = base_size * 0.035; - - let raw_layouts = math_markup_line_layouts(lines); + let gap = base_size * 0.24; + + // Measure against the face math is actually drawn in (FONT_HELV, the + // sans regular slot — which `--font` replaces) so reserved widths stay + // aligned with the rendered glyphs. + let metrics = PdfMathMetrics { + fonts: self.fonts, + font_idx: FONT_HELV, + }; + let raw_layouts = math_markup_line_layouts(lines, &metrics); let layouts = fit_packed_math_markup_line_layouts( lines, raw_layouts, base_size, gap, max_w_pt / max_h_pt, + &metrics, ); let (max_line_w, total_h) = math_markup_metrics(&layouts, base_size, gap); let scale_w = max_w_pt / max_line_w; @@ -2634,11 +2776,18 @@ impl<'a> SlideRenderer<'a> { 600000, &theme.accent.clone(), ); + // Shrink the hero size if needed so a long title fits between + // its top and the footer/subtitle region instead of running + // off the bottom and overprinting the author/date. + let title_top = h / 2 - 1600000; + let title_max_h = (h.saturating_sub(1_400_000)).saturating_sub(title_top); + let hero = + self.fit_hero_size(&slide.title, theme.hero_size, w - 1200000, title_max_h); let title_lines = self.text_line( 800000, - h / 2 - 1600000, + title_top, &slide.title, - theme.hero_size, + hero, &theme.title_color.clone(), true, false, @@ -2648,8 +2797,7 @@ impl<'a> SlideRenderer<'a> { ); // Push subtitle down by the number of extra title lines so // a wrapped 2-line title doesn't overlap the subtitle. - let extra = - (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(theme.hero_size); + let extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero); if let Some(sub) = subtitle { let sub_size = if theme.portrait { 1800 } else { 2400 }; self.text_line( @@ -2697,11 +2845,14 @@ impl<'a> SlideRenderer<'a> { w - 1500000, ); } + let studio_max_h = (h * 80 / 100).saturating_sub(h / 2 - 1000000); + let hero = + self.fit_hero_size(&slide.title, theme.hero_size, w - 1500000, studio_max_h); let title_lines = self.text_line( 900000, h / 2 - 1000000, &slide.title, - theme.hero_size, + hero, &theme.title_color.clone(), false, true, @@ -2709,8 +2860,7 @@ impl<'a> SlideRenderer<'a> { TextAlign::Left, w - 1500000, ); - let extra = - (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(theme.hero_size); + let extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero); if let Some(sub) = subtitle { let sub_size = if theme.portrait { 1700 } else { 2200 }; self.text_line( @@ -2768,11 +2918,13 @@ impl<'a> SlideRenderer<'a> { } let title_x = sidebar + 600000; let title_w = w - title_x - 600000; + let frame_max_h = (h * 82 / 100).saturating_sub(h / 2 - 1100000); + let hero = self.fit_hero_size(&slide.title, theme.hero_size, title_w, frame_max_h); let title_lines = self.text_line( title_x, h / 2 - 1100000, &slide.title, - theme.hero_size, + hero, &theme.title_color.clone(), true, false, @@ -2780,8 +2932,7 @@ impl<'a> SlideRenderer<'a> { TextAlign::Left, title_w, ); - let extra = - (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(theme.hero_size); + let extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero); if let Some(sub) = subtitle { let sub_size = if theme.portrait { 1700 } else { 2200 }; self.text_line( @@ -2806,7 +2957,15 @@ impl<'a> SlideRenderer<'a> { // title naturally grows upward into the block. Pre-compute // the line count so we can place the title's baseline to // keep its first line inside the block regardless. - let hero_pt = theme.hero_size as f32 / 100.0; + // Shrink the title so it can't grow up and out of the top of + // the accent block (which would drop it off the slide). + let hero = self.fit_hero_size( + &slide.title, + theme.hero_size, + w - 2 * pad, + block_h.saturating_sub(pad + 1_400_000), + ); + let hero_pt = hero as f32 / 100.0; let title_lines = { let font_idx = font_index(true, false, false); let max_w_pt = self.pt(w - 2 * pad); @@ -2818,13 +2977,12 @@ impl<'a> SlideRenderer<'a> { .len() } }; - let title_extra = - (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(theme.hero_size); + let title_extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero); self.text_line( pad, block_h - 1400000 - pad - title_extra, &slide.title, - theme.hero_size, + hero, &theme.on_accent.clone(), true, false, @@ -2884,11 +3042,17 @@ impl<'a> SlideRenderer<'a> { 60000, &theme.section_text.clone(), ); + // Shrink to fit so a long section title doesn't overflow the + // bottom edge (where it would be silently clipped/lost). + let sec_top = h / 2 - 200000; + let sec_max_h = (h.saturating_sub(500_000)).saturating_sub(sec_top); + let sec_size = + self.fit_hero_size(&slide.title, theme.hero_size, w - 1600000, sec_max_h); self.text_line( 800000, - h / 2 - 200000, + sec_top, &slide.title, - theme.hero_size, + sec_size, &theme.section_text.clone(), true, false, @@ -3041,8 +3205,29 @@ impl<'a> SlideRenderer<'a> { 720000 }; + // Measure how many lines the title wraps to so a long heading expands + // the title band instead of overprinting the underline rule and body. + let title_max_w = if matches!(layout.kind, LayoutKind::Bold) { + w - 2 * base_margin + } else { + content_w + }; + let title_lines = { + let title_pt = theme.title_size as f32 / 100.0; + let font_idx = font_index(true, false, false); + let max_w_pt = self.pt(title_max_w); + let total_w = text_width_pt(self.fonts, &slide.title, font_idx, title_pt); + if total_w <= max_w_pt || max_w_pt <= 0.0 { + 1 + } else { + wrap_text_simple(self.fonts, &slide.title, font_idx, title_pt, max_w_pt).len() + } + }; + let title_extra = + (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(theme.title_size); + if matches!(layout.kind, LayoutKind::Bold) { - let block_h = title_h + 240000; + let block_h = title_h + 240000 + title_extra; self.rect(0, 0, w, title_y + block_h, &theme.accent.clone()); self.text_line( base_margin, @@ -3072,7 +3257,7 @@ impl<'a> SlideRenderer<'a> { } let underline_y = if matches!(layout.kind, LayoutKind::Clean) { - let y = title_y + title_h + 30000; + let y = title_y + title_h + title_extra + 30000; self.rect( content_x, y + 18000, @@ -3089,7 +3274,7 @@ impl<'a> SlideRenderer<'a> { ); y } else { - title_y + title_h + title_y + title_h + title_extra }; let content_y_start = underline_y @@ -3228,8 +3413,12 @@ impl<'a> SlideRenderer<'a> { y = self.render_quote(paras, x, y, w); y += 80000; } - Block::Table { headers, rows } => { - y = self.render_table(headers, rows, x, y, w); + Block::Table { + headers, + rows, + aligns, + } => { + y = self.render_table(headers, rows, aligns, x, y, w); y += 80000; } Block::Columns { left, right } => { @@ -3303,7 +3492,13 @@ impl<'a> SlideRenderer<'a> { for c in &mut ordered_counters[lvl + 1..] { *c = 0; } - let bullet = if item.ordered { + // Task-list items carry their own ☐/☑ marker in the runs, so the + // normal bullet is suppressed (and its gutter collapsed) to avoid + // drawing both a bullet and a checkbox. + let is_task = item.is_task(); + let bullet = if is_task { + String::new() + } else if item.ordered { format!("{}.", ordered_counters[lvl]) } else { match lvl { @@ -3324,7 +3519,11 @@ impl<'a> SlideRenderer<'a> { bullet_pt, ); let bullet_w_emu = (bullet_w_pt * EMU_PER_PT) as u32; - let gutter_emu = bullet_w_emu.max(180_000) + 120_000; + let gutter_emu = if is_task { + 0 + } else { + bullet_w_emu.max(180_000) + 120_000 + }; self.text_line( bullet_x, y, @@ -3703,6 +3902,7 @@ impl<'a> SlideRenderer<'a> { &mut self, headers: &[Vec], rows: &[Vec>], + aligns: &[crate::ir::ColumnAlign], x: u32, y_start: u32, w: u32, @@ -3931,9 +4131,17 @@ impl<'a> SlideRenderer<'a> { &theme.accent.clone(), ); for (i, line) in header_wrapped[c].iter().enumerate() { + let lw = runs_width_emu(self.fonts, line, header_size, true); + let tx = aligned_cell_x( + aligns.get(c).copied(), + x + col_x[c], + col_widths[c], + pad_x, + lw, + ); self.text_runs_line( line, - x + col_x[c] + pad_x, + tx, y_start + pad_y + i as u32 * line_h_header, header_size, &theme.on_accent.clone(), @@ -3956,9 +4164,17 @@ impl<'a> SlideRenderer<'a> { for c in 0..cols { self.rect(x + col_x[c], ry, col_widths[c], rh, &bg); for (l, line) in row_cells[c].iter().enumerate() { + let lw = runs_width_emu(self.fonts, line, body_size, false); + let tx = aligned_cell_x( + aligns.get(c).copied(), + x + col_x[c], + col_widths[c], + pad_x, + lw, + ); self.text_runs_line( line, - x + col_x[c] + pad_x, + tx, ry + pad_y + l as u32 * line_h_body, body_size, &theme.body_color.clone(), @@ -4023,6 +4239,37 @@ impl<'a> SlideRenderer<'a> { // Text wrapping // --------------------------------------------------------------------------- +/// Hard-break a single token (no spaces) into chunks that each fit `max_w_pt`, +/// for emergency wrapping of long unbreakable tokens (URLs, hashes, CamelCase) +/// that would otherwise run off the slide edge. +fn break_token_to_width( + fonts: &PdfFonts, + tok: &str, + font_idx: usize, + size_pt: f32, + max_w_pt: f32, +) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut cur_w = 0.0; + for ch in tok.chars() { + let cw = text_width_pt(fonts, ch.encode_utf8(&mut [0u8; 4]), font_idx, size_pt); + if !cur.is_empty() && cur_w + cw > max_w_pt { + out.push(std::mem::take(&mut cur)); + cur_w = 0.0; + } + cur.push(ch); + cur_w += cw; + } + if !cur.is_empty() { + out.push(cur); + } + if out.is_empty() { + out.push(String::new()); + } + out +} + fn wrap_text_simple( fonts: &PdfFonts, text: &str, @@ -4057,6 +4304,25 @@ fn wrap_text_simple( for tok in tokens { let tok_w = text_width_pt(fonts, &tok, font_idx, size_pt); let only_space = tok.chars().all(|c| c == ' ' || c == '\t'); + // A token wider than the whole line can't be placed as-is — hard-break + // it so it wraps instead of overflowing the edge. + if !only_space && tok_w > max_w_pt && max_w_pt > 0.0 { + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + current_w = 0.0; + } + let chunks = break_token_to_width(fonts, &tok, font_idx, size_pt, max_w_pt); + let n = chunks.len(); + for (i, chunk) in chunks.into_iter().enumerate() { + if i + 1 < n { + lines.push(chunk); + } else { + current_w = text_width_pt(fonts, &chunk, font_idx, size_pt); + current = chunk; + } + } + continue; + } if !current.is_empty() && current_w + tok_w > max_w_pt && !only_space { lines.push(std::mem::take(&mut current)); current_w = 0.0; @@ -4116,6 +4382,35 @@ fn wrap_runs( for tok in tokens { let tok_w = text_width_pt(fonts, &tok, font_idx, size_pt); let only_space = tok.chars().all(|c| c == ' '); + let mk_run = |text: String| Run { + text, + bold: r.bold, + italic: r.italic, + code: r.code, + strike: r.strike, + link: r.link.clone(), + }; + // Hard-break a token wider than the whole line so it wraps instead + // of overflowing the slide edge. + if !only_space && tok_w > max_w_pt && max_w_pt > 0.0 { + if !lines.last().unwrap().is_empty() { + lines.push(Vec::new()); + cur_width = 0.0; + } + let chunks = break_token_to_width(fonts, &tok, font_idx, size_pt, max_w_pt); + let n = chunks.len(); + for (i, chunk) in chunks.into_iter().enumerate() { + let cw = text_width_pt(fonts, &chunk, font_idx, size_pt); + lines.last_mut().unwrap().push(mk_run(chunk)); + if i + 1 < n { + lines.push(Vec::new()); + cur_width = 0.0; + } else { + cur_width = cw; + } + } + continue; + } if cur_width + tok_w > max_w_pt && !lines.last().unwrap().is_empty() && !only_space { lines.push(Vec::new()); cur_width = 0.0; @@ -4124,15 +4419,7 @@ fn wrap_runs( continue; } } - let last = lines.last_mut().unwrap(); - last.push(Run { - text: tok, - bold: r.bold, - italic: r.italic, - code: r.code, - strike: r.strike, - link: r.link.clone(), - }); + lines.last_mut().unwrap().push(mk_run(tok)); cur_width += tok_w; } } @@ -4150,14 +4437,44 @@ enum TextAlign { Right, } -fn math_markup_line_layouts(lines: &[String]) -> Vec> { +/// Glyph-advance source for the math layout engine backed by the PDF's +/// embedded faces. Measuring each glyph in the face that will actually render +/// it (via [`text_width_pt`], the same fallback selection the writer uses) +/// keeps the reserved layout width equal to the drawn advance even when +/// `--font` swaps DejaVu for a brand face. A non-positive advance (combining +/// marks) defers to the built-in DejaVu table. +struct PdfMathMetrics<'a> { + fonts: &'a PdfFonts, + font_idx: usize, +} + +impl crate::math::GlyphMetrics for PdfMathMetrics<'_> { + fn advance_em(&self, ch: char, bold: bool) -> f32 { + // Bold text is drawn in the bold sans face, which has its own + // advances — measure that face so a bold run reserves the right room. + let font_idx = if bold { FONT_HELV_BOLD } else { self.font_idx }; + let mut buf = [0u8; 4]; + let s = ch.encode_utf8(&mut buf); + let w = text_width_pt(self.fonts, s, font_idx, 1.0); + if w > 0.0 { + w + } else { + crate::math::DejaVuMetrics.advance_em(ch, bold) + } + } +} + +fn math_markup_line_layouts( + lines: &[String], + metrics: &dyn crate::math::GlyphMetrics, +) -> Vec> { lines .iter() .map(|line| { if line.trim().is_empty() { None } else { - Some(crate::math::layout_markup_text(line, 100)) + Some(crate::math::layout_markup_text_with(line, 100, metrics)) } }) .collect() @@ -4166,6 +4483,7 @@ fn math_markup_line_layouts(lines: &[String]) -> Vec Vec> { let mut out = Vec::new(); let mut current = String::new(); @@ -4184,12 +4502,12 @@ fn pack_math_markup_line_layouts( if current.is_empty() { current.push_str(trimmed); - current_layout = Some(crate::math::layout_markup_text(¤t, 100)); + current_layout = Some(crate::math::layout_markup_text_with(¤t, 100, metrics)); continue; } let candidate = format!("{current} {trimmed}"); - let candidate_layout = crate::math::layout_markup_text(&candidate, 100); + let candidate_layout = crate::math::layout_markup_text_with(&candidate, 100, metrics); if candidate_layout.width <= target_width { current = candidate; current_layout = Some(candidate_layout); @@ -4199,7 +4517,7 @@ fn pack_math_markup_line_layouts( } current.clear(); current.push_str(trimmed); - current_layout = Some(crate::math::layout_markup_text(¤t, 100)); + current_layout = Some(crate::math::layout_markup_text_with(¤t, 100, metrics)); } } @@ -4215,6 +4533,7 @@ fn fit_packed_math_markup_line_layouts( base_size: f32, gap: f32, page_ratio: f32, + metrics: &dyn crate::math::GlyphMetrics, ) -> Vec> { let (raw_max_line_w, raw_total_h) = math_markup_metrics(&raw_layouts, base_size, gap); let raw_ratio = raw_max_line_w / raw_total_h.max(1.0); @@ -4230,7 +4549,7 @@ fn fit_packed_math_markup_line_layouts( for _ in 0..9 { let target = (low + high) / 2.0; - let packed = pack_math_markup_line_layouts(lines, target); + let packed = pack_math_markup_line_layouts(lines, target, metrics); let (packed_w, packed_h) = math_markup_metrics(&packed, base_size, gap); let ratio = packed_w / packed_h.max(1.0); let err = (ratio - desired_ratio).abs(); @@ -4374,3 +4693,26 @@ fn author_date(author: Option<&str>, date: Option<&str>) -> Option { Some(s) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cff_outline_detection_picks_the_right_font_program() { + // CFF-flavoured OpenType (the `OTTO` magic) must take the + // CIDFontType0 / FontFile3 path; TrueType-flavoured sfnts the + // CIDFontType2 / FontFile2 path. + assert!(font_has_cff_outlines(b"OTTO\x00\x04\x00\x80")); + assert!(!font_has_cff_outlines(&[0x00, 0x01, 0x00, 0x00])); // TrueType + assert!(!font_has_cff_outlines(b"true")); // legacy TrueType magic + // Every bundled DejaVu face is TrueType (glyf) — the default output + // must stay on the FontFile2 path. + for face in crate::font::FONTS { + assert!( + !font_has_cff_outlines(face), + "bundled faces are expected to be TrueType", + ); + } + } +} diff --git a/src/pptx.rs b/src/pptx.rs index fe70f7a..4439070 100644 --- a/src/pptx.rs +++ b/src/pptx.rs @@ -2248,7 +2248,7 @@ fn render_blocks( )); *id += 1; } - Block::Table { headers, rows } => { + Block::Table { headers, rows, .. } => { shapes.push_str(&render_table(headers, rows, theme, x, y, w, h, id)); } Block::Columns { left, right } => { @@ -2392,7 +2392,7 @@ fn block_height_emu(b: &Block, w: u32, theme: &Theme, imgs: &Imgs) -> u32 { .sum::() + 120000 } - Block::Table { headers, rows } => table_height_emu(headers, rows, w, theme), + Block::Table { headers, rows, .. } => table_height_emu(headers, rows, w, theme), Block::Columns { left, right } => { let gap: u32 = 280000; let half = w.saturating_sub(gap) / 2; @@ -2722,7 +2722,11 @@ fn render_list(items: &[ListItem], theme: &Theme) -> String { let lvl = item.level.min(8) as u32; let mar_l = 228600 + lvl * 285750; let indent = -228600_i32; - let bullet = if item.ordered { + let bullet = if item.is_task() { + // Task items carry their own ☐/☑ marker in the runs, so suppress + // the native bullet to avoid showing both. + r#""#.to_string() + } else if item.ordered { r#""#.to_string() } else { let ch = match lvl { diff --git a/src/render_plan.rs b/src/render_plan.rs index b500329..df22266 100644 --- a/src/render_plan.rs +++ b/src/render_plan.rs @@ -424,7 +424,7 @@ fn block_plan(index: usize, block: &Block, context: WeightContext, path: String) Vec::new(), ) } - Block::Table { headers, rows } => ( + Block::Table { headers, rows, .. } => ( "table", format!("{} column(s), {} row(s)", headers.len(), rows.len()), BlockMetrics { @@ -564,7 +564,7 @@ fn ir_block(block: &Block) -> IrBlock { .collect(), ..IrBlock::empty() }, - Block::Table { headers, rows } => IrBlock { + Block::Table { headers, rows, .. } => IrBlock { kind: "table".to_string(), headers: vec![headers.iter().map(|cell| runs_text(cell)).collect()], rows: rows diff --git a/src/svg.rs b/src/svg.rs index 4f250d6..3f32487 100644 --- a/src/svg.rs +++ b/src/svg.rs @@ -596,7 +596,11 @@ fn render_block( } Ok(yy) } - Block::Table { headers, rows } => render_table(ctx, headers, rows, x, y, width), + Block::Table { + headers, + rows, + aligns, + } => render_table(ctx, headers, rows, aligns, x, y, width), Block::ColumnBreak => Ok(y), Block::Columns { left, right } => { let gap = 22.0; @@ -641,6 +645,9 @@ fn render_list( *counter = 0; } let indent = level as f32 * 19.0; + // Task-list items carry their own ☐/☑ marker in the runs, so suppress + // the bullet and start the text where the bullet would have gone. + let is_task = item.is_task(); let marker = if item.ordered { format!("{}.", counters[level].max(1)) } else { @@ -651,23 +658,27 @@ fn render_list( } else { x + indent }; - let text_x = if ctx.rtl { + let text_x = if is_task { + marker_x + } else if ctx.rtl { marker_x - 20.0 } else { marker_x + 20.0 }; let anchor = if ctx.rtl { "end" } else { "start" }; - write!( - ctx.out, - r##"{}"##, - marker_x, - y, - escape_attr(svg_font_family(&theme.body_font)), - body_size, - theme.accent, - anchor, - escape_xml(&marker) - )?; + if !is_task { + write!( + ctx.out, + r##"{}"##, + marker_x, + y, + escape_attr(svg_font_family(&theme.body_font)), + body_size, + theme.accent, + anchor, + escape_xml(&marker) + )?; + } y = draw_runs_plain( ctx.out, &item.runs, @@ -865,7 +876,7 @@ fn render_full_page_math_markup( let max_w = (w - margin_x * 2.0).max(1.0); let max_h = (h - margin_y * 2.0).max(1.0); let base_size = 28.0_f32; - let gap = base_size * 0.035; + let gap = base_size * 0.24; let raw_layouts = svg_math_line_layouts(lines); let layouts = fit_svg_math_line_layouts(lines, raw_layouts, base_size, gap, max_w / max_h); let (max_line_w, total_h) = svg_math_metrics(&layouts, base_size, gap); @@ -901,13 +912,20 @@ fn draw_svg_math_layout( ) -> Result<()> { for draw in &layout.draws { match draw { - crate::math::MathLayoutDraw::Text { x, y, size, text } => { + crate::math::MathLayoutDraw::Text { + x, + y, + size, + text, + bold, + } => { if text.trim().is_empty() { continue; } + let weight = if *bold { r#" font-weight="bold""# } else { "" }; write!( out, - r##"{}"##, + r##"{}"##, origin_x + x * scale, top_y + y * scale, font, @@ -1097,6 +1115,7 @@ fn render_table( ctx: &mut RenderCtx<'_>, headers: &[Vec], rows: &[Vec>], + aligns: &[crate::ir::ColumnAlign], x: f32, y: f32, width: f32, @@ -1109,6 +1128,15 @@ fn render_table( .max(1); let col_w = width / cols as f32; let row_h = body_size * 1.65; + // (text-anchor, x) for a column's cell text, honouring GFM alignment. + let cell_anchor = |idx: usize| -> (&'static str, f32) { + let left = x + idx as f32 * col_w; + match aligns.get(idx) { + Some(crate::ir::ColumnAlign::Center) => ("middle", left + col_w / 2.0), + Some(crate::ir::ColumnAlign::Right) => ("end", left + col_w - 6.0), + _ => ("start", left + 6.0), + } + }; let mut yy = y; write!( ctx.out, @@ -1116,7 +1144,7 @@ fn render_table( x, yy, width, row_h, theme.accent_soft, theme.divider )?; for (idx, cell) in headers.iter().enumerate() { - let cx = x + idx as f32 * col_w + 6.0; + let (anchor, cx) = cell_anchor(idx); draw_runs_plain( ctx.out, cell, @@ -1130,7 +1158,7 @@ fn render_table( font: &theme.body_font, weight: "700", style: "normal", - anchor: "start", + anchor, }, )?; } @@ -1148,7 +1176,7 @@ fn render_table( x, yy, width, row_h, bg, theme.divider )?; for (idx, cell) in row.iter().enumerate() { - let cx = x + idx as f32 * col_w + 6.0; + let (anchor, cx) = cell_anchor(idx); draw_runs_plain( ctx.out, cell, @@ -1162,7 +1190,7 @@ fn render_table( font: &theme.body_font, weight: "400", style: "normal", - anchor: "start", + anchor, }, )?; } @@ -1286,6 +1314,26 @@ fn wrap_text(text: &str, width: f32, font_size: f32) -> Vec { for raw_line in text.lines() { let mut current = String::new(); for word in raw_line.split_whitespace() { + // Hard-break a word longer than a whole line so it wraps instead of + // overflowing the slide edge. + if word.chars().count() > max_chars { + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + let chars: Vec = word.chars().collect(); + let mut i = 0; + while i < chars.len() { + let end = (i + max_chars).min(chars.len()); + let chunk: String = chars[i..end].iter().collect(); + if end < chars.len() { + lines.push(chunk); + } else { + current = chunk; + } + i = end; + } + continue; + } let next_len = current.chars().count() + if current.is_empty() { 0 } else { 1 } + word.chars().count(); diff --git a/tests/renderers.rs b/tests/renderers.rs index fbded4e..f540001 100644 --- a/tests/renderers.rs +++ b/tests/renderers.rs @@ -290,6 +290,165 @@ fn content_page_count(slides: &[md2any::ir::Slide]) -> usize { .count() } +#[test] +fn nested_blockquotes_keep_all_levels() { + // `>`/`>>`/`>>>` must keep every level, not just the deepest. + let md = "---\n---\n## Q\n> Level one\n>> Level two\n>>> Level three\n"; + let (front, body) = md2any::front_matter::extract(md); + let slides = md2any::parser::parse(&body, &front, "test"); + let paras = slides + .iter() + .flat_map(|s| &s.blocks) + .find_map(|b| match b { + md2any::ir::Block::Quote(paras) => Some(paras.clone()), + _ => None, + }) + .expect("quote present"); + let text: String = paras + .iter() + .flat_map(|p| p.iter().map(|r| r.text.clone())) + .collect(); + assert!(text.contains("Level one"), "outer level dropped: {text:?}"); + assert!(text.contains("Level two"), "{text:?}"); + assert!(text.contains("Level three"), "{text:?}"); +} + +#[test] +fn table_column_alignment_is_captured() { + let md = "---\n---\n## T\n| L | C | R |\n|:--|:-:|--:|\n| a | b | c |\n"; + let (front, body) = md2any::front_matter::extract(md); + let slides = md2any::parser::parse(&body, &front, "test"); + let aligns = slides + .iter() + .flat_map(|s| &s.blocks) + .find_map(|b| match b { + md2any::ir::Block::Table { aligns, .. } => Some(aligns.clone()), + _ => None, + }) + .expect("table present"); + use md2any::ir::ColumnAlign::*; + assert_eq!( + aligns, + vec![Left, Center, Right], + "alignment from delimiter row" + ); +} + +#[test] +fn tabs_in_code_expand_to_spaces() { + let md = "---\n---\n## C\n```c\n\tx;\n```\n"; + let (front, body) = md2any::front_matter::extract(md); + let slides = md2any::parser::parse(&body, &front, "test"); + let line = slides + .iter() + .flat_map(|s| &s.blocks) + .find_map(|b| match b { + md2any::ir::Block::CodeBlock { lines, .. } => lines.first().cloned(), + _ => None, + }) + .expect("code block present"); + assert!(!line.contains('\t'), "tabs should be expanded: {line:?}"); + assert!(line.starts_with(" x"), "tab -> 4 spaces: {line:?}"); +} + +#[test] +fn inline_svg_becomes_an_image_block() { + let md = "---\n---\n## S\n\n"; + let (front, body) = md2any::front_matter::extract(md); + let slides = md2any::parser::parse(&body, &front, "test"); + let has_svg_image = slides.iter().flat_map(|s| &s.blocks).any(|b| { + matches!(b, md2any::ir::Block::Image { src, .. } if src.starts_with("data:image/svg+xml")) + }); + assert!( + has_svg_image, + "inline should become a data-URI image block" + ); +} + +#[test] +fn oversize_image_width_is_clamped_not_leaked() { + // `{width=150%}` must clamp to 100, not fall through and leak the literal + // attribute text into the slide body. + let md = "---\n---\n## Img\n![pic](examples/sample.png){width=150%}\n"; + let (front, body) = md2any::front_matter::extract(md); + let slides = md2any::parser::parse(&body, &front, "test"); + let mut saw_image = false; + for slide in &slides { + for block in &slide.blocks { + match block { + md2any::ir::Block::Image { width_pct, .. } => { + saw_image = true; + assert_eq!(*width_pct, Some(100), "width should clamp to 100"); + } + md2any::ir::Block::Paragraph(runs) => { + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + assert!( + !text.contains("width="), + "literal width attribute leaked into body: {text:?}" + ); + } + _ => {} + } + } + } + assert!(saw_image, "image block should be present"); +} + +#[test] +fn fence_form_columns_split_left_and_right() { + // The fence form `::: … ::: … :::` (leading + trailing markers) must produce + // a two-column block with content on BOTH sides, not dump everything right. + let md = "---\n---\n## Cols\n\n:::\n\nLeft side.\n\n:::\n\nRight side.\n\n:::\n"; + let (front, body) = md2any::front_matter::extract(md); + let theme = md2any::theme::Theme::resolve("light", "16:9", None).unwrap(); + let layout = md2any::layout::Layout::resolve("clean").unwrap(); + let slides = md2any::parser::parse(&body, &front, "test"); + let slides = md2any::paginate::paginate_for_layout_with_options( + slides, + &theme, + &layout, + md2any::paginate::PaginationOptions::default(), + ); + let has_balanced_columns = slides.iter().flat_map(|s| &s.blocks).any(|b| { + matches!(b, md2any::ir::Block::Columns { left, right } if !left.is_empty() && !right.is_empty()) + }); + assert!( + has_balanced_columns, + "fence-form columns should yield a Columns block with non-empty left and right" + ); +} + +#[test] +fn display_math_equation_stays_on_its_heading_slide() { + // A heading + short caption + one display equation must share a single + // slide. Generated math images scale to the content width and render + // short, so they must not carry the full-photo pagination weight that + // would shove the equation onto a "(cont.)" overflow page. + let md = "---\nmath: svg\n---\n\ +## Bayes' Theorem\n\ +How evidence updates belief.\n\n\ +$$ P(A \\mid B) = \\frac{P(B \\mid A)\\,P(A)}{P(B)} $$\n"; + let (front, body) = md2any::front_matter::extract(md); + let theme = md2any::theme::Theme::resolve("light", "16:9", None).unwrap(); + let layout = md2any::layout::Layout::resolve("clean").unwrap(); + let parse_opts = md2any::parser::ParseOptions { + math_mode: md2any::math::MathMode::Svg, + ..Default::default() + }; + let slides = md2any::parser::parse_with_options(&body, &front, "test", parse_opts); + let slides = md2any::paginate::paginate_for_layout_with_options( + slides, + &theme, + &layout, + md2any::paginate::PaginationOptions::default(), + ); + assert_eq!( + content_page_count(&slides), + 1, + "equation should stay with its heading, not split to a (cont.) page" + ); +} + #[test] fn render_plan_json_captures_layout_and_blocks() { let md = "---\ntitle: plan\n---\n# Deck\n## Diagnostics\nParagraph text for diagnostics.\n\n```rust\nfn main() {}\nprintln!(\"hi\");\nprintln!(\"bye\");\nprintln!(\"ok\");\nprintln!(\"done\");\nprintln!(\"end\");\n```\n"; @@ -857,229 +1016,7 @@ $$\n"; } #[test] -fn standard_model_lagrangian_stress_fixture_renders() { - fn count_images(blocks: &[md2any::ir::Block]) -> (usize, usize) { - let mut total = 0; - let mut math = 0; - for block in blocks { - match block { - md2any::ir::Block::Image { src, alt, .. } => { - total += 1; - if md2any::math::math_image_meta(src, alt).is_some() { - math += 1; - } - } - md2any::ir::Block::Columns { left, right } => { - let (left_total, left_math) = count_images(left); - let (right_total, right_math) = count_images(right); - total += left_total + right_total; - math += left_math + right_math; - } - _ => {} - } - } - (total, math) - } - - let deck_path = assets().join("examples/standard-model-lagrangian.md"); - let md = std::fs::read_to_string(&deck_path).unwrap(); - let (front, body) = md2any::front_matter::extract(&md); - let theme = md2any::theme::Theme::resolve("light", "16:9", None).unwrap(); - let layout = md2any::layout::Layout::resolve("clean").unwrap(); - let math_mode = match front.math.as_deref() { - Some("svg") => md2any::math::MathMode::Svg, - Some("source") => md2any::math::MathMode::Source, - _ => md2any::math::MathMode::Unicode, - }; - let math_svg = md2any::math::MathSvgOptions { - scale_percent: (front.math_scale.unwrap_or(1.0) * 100.0).round() as u16, - max_height_px: front.math_max_height.map(|h| h.round() as u16), - block_align: match front.math_block_align.as_deref().unwrap_or("center") { - "left" => md2any::math::MathBlockAlign::Left, - "right" => md2any::math::MathBlockAlign::Right, - _ => md2any::math::MathBlockAlign::Center, - }, - }; - let example_dir = deck_path.parent().unwrap().to_path_buf(); - let slides = md2any::parser::parse_with_options( - &body, - &front, - "Standard Model Lagrangian Stress", - md2any::parser::ParseOptions { - math_mode, - math_svg, - include_base_dir: Some(example_dir.clone()), - ..Default::default() - }, - ); - let slides = md2any::paginate::paginate_for_layout(slides, &theme, &layout); - assert!(slides.len() >= 6, "expected a multi-slide stress deck"); - - let (image_count, math_image_count) = slides - .iter() - .map(|slide| count_images(&slide.blocks)) - .fold((0, 0), |acc, item| (acc.0 + item.0, acc.1 + item.1)); - assert!( - image_count >= 7 && math_image_count >= 6, - "expected one reference image plus several generated math images, got {image_count} total and {math_image_count} math" - ); - - let diagnostics = md2any::math::diagnose(&body); - assert!( - !diagnostics - .iter() - .any(|d| d.kind == "unsupported-math-macro"), - "stress fixture should stay inside the supported command subset: {diagnostics:?}" - ); - assert!( - diagnostics.len() <= 24, - "diagnostics should remain bounded for the stress fixture: {diagnostics:?}" - ); - - let pptx = md2any::pptx::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian Stress", - "tests", - &example_dir, - None, - None, - 0.4, - None, - ) - .unwrap(); - zip_contains(&pptx, &["ppt/presentation.xml", "ppt/media/image1.png"]).unwrap(); - - let odp = md2any::odp::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian Stress", - "tests", - &example_dir, - None, - None, - 0.4, - None, - ) - .unwrap(); - zip_contains(&odp, &["content.xml"]).unwrap(); - assert!(zip_read(&odp, "content.xml").contains(">() - .join("\n"); - assert!(!svg.contains("image failed"), "{svg}"); - assert!(svg.contains("overflow=\"visible\""), "{svg}"); - let y_values = xml_attr_numbers(&svg, "y"); - assert!( - y_values.iter().all(|y| *y <= 640.0), - "stress fixture should not push SVG elements far past the slide viewport: {y_values:?}" - ); - - let png_files = md2any::svg::write_files( - &slides, - &theme, - &layout, - "Standard Model Lagrangian Stress", - "tests", - &example_dir, - None, - None, - md2any::svg::ImageFormat::Png, - ) - .unwrap(); - assert_eq!(png_files.len(), slides.len()); - assert!(png_files - .iter() - .all(|file| file.bytes.starts_with(b"\x89PNG"))); - assert!( - png_files.iter().any(|file| file.bytes.len() > 600_000), - "reference-image slide should paint embedded raster data, not a blank page" - ); -} - -#[test] -fn standard_model_lagrangian_a4_source_image_fits_single_page() { +fn standard_model_lagrangian_a4_uses_selectable_math_layout() { let deck_path = assets().join("examples/standard-model-lagrangian-a4.md"); let md = std::fs::read_to_string(&deck_path).unwrap(); let (front, body) = md2any::front_matter::extract(&md); @@ -1092,288 +1029,6 @@ fn standard_model_lagrangian_a4_source_image_fits_single_page() { let layout = md2any::layout::Layout::resolve(front.layout.as_deref().unwrap_or("clean")).unwrap(); let example_dir = deck_path.parent().unwrap().to_path_buf(); - let slides = md2any::parser::parse_with_options( - &body, - &front, - "Standard Model Lagrangian A4", - md2any::parser::ParseOptions { - include_base_dir: Some(example_dir.clone()), - ..Default::default() - }, - ); - let slides = md2any::paginate::paginate_for_layout(slides, &theme, &layout); - - assert_eq!(slides.len(), 1, "A4 source page should stay on one slide"); - let Some((src, alt, _)) = slides[0].full_page_image() else { - panic!( - "expected image-full slide with exactly one image: {:?}", - slides[0] - ); - }; - assert_eq!(src, "assets/standard-model-lagrangian-a4.png"); - assert_eq!(alt, "Full Standard Model Lagrangian"); - - let pdf = md2any::pdf::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian A4", - "tests", - &example_dir, - None, - None, - None, - 0.4, - None, - false, - md2any::pdf::NotesPageSize::Slide, - md2any::pdf::NotesLayout::Auto, - None, - ) - .unwrap(); - assert_eq!(pdf_media_boxes(&pdf), vec![(595, 842)]); - - let pptx = md2any::pptx::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian A4", - "tests", - &example_dir, - None, - None, - 0.4, - None, - ) - .unwrap(); - zip_contains(&pptx, &["ppt/slides/slide1.xml", "ppt/media/image1.png"]).unwrap(); - let pptx_slide = zip_read(&pptx, "ppt/slides/slide1.xml"); - assert!(pptx_slide.contains(""), "{pptx_slide}"); - assert!(!pptx_slide.contains("PageNum"), "{pptx_slide}"); - - let odp = md2any::odp::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian A4", - "tests", - &example_dir, - None, - None, - 0.4, - None, - ) - .unwrap(); - let content = zip_read(&odp, "content.xml"); - assert!(content.contains(" 400_000, - "source page should paint the embedded equation image, not a blank page" - ); -} - -#[test] -fn standard_model_lagrangian_selectable_a4_stays_text() { - let deck_path = assets().join("examples/standard-model-lagrangian-selectable-a4.md"); - let md = std::fs::read_to_string(&deck_path).unwrap(); - let (front, body) = md2any::front_matter::extract(&md); - let theme = md2any::theme::Theme::resolve( - front.theme.as_deref().unwrap_or("light"), - front.aspect.as_deref().unwrap_or("16:9"), - None, - ) - .unwrap(); - let layout = - md2any::layout::Layout::resolve(front.layout.as_deref().unwrap_or("clean")).unwrap(); - let example_dir = deck_path.parent().unwrap().to_path_buf(); - let slides = md2any::parser::parse_with_options( - &body, - &front, - "Standard Model Lagrangian Selectable A4", - md2any::parser::ParseOptions { - include_base_dir: Some(example_dir.clone()), - ..Default::default() - }, - ); - let slides = md2any::paginate::paginate_for_layout_with_options( - slides, - &theme, - &layout, - md2any::paginate::PaginationOptions { - break_mode: md2any::paginate::BreakMode::Off, - ..Default::default() - }, - ); - - assert_eq!( - slides.len(), - 1, - "selectable source page should stay one slide" - ); - let Some((lines, _lang)) = slides[0].full_page_code() else { - panic!( - "expected text-full slide with exactly one code block: {:?}", - slides[0] - ); - }; - assert!( - lines.len() >= 70, - "expected dense formula lines: {}", - lines.len() - ); - assert!(lines[0].starts_with("L_SM ="), "{:?}", lines.first()); - - let pdf = md2any::pdf::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian Selectable A4", - "tests", - &example_dir, - None, - None, - None, - 0.4, - None, - false, - md2any::pdf::NotesPageSize::Slide, - md2any::pdf::NotesLayout::Auto, - None, - ) - .unwrap(); - assert_eq!(pdf_media_boxes(&pdf), vec![(595, 842)]); - let pdf_text = String::from_utf8_lossy(&pdf); - assert!( - !pdf_text.contains("/Subtype /Image"), - "selectable fixture should not embed an image XObject" - ); - assert!( - pdf_text.contains("/ToUnicode"), - "PDF text should keep copy/paste maps" - ); - let positions = pdf_text_positions(&pdf); - assert!( - positions.len() >= lines.len(), - "expected one or more text positions per formula line, got {} for {} lines", - positions.len(), - lines.len() - ); - - let html = String::from_utf8( - md2any::html::write( - &slides, - &theme, - &layout, - "Standard Model Lagrangian Selectable A4", - "tests", - &example_dir, - None, - None, - ) - .unwrap(), - ) - .unwrap(); - assert!(html.contains("slide-content text-full active"), "{html}"); - assert!(html.contains("full-page-text"), "{html}"); - assert!(!html.contains(" Some((headers, rows)), + md2any::ir::Block::Table { headers, rows, .. } => Some((headers, rows)), _ => None, }) .collect::>(); @@ -2132,7 +1787,7 @@ fn table_fit_auto_transposes_compact_portrait_tables() { .iter() .flat_map(|slide| slide.blocks.iter()) .find_map(|block| match block { - md2any::ir::Block::Table { headers, rows } => Some((headers, rows)), + md2any::ir::Block::Table { headers, rows, .. } => Some((headers, rows)), _ => None, }) .expect("expected transposed table"); @@ -2935,6 +2590,7 @@ fn deck_doctor_reports_accessibility_and_structure_warnings() { Block::Table { headers: Vec::new(), rows: vec![vec![vec![Run::plain("cell")]; 4]; 24], + aligns: Vec::new(), }, Block::List(many_items), ],
"); + for (col, cell) in headers.iter().enumerate() { + write!(out, "", align_attr(col))?; render_runs(out, cell)?; out.push_str("
"); + for (col, cell) in row.iter().enumerate() { + write!(out, "", align_attr(col))?; render_runs(out, cell)?; out.push_str("