\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("");
+ for (col, cell) in headers.iter().enumerate() {
+ write!(out, " ", align_attr(col))?;
render_runs(out, cell)?;
out.push_str(" ");
}
out.push_str(" \n\n");
for row in rows {
out.push_str("");
- for cell in row {
- out.push_str("");
+ for (col, cell) in row.iter().enumerate() {
+ write!(out, " ", align_attr(col))?;
render_runs(out, cell)?;
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{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),
],