Skip to main content

latexml_post/mathml/
linebreaker.rs

1//! MathML line-breaking algorithm.
2//!
3//! Port of `LaTeXML::Post::MathML::Linebreaker` (1053 lines of Perl).
4//! Implements line-breaking for long Presentation MathML expressions
5//! by finding optimal breakpoints and inserting `<mspace linebreak="newline"/>`.
6//!
7//! Strategy (from Perl source):
8//! 1. If top-level has trailing punctuation, remove it to add back later
9//! 2. Find all layouts that fit within a specified width (top-down)
10//!    - Find possible breaks within current node
11//!    - Recursively find possible layouts for children
12//!    - Pattern of breaks depends on tag (sub/superscripts don't break)
13//!    - Each combination scored by width + penalty
14//! 3. Apply the best layout's breaks
15
16use rustc_hash::FxHashSet as HashSet;
17
18use crate::document::NodeData;
19
20/// Penalty constants (matching Perl).
21const NOBREAK: i32 = 99999999;
22const POORBREAK_FACTOR: i32 = 20;
23const BADBREAK_FACTOR: i32 = 100;
24const PENALTY_OK: i32 = 5;
25const PENALTY_LIMIT: i32 = 1000;
26const CONVERSION_FACTOR: i32 = 2;
27
28/// Operators that prefer breaking BEFORE them.
29fn break_before_ops() -> HashSet<&'static str> {
30  ["+", "-", "\u{00B1}", "\u{2212}", "\u{2213}"]
31    .into_iter()
32    .collect()
33}
34
35/// Operators that prefer breaking AFTER them.
36fn break_after_ops() -> HashSet<&'static str> { [","].into_iter().collect() }
37
38/// Relation operators (good breakpoints).
39fn relation_ops() -> HashSet<&'static str> {
40  [
41    "=", "<", ">", "\u{2264}", "\u{2265}", "\u{2260}", "\u{226A}", "\u{2261}", "\u{223C}",
42    "\u{2243}", "\u{224D}", "\u{2248}", "\u{221D}",
43  ]
44  .into_iter()
45  .collect()
46}
47
48/// Fence/delimiter operators (bad breakpoints).
49fn fence_ops() -> HashSet<&'static str> {
50  [
51    "(", ")", "[", "]", "{", "}", "|", "||", "\u{2308}", "\u{2309}", "\u{230A}", "\u{230B}",
52    "\u{27E8}", "\u{27E9}", "\u{27EA}", "\u{27EB}", "\u{27EE}", "\u{27EF}",
53  ]
54  .into_iter()
55  .collect()
56}
57
58/// Separator operators.
59fn separator_ops() -> HashSet<&'static str> { [",", ";", ".", "\u{2063}"].into_iter().collect() }
60
61/// Invisible times → visible times conversion for breakpoints.
62fn convert_ops() -> Vec<(&'static str, &'static str)> {
63  vec![("\u{2062}", "\u{00D7}")] // INVISIBLE TIMES → MULTIPLICATION SIGN
64}
65
66/// A single layout option for a MathML subtree.
67#[derive(Debug, Clone)]
68pub struct Layout {
69  /// Total width in "em-like" units.
70  pub width:     f64,
71  /// Total penalty score.
72  pub penalty:   i32,
73  /// Whether this layout contains line breaks.
74  pub has_break: bool,
75  /// Break positions (indices into children).
76  pub breaks:    Vec<usize>,
77  /// Indentation depth for continuation lines.
78  pub indent:    f64,
79}
80
81impl Layout {
82  fn no_break(width: f64) -> Self {
83    Layout {
84      width,
85      penalty: 0,
86      has_break: false,
87      breaks: vec![],
88      indent: 0.0,
89    }
90  }
91}
92
93/// Line-breaking configuration.
94pub struct Linebreaker {
95  /// Target line width in "em" units.
96  pub target_width: f64,
97}
98
99impl Linebreaker {
100  pub fn new(target_width: f64) -> Self { Linebreaker { target_width } }
101
102  /// Find the best layout for a MathML expression that fits within target width.
103  ///
104  /// Port of `Linebreaker::bestFitToWidth`.
105  pub fn best_fit_to_width(&self, node: &NodeData) -> Layout {
106    let layouts = self.find_layouts(node, 0);
107    // Find the best layout: widest that fits, lowest penalty
108    let mut best = Layout::no_break(self.estimate_width(node));
109    for layout in &layouts {
110      if layout.width <= self.target_width && (!best.has_break || layout.penalty < best.penalty) {
111        best = layout.clone();
112      }
113    }
114    best
115  }
116
117  /// Recursively find possible layouts for a node.
118  ///
119  /// Port of `Linebreaker::findLayouts`.
120  fn find_layouts(&self, node: &NodeData, depth: usize) -> Vec<Layout> {
121    match node {
122      NodeData::Text(s) => {
123        vec![Layout::no_break(estimate_text_width(s))]
124      },
125      NodeData::Element { tag, children, .. } => {
126        // Don't break inside scripts, fractions, roots
127        if tag.starts_with("m:msub")
128          || tag.starts_with("m:msup")
129          || tag == "m:mfrac"
130          || tag == "m:msqrt"
131          || tag == "m:mroot"
132          || tag == "m:munder"
133          || tag == "m:mover"
134          || tag == "m:munderover"
135        {
136          let w: f64 = children.iter().map(|c| self.estimate_width(c)).sum();
137          return vec![Layout::no_break(w)];
138        }
139
140        // For mrow and similar containers, find breakpoints
141        if tag == "m:mrow" || tag == "m:math" {
142          return self.find_mrow_layouts(children, depth);
143        }
144
145        // Default: sum of children, no breaks
146        let w: f64 = children.iter().map(|c| self.estimate_width(c)).sum();
147        vec![Layout::no_break(w)]
148      },
149      NodeData::XmlNode(_) => vec![Layout::no_break(1.0)],
150    }
151  }
152
153  /// Find breakpoint layouts for an mrow's children.
154  fn find_mrow_layouts(&self, children: &[NodeData], _depth: usize) -> Vec<Layout> {
155    let total_width: f64 = children.iter().map(|c| self.estimate_width(c)).sum();
156
157    // No break needed
158    if total_width <= self.target_width {
159      return vec![Layout::no_break(total_width)];
160    }
161
162    let break_before = break_before_ops();
163    let break_after = break_after_ops();
164    let relation = relation_ops();
165
166    // Find potential breakpoints
167    let mut layouts = vec![Layout::no_break(total_width)];
168
169    for (i, child) in children.iter().enumerate() {
170      if let NodeData::Element { tag, children: inner, .. } = child {
171        if tag == "m:mo" {
172          if let Some(NodeData::Text(text)) = inner.first() {
173            let penalty = if relation.contains(text.as_str()) {
174              PENALTY_OK
175            } else if break_before.contains(text.as_str()) {
176              PENALTY_OK * POORBREAK_FACTOR
177            } else if break_after.contains(text.as_str()) {
178              PENALTY_OK * 2
179            } else {
180              PENALTY_OK * BADBREAK_FACTOR
181            };
182
183            // Create a layout with a break at this point
184            let indent = 2.0; // em
185            let width_after = children[i + 1..]
186              .iter()
187              .map(|c| self.estimate_width(c))
188              .sum::<f64>()
189              + indent;
190            let width_before: f64 = children[..i].iter().map(|c| self.estimate_width(c)).sum();
191            let max_line = width_before.max(width_after);
192
193            layouts.push(Layout {
194              width: max_line,
195              penalty,
196              has_break: true,
197              breaks: vec![i],
198              indent,
199            });
200          }
201        }
202      }
203    }
204
205    // Sort by width, then penalty; prune dominated layouts
206    layouts.sort_by(|a, b| {
207      a.width
208        .partial_cmp(&b.width)
209        .unwrap()
210        .then(a.penalty.cmp(&b.penalty))
211    });
212
213    // Prune: remove layouts wider than target with higher penalty than narrower ones
214    let mut pruned = Vec::new();
215    let mut best_penalty = i32::MAX;
216    for layout in layouts {
217      if layout.penalty < best_penalty || layout.width <= self.target_width {
218        best_penalty = best_penalty.min(layout.penalty);
219        pruned.push(layout);
220      }
221    }
222
223    pruned
224  }
225
226  /// Estimate the width of a node in "em-like" units.
227  fn estimate_width(&self, node: &NodeData) -> f64 {
228    match node {
229      NodeData::Text(s) => estimate_text_width(s),
230      NodeData::Element { children, .. } => children
231        .iter()
232        .map(|c| self.estimate_width(c))
233        .sum::<f64>()
234        .max(0.5),
235      NodeData::XmlNode(_) => 1.0,
236    }
237  }
238
239  /// Apply a layout's breaks to a MathML expression.
240  ///
241  /// Port of `Linebreaker::applyLayout`.
242  pub fn apply_layout(&self, node: &NodeData, layout: &Layout) -> NodeData {
243    if !layout.has_break {
244      return node.clone();
245    }
246    // Insert mspace linebreak="newline" at each break position
247    match node {
248      NodeData::Element { tag, attributes, children } => {
249        let mut new_children = Vec::new();
250        for (i, child) in children.iter().enumerate() {
251          new_children.push(child.clone());
252          if layout.breaks.contains(&i) {
253            new_children.push(NodeData::Element {
254              tag:        "m:mspace".to_string(),
255              attributes: Some(rustc_hash::FxHashMap::from_iter([(
256                "linebreak".to_string(),
257                "newline".to_string(),
258              )])),
259              children:   vec![],
260            });
261          }
262        }
263        NodeData::Element {
264          tag:        tag.clone(),
265          attributes: attributes.clone(),
266          children:   new_children,
267        }
268      },
269      _ => node.clone(),
270    }
271  }
272}
273
274/// Estimate text width in em-like units (rough approximation).
275fn estimate_text_width(s: &str) -> f64 { s.chars().count() as f64 * 0.6 }
276
277#[cfg(test)]
278mod tests {
279  use super::*;
280
281  #[test]
282  fn penalty_constants_ordering() {
283    // NOBREAK must dwarf every other penalty (used as "never break here" sentinel).
284    const {
285      assert!(NOBREAK > PENALTY_LIMIT);
286      assert!(PENALTY_LIMIT > BADBREAK_FACTOR);
287      assert!(BADBREAK_FACTOR > POORBREAK_FACTOR);
288      assert!(POORBREAK_FACTOR > PENALTY_OK);
289      assert!(PENALTY_OK > 0);
290    }
291  }
292
293  #[test]
294  fn break_before_ops_contains_plus_minus() {
295    let ops = break_before_ops();
296    assert!(ops.contains("+"));
297    assert!(ops.contains("-"));
298    assert!(ops.contains("\u{00B1}")); // ±
299    assert!(ops.contains("\u{2212}")); // −
300  }
301
302  #[test]
303  fn break_after_ops_contains_comma() {
304    let ops = break_after_ops();
305    assert!(ops.contains(","));
306  }
307
308  #[test]
309  fn relation_ops_contains_common() {
310    let ops = relation_ops();
311    assert!(ops.contains("="));
312    assert!(ops.contains("<"));
313    assert!(ops.contains(">"));
314    assert!(ops.contains("\u{2264}")); // ≤
315    assert!(ops.contains("\u{2265}")); // ≥
316    assert!(ops.contains("\u{2260}")); // ≠
317  }
318
319  #[test]
320  fn fence_ops_contains_parens_brackets_braces() {
321    let ops = fence_ops();
322    for c in ["(", ")", "[", "]", "{", "}"] {
323      assert!(ops.contains(c), "missing {c}");
324    }
325  }
326
327  #[test]
328  fn separator_ops_distinct_from_relation() {
329    let sep = separator_ops();
330    let rel = relation_ops();
331    // Separators and relations should not overlap.
332    for s in &sep {
333      assert!(
334        !rel.contains(s),
335        "{s:?} should not be both separator and relation"
336      );
337    }
338    // Common separators present.
339    assert!(sep.contains(","));
340    assert!(sep.contains(";"));
341  }
342
343  #[test]
344  fn convert_ops_invisible_to_visible_times() {
345    let pairs = convert_ops();
346    assert_eq!(pairs.len(), 1);
347    assert_eq!(
348      pairs[0],
349      ("\u{2062}", "\u{00D7}"),
350      "INVISIBLE TIMES → MULTIPLICATION SIGN"
351    );
352  }
353
354  #[test]
355  fn layout_no_break_has_zero_penalty() {
356    let l = Layout::no_break(5.0);
357    assert_eq!(l.width, 5.0);
358    assert_eq!(l.penalty, 0);
359    assert!(!l.has_break);
360    assert!(l.breaks.is_empty());
361    assert_eq!(l.indent, 0.0);
362  }
363
364  #[test]
365  fn estimate_text_width_proportional_to_length() {
366    // 0.6 em per character (rough).
367    assert!((estimate_text_width("") - 0.0).abs() < 1e-6);
368    assert!((estimate_text_width("a") - 0.6).abs() < 1e-6);
369    assert!((estimate_text_width("abcde") - 3.0).abs() < 1e-6);
370  }
371
372  #[test]
373  fn estimate_text_width_counts_chars_not_bytes() {
374    // Unicode chars count as 1 each, even multi-byte.
375    // "αβγ" is 3 chars = 1.8 em, not 6 bytes.
376    assert!((estimate_text_width("αβγ") - 1.8).abs() < 1e-6);
377  }
378}