Skip to main content

latexml_core/
alignment.rs

1//! # Representation of aligned structures
2//! An "Alignment" is an array/tabular construct as:
3//!  `<tabular><tr><td>...`
4//! or, for math mode
5//!   `<XMArray><XMRow><XMCell>...`
6//! (where initially, each XMCell will contain an XMArg to indicate
7//! individual parsing of each cell's content is desired)
8//!
9//! An Alignment object is a sort of fake Whatsit;
10//! It takes some magic to sneak it into the Digestion stream
11//! (see TeX.pool \lx@begin@alignment), but it needs to be created
12//! BEFORE the contents of the alignment are digested,
13//! since we stuff a lot of information into it
14//! (row, column boxes, borders, spacing, etc...)
15//! But once it has been captured, it should otherwise act
16//! like a Whatsit and be responsible for construction (be_absorbed),
17//! and sizing estimation (computeSize)
18//!
19//! Ultimately, this should be better tied into DefConstructor
20//! because an Alignment currently doesn't know what CS created it (debugging!);
21//! Also, it would better connect the things being constructed, reversion, etc.
22
23// keep in until code is completed.
24pub mod cell;
25mod normalize;
26pub mod template;
27
28use libxml::tree::{Node, NodeType};
29use once_cell::sync::Lazy;
30
31use self::{
32  cell::Cell,
33  normalize::*,
34  template::{Align, Axis, BorderSpec, ColumnSpec, Row, Template, TemplateConfig},
35};
36use crate::{
37  BoxOps,
38  common::{
39    arena::SymHashMap, dimension::Dimension, error::*, numeric_ops::NumericOps, object::Object,
40  },
41  digested::Digested,
42  document::{Document, get_node_qname, with_node_qname},
43  gullet::{self, ExpansionLevel},
44  mouth::Mouth,
45  state::*,
46  stomach::*,
47  token::Catcode,
48  tokens::Tokens,
49};
50
51/// token-locators: source span of an alignment cell (its content's locator).
52/// `tabular`/`tr`/`td` are opened before their content's `box_to_absorb` is set,
53/// so the absorb explicitly stamps each with its cell/row/table span via
54/// `Document::set_current_box_locator`. See docs/performance/SOURCE_PROVENANCE.md §3.1.3.
55#[cfg(feature = "token-locators")]
56fn cell_loc(cell: &Cell) -> Option<crate::common::locator::Locator> {
57  cell
58    .boxes
59    .as_ref()
60    .and_then(|b| b.get_locator())
61    .filter(|l| l.from_line != 0)
62}
63
64/// Union (first `from` → last `to`) of a row's cell spans.
65#[cfg(feature = "token-locators")]
66fn row_span(row: &Row) -> Option<crate::common::locator::Locator> {
67  row
68    .get_columns()
69    .iter()
70    .filter_map(cell_loc)
71    .reduce(|a, b| crate::common::locator::Locator::new_range(a, b).unwrap_or(a))
72}
73use std::{
74  borrow::Cow,
75  collections::VecDeque,
76  fmt::{self, Debug, Display},
77  rc::Rc,
78};
79
80use regex::Regex;
81use rustc_hash::FxHashMap as HashMap;
82
83//DebuggableFeature('alignment', "Debug guessing headers of alignments/tables");
84pub type OpenContainerFn =
85  Rc<dyn Fn(&mut Document, HashMap<String, String>) -> Result<Option<Node>>>;
86pub type CloseContainerFn = Rc<dyn Fn(&mut Document) -> Result<Option<Node>>>;
87pub type OpenRowFn = Rc<dyn Fn(&mut Document, HashMap<String, Stored>) -> Result<()>>;
88pub type CloseRowFn = Rc<dyn Fn(&mut Document) -> Result<Option<Node>>>;
89pub type OpenColumnFn = Rc<dyn Fn(&mut Document, HashMap<String, String>) -> Result<Option<Node>>>;
90pub type CloseColumnFn = Rc<dyn Fn(&mut Document) -> Result<Option<Node>>>;
91
92static SINGLE_PUNCT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*[\.,;]\s*$").unwrap());
93
94pub struct AlignmentConfig {
95  pub template:        Option<Template>,
96  pub open_container:  OpenContainerFn,
97  pub close_container: CloseContainerFn,
98  pub open_row:        OpenRowFn,
99  pub close_row:       CloseRowFn,
100  pub open_column:     OpenColumnFn,
101  pub close_column:    CloseColumnFn,
102  pub properties:      SymHashMap<Stored>,
103  pub xml_attributes:  HashMap<String, String>,
104  pub is_math:         bool,
105}
106
107#[derive(Clone)]
108pub struct Alignment {
109  in_column:         bool,
110  in_row:            bool,
111  in_tabular_head:   bool,
112  is_math:           bool,
113  is_normalized:     bool,
114  /// True for \halign templates
115  pub is_halign:     bool,
116  current_column:    usize,
117  current_row:       Option<usize>,
118  reversion:         Option<Tokens>,
119  content_reversion: Option<Tokens>,
120  rows:              VecDeque<Row>,
121  properties:        SymHashMap<Stored>,
122  xml_attributes:    HashMap<String, String>,
123  template:          Template,
124  open_container:    OpenContainerFn,
125  close_container:   CloseContainerFn,
126  open_row:          OpenRowFn,
127  close_row:         CloseRowFn,
128  open_column:       OpenColumnFn,
129  close_column:      CloseColumnFn,
130  cached_width:      Option<Dimension>,
131  cached_height:     Option<Dimension>,
132  cached_depth:      Option<Dimension>,
133  column_widths:     Vec<Dimension>,
134  row_heights:       Vec<Dimension>,
135  row_depths:        Vec<Dimension>,
136  // Longtable: stored head/foot rows for reinsertion
137  pub head_rows:     Vec<Row>,
138  pub foot_rows:     Vec<Row>,
139}
140impl Alignment {
141  /// Create a new Alignment.
142  /// `config` can contain:
143  ///   - template: an Alignment::Template object
144  ///   - openContainer: creates the container element with given attributes
145  ///   - closeContainer = sub($doc); closes the container
146  ///   - openRow        = sub($doc,%attrib); creates the row element with given attributes
147  ///   - closeRow       = closes the row
148  ///   - openColumn     = sub($doc,%attrib); creates the column element with given attributes
149  ///   - closeColumn    = closes the column
150  ///   - properties     = hashmap containing extra attributes for the container element.
151  ///   - xml_attributes = hashmap containing attributes for the main XML node
152  pub fn new(config: AlignmentConfig) -> Self {
153    let template = config.template.unwrap_or_default();
154    // Perl Alignment.pm: Copy width/height/depth from XML attributes to main properties,
155    // but REMOVE them from XML attributes so they don't appear on the element.
156    let mut xml_attributes = config.xml_attributes;
157    for key in ["width", "height", "depth"] {
158      xml_attributes.remove(key);
159    }
160    Alignment {
161      template,
162      current_row: None,
163      reversion: None,
164      content_reversion: None,
165      cached_width: None,
166      cached_height: None,
167      cached_depth: None,
168      open_container: config.open_container,
169      close_container: config.close_container,
170      open_row: config.open_row,
171      close_row: config.close_row,
172      open_column: config.open_column,
173      close_column: config.close_column,
174      current_column: 0,
175      is_math: config.is_math,
176      in_row: false,
177      in_column: false,
178      in_tabular_head: false,
179      is_normalized: false,
180      is_halign: false,
181      properties: config.properties,
182      xml_attributes,
183      rows: VecDeque::new(),
184      column_widths: Vec::new(),
185      row_heights: Vec::new(),
186      row_depths: Vec::new(),
187      head_rows: Vec::new(),
188      foot_rows: Vec::new(),
189    }
190  }
191
192  pub fn get_template(&self) -> &Template { &self.template }
193
194  pub fn current_row(&self) -> Option<&Row> {
195    match self.current_row {
196      Some(idx) => self.rows.get(idx),
197      None => None,
198    }
199  }
200  pub fn current_row_mut(&mut self) -> Option<&mut Row> {
201    match self.current_row {
202      Some(idx) => self.rows.get_mut(idx),
203      None => None,
204    }
205  }
206
207  pub fn new_row(&mut self) -> Option<&Row> {
208    let row = self.template.clone();
209    self.current_row = Some(self.rows.len());
210    self.rows.push_back(row);
211    self.current_column = 0;
212    self.rows.back()
213  }
214
215  pub fn remove_row(&mut self) -> Option<Row> { self.rows.pop_back() }
216
217  pub fn prepend_rows(&mut self, new_rows: Vec<Row>) {
218    for new_row in new_rows.into_iter().rev() {
219      self.rows.push_front(new_row)
220    }
221  }
222
223  pub fn append_rows(&mut self, new_rows: Vec<Row>) {
224    for new_row in new_rows.into_iter() {
225      self.rows.push_back(new_row)
226    }
227  }
228
229  pub fn rows(&self) -> &VecDeque<Row> { &self.rows }
230  pub fn get_cached_height(&self) -> Option<Dimension> { self.cached_height }
231  pub fn get_cached_depth(&self) -> Option<Dimension> { self.cached_depth }
232  pub fn get_row_heights(&self) -> &[Dimension] { &self.row_heights }
233  pub fn get_column_widths(&self) -> &[Dimension] { &self.column_widths }
234  /// Run normalization (cell sizes, spans, pruning, positioning).
235  /// Perl: $alignment->normalizeAlignment
236  pub fn normalize(&mut self) -> Result<()> { normalize_alignment(self) }
237
238  pub fn add_line(&mut self, border: &str, cols: Vec<usize>) {
239    if let Some(row_idx) = self.current_row {
240      let Some(row) = self.rows.get_mut(row_idx) else {
241        return;
242      };
243      self.current_column = 1;
244      if !cols.is_empty() {
245        // Perl Alignment.pm:128-130 — `$row->column($c)` returns undef
246        // for out-of-range column index and autovivifies a discarded
247        // temp hash, so the assignment silently no-ops. Match that
248        // here: skip indices that don't map to a real column instead
249        // of panicking on `.unwrap()`. Witness: 0708.2784 with a
250        // `\hline`/`\cline`-style line referencing a column past the
251        // tabular's column count.
252        for c in cols {
253          if let Some(colspec) = row.get_column_mut(c) {
254            colspec.border.push_str(border);
255          }
256        }
257      } else {
258        for colspec in row.get_columns_mut() {
259          colspec.border.push_str(border)
260        }
261      }
262    }
263  }
264
265  pub fn next_column(&mut self) -> Result<Option<&mut Cell>> {
266    if self.current_row.is_none() {
267      return Ok(None);
268    }
269    self.current_column += 1;
270    // `current_row` index is Some (guarded above), but it can point past
271    // `self.rows` if that row was never materialised — degrade to "no next
272    // column" instead of panicking, LOUDLY (the sibling extra-& path errors
273    // too; fail-toward-flagging). Witness: 1809.10756.
274    let Some(current_row) = self.rows.get_mut(self.current_row.unwrap()) else {
275      Error!(
276        "unexpected",
277        "alignment_row",
278        "Alignment row index points past the materialised rows; dropping the cell"
279      );
280      return Ok(None);
281    };
282    if current_row.get_column_mut(self.current_column).is_some() {
283      Ok(current_row.get_column_mut(self.current_column))
284    } else {
285      // Perl: Error then add fallback column with align=center
286      Error!("unexpected", "&", "Extra alignment tab '&'");
287      let fallback = Cell {
288        align: Some(Align::Center),
289        ..Cell::default()
290      };
291      current_row.add_column(fallback);
292      Ok(current_row.get_column_mut(self.current_column))
293    }
294  }
295
296  pub fn last_column(&mut self) -> Option<&mut Cell> {
297    if let Some(row_idx) = self.current_row {
298      if let Some(row) = self.rows.get_mut(row_idx) {
299        self.current_column = row.get_columns().len();
300        row.get_column_mut(self.current_column)
301      } else {
302        None
303      }
304    } else {
305      None
306    }
307  }
308
309  pub fn current_column_number(&self) -> usize { self.current_column }
310
311  /// Set a property on the current row (for attributes like backgroundcolor from \rowcolor)
312  pub fn set_row_property(&mut self, key: &str, value: String) {
313    if let Some(row_idx) = self.current_row
314      && let Some(row) = self.rows.get_mut(row_idx)
315    {
316      row.properties.insert(key.to_string(), Stored::from(value));
317    }
318  }
319
320  pub fn current_row_number(&self) -> usize {
321    self.rows.iter().filter(|row| !row.is_pseudo()).count()
322  }
323
324  /// The alignment's `name` XML attribute (e.g. `multline`, `gathered`), set
325  /// via the container bindings. Perl reads `$$alignment{properties}{attributes}
326  /// {name}`; here it lives in `xml_attributes`. Used by amsmath's `\shove*`.
327  pub fn get_name(&self) -> Option<&str> { self.xml_attributes.get("name").map(String::as_str) }
328
329  pub fn current_column(&mut self) -> Option<&mut Cell> {
330    self
331      .current_row
332      .and_then(|cw| self.rows.get_mut(cw)?.get_column_mut(self.current_column))
333  }
334
335  pub fn get_column(&mut self, n: usize) -> Option<&mut Cell> {
336    // TODO: do we need an immutable variant? For now alias the mutable one
337    self.get_column_mut(n)
338  }
339
340  pub fn get_column_mut(&mut self, n: usize) -> Option<&mut Cell> {
341    self
342      .current_row
343      .and_then(|cw| self.rows.get_mut(cw)?.get_column_mut(n))
344  }
345
346  // Ugh... these take boxes; adding before/after columns takes tokens!
347  pub fn add_before_row(&mut self, boxes: Vec<Digested>) {
348    if let Some(cw) = self.current_row
349      && let Some(current_row) = self.rows.get_mut(cw)
350    {
351      current_row.before.extend(boxes);
352    }
353  }
354
355  pub fn add_after_row(&mut self, boxes: Vec<Digested>) {
356    if let Some(cw) = self.current_row
357      && let Some(current_row) = self.rows.get_mut(cw)
358    {
359      current_row.after.extend(boxes);
360    }
361  }
362
363  pub fn omit_column(&mut self) {
364    if let Some(column) = self.current_column() {
365      column.omitted = true;
366    }
367  }
368
369  pub fn omit_next_column(&mut self) {
370    if let Some(cw) = self.current_row
371      && let Some(row) = self.rows.get_mut(cw)
372      && let Some(column) = row.get_column_mut(self.current_column + 1)
373    {
374      column.omitted = true;
375    }
376  }
377
378  pub fn get_column_before(&mut self) -> Tokens {
379    if let Some(column) = self.current_column() {
380      if !column.omitted {
381        Tokens!(
382          T_CS!("\\lx@alignment@column@before"),
383          column.before.clone().unwrap_or_default().unlist()
384        )
385      } else {
386        Tokens!()
387      }
388    } else {
389      Tokens!()
390    }
391  }
392
393  pub fn get_column_after(&mut self) -> Tokens {
394    if let Some(column) = self.current_column() {
395      if !column.omitted {
396        // Possible \lx@column@trimright ??? (if LaTeX style???)
397        Tokens!(
398          column.after.clone().unwrap_or_default().unlist(),
399          T_CS!("\\lx@alignment@column@after")
400        )
401      } else {
402        Tokens!()
403      }
404    } else {
405      Tokens!()
406    }
407  }
408
409  pub fn revert(&self) -> Result<Tokens> { Ok(self.reversion.clone().unwrap_or_default()) }
410
411  pub fn set_reversion(&mut self, rev: Tokens) { self.reversion = Some(rev); }
412  pub fn set_content_reversion(&mut self, rev: Tokens) { self.content_reversion = Some(rev); }
413
414  //%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
415  // Support for building an alignment's Rows & Columns
416  pub fn is_in_row(&self) -> bool { self.in_row }
417  pub fn is_in_column(&self) -> bool { self.in_column }
418  pub fn start_row(&mut self, pseudorow: bool) -> Result<()> {
419    self.new_row();
420    bgroup(); // Grouping around ROW!
421    if pseudorow {
422      self.current_row_mut().unwrap().set_pseudo()
423    } else {
424      // Store row number before digest — row hooks may need it (e.g. \rowcolors)
425      // and cannot re-borrow the alignment which is already mutably borrowed.
426      let row_num = self.current_row_number();
427      assign_value("alignmentRowNumber", row_num as i32, None);
428      let row_before = digest(T_CS!("\\lx@alignment@row@before"))?;
429      push_box_list(row_before);
430    }
431    self.in_row = true;
432    assign_value("alignmentStartColumn", 0, None); // ???
433    Ok(())
434  }
435
436  pub fn end_row(&mut self) -> Result<()> {
437    if self.in_row {
438      if self.in_column {
439        self.end_column()?;
440      }
441      egroup()?; // Grouping around ROW!
442      self.in_row = false;
443    }
444    Ok(()) //  Digest(T_CS('\lx@alignment@row@after'));
445  }
446
447  pub fn start_column(&mut self, pseudorow: bool) -> Result<()> {
448    if !self.in_row {
449      self.start_row(pseudorow)?;
450    } else if pseudorow {
451      self.current_row_mut().unwrap().set_pseudo();
452    }
453    bgroup(); // Grouping around CELL!
454    // Note: a VERY round-about way of tracking the column spanning!
455    assign_value("alignmentStartColumn", self.current_column_number(), None);
456    // Propagate `?` so a TooManyErrors Fatal (e.g. from a runaway
457    // `&` storm in a malformed alignment, paper 1112.6246) actually
458    // aborts rather than getting silently swallowed by `let _ =`.
459    let _colspec = self.next_column()?;
460    set_align_group_count(1000000);
461    self.in_column = true;
462    Ok(())
463  }
464
465  pub fn end_column(&mut self) -> Result<()> {
466    if self.in_column {
467      egroup()?; // Grouping around CELL!
468      self.in_column = false;
469    }
470    Ok(())
471  }
472
473  pub fn set_in_tabular_head(&mut self) { self.in_tabular_head = true; }
474  pub fn unset_in_tabular_head(&mut self) { self.in_tabular_head = false; }
475  pub fn is_in_tabular_head(&self) -> bool { self.in_tabular_head }
476
477  pub fn get_properties_mut(&mut self) -> &mut SymHashMap<Stored> { &mut self.properties }
478  pub fn get_xml_attributes_mut(&mut self) -> &mut HashMap<String, String> {
479    &mut self.xml_attributes
480  }
481}
482
483//======================================================================
484// Constructing the XML for the alignment.
485
486impl Object for Alignment {}
487impl BoxOps for Alignment {
488  fn get_properties(&self) -> &SymHashMap<Stored> { &self.properties }
489  fn with_properties<R, FnR>(&self, caller: FnR) -> R
490  where FnR: FnOnce(&SymHashMap<Stored>) -> R {
491    caller(&self.properties)
492  }
493  fn get_property(&self, key: &str) -> Option<Cow<'_, Stored>> {
494    self.properties.get(key).map(Cow::Borrowed)
495  }
496  fn get_properties_mut(&mut self) -> &mut SymHashMap<Stored> { &mut self.properties }
497
498  fn compute_size(
499    &self,
500    _options: SymHashMap<Stored>,
501  ) -> Result<(Dimension, Dimension, Dimension)> {
502    Ok((
503      Dimension::default(),
504      Dimension::default(),
505      Dimension::default(),
506    ))
507  }
508  fn get_font(&self) -> Result<Option<Rc<crate::common::font::Font>>> { Ok(None) }
509  fn get_string(&self) -> Result<Cow<'_, str>> { Ok(Cow::Borrowed("")) }
510
511  fn compute_size_and_cache(
512    &mut self,
513    _options: SymHashMap<Stored>,
514  ) -> Result<(Dimension, Dimension, Dimension)> {
515    normalize_alignment(self)?;
516    let w = self.cached_width.unwrap();
517    let h = self.cached_height.unwrap();
518    let d = self.cached_depth.unwrap();
519    // Store in properties so get_size()'s has_property("cached_width") check works
520    self.properties.insert("cached_width", Stored::Dimension(w));
521    self
522      .properties
523      .insert("cached_height", Stored::Dimension(h));
524    self.properties.insert("cached_depth", Stored::Dimension(d));
525    Ok((w, h, d))
526  }
527
528  fn be_absorbed(&self, _document: &mut Document) -> Result<Vec<Node>> {
529    // Alignments must be absorbed via `be_absorbed_mut` because
530    // `normalize_alignment` mutates the carrier (rearranging rows,
531    // applying border specs, etc). The `Digested::be_absorbed` dispatch
532    // for `DigestedData::Alignment` correctly calls `be_absorbed_mut`;
533    // this immutable path should never be reached in practice. Surface
534    // a meaningful error rather than a bare `todo!()` panic if an
535    // unexpected caller lands here.
536    fatal!(
537      Internal,
538      Misdefined,
539      "Alignment::be_absorbed called — use be_absorbed_mut instead \
540       (alignment rearrangement requires &mut self)"
541    );
542  }
543  fn be_absorbed_mut(&mut self, document: &mut Document) -> Result<Vec<Node>> {
544    let ismath = self.is_math;
545    normalize_alignment(self)?;
546    // token-locators: the whole table's span (union of all cell spans), stamped
547    // on the `tabular` element below. Computed before the mutable `rows` borrow.
548    #[cfg(feature = "token-locators")]
549    let table_span = self
550      .rows
551      .iter()
552      .filter_map(row_span)
553      .reduce(|a, b| crate::common::locator::Locator::new_range(a, b).unwrap_or(a));
554    let rows = &mut self.rows;
555    if rows.is_empty() {
556      return Ok(Vec::new());
557    }
558
559    // Guard via the absorb limit to avoid infinite loops (Perl L478-483)
560    let absorb_limit = lookup_int("absorb_limit");
561    if absorb_limit > 0 {
562      let mut absorb_count = lookup_int("absorb_count");
563      absorb_count += 1;
564      assign_value("absorb_count", absorb_count, Some(Scope::Global));
565      if absorb_count > absorb_limit {
566        fatal!(
567          Timeout,
568          Convert,
569          s!(
570            "Whatsit absorb limit of {} exceeded, infinite loop?",
571            absorb_limit
572          )
573        );
574      }
575    }
576
577    // We _should_ attach boxes to the alignment and rows,
578    // but (ATM) we"ve only got sensible boxes for the cells.
579    let mut attrs = HashMap::default();
580    std::mem::swap(&mut attrs, &mut self.xml_attributes);
581    // Perl Alignment.pm L311-316: pass dimension data to openContainer callback
582    // Serialized as px-value strings for callbacks that need positioning (tikz matrices).
583    if let Some(w) = self.cached_width {
584      attrs.insert("cwidth".to_string(), format!("{}", w.px_value(None)));
585    }
586    if let Some(h) = self.cached_height {
587      attrs.insert("cheight".to_string(), format!("{}", h.px_value(None)));
588    }
589    if let Some(d) = self.cached_depth {
590      attrs.insert("cdepth".to_string(), format!("{}", d.px_value(None)));
591    }
592    let open_container_fn = &self.open_container;
593    // token-locators: stamp the `tabular` with the table span before it opens.
594    #[cfg(feature = "token-locators")]
595    document.set_current_box_locator(table_span);
596    open_container_fn(document, attrs)?;
597
598    for row in rows {
599      // token-locators: stamp this `tr` with the row's span before it opens.
600      #[cfg(feature = "token-locators")]
601      document.set_current_box_locator(row_span(row));
602      let vpad_opt = row.get_padding().copied();
603      // Perl Alignment.pm L319-324: pass position/size to openRow callback
604      let mut open_row_attrs = HashMap::default();
605      for (k, v) in &row.properties {
606        open_row_attrs.insert(k.clone(), v.clone());
607      }
608      if let Some(x) = row.x {
609        open_row_attrs.insert("x".to_string(), Stored::Dimension(x));
610      }
611      if let Some(y) = row.y {
612        open_row_attrs.insert("y".to_string(), Stored::Dimension(y));
613      }
614      if let Some(w) = row.cached_width {
615        open_row_attrs.insert("cwidth".to_string(), Stored::Dimension(w));
616      }
617      if let Some(h) = row.cached_height {
618        open_row_attrs.insert("cheight".to_string(), Stored::Dimension(h));
619      }
620      if let Some(d) = row.cached_depth {
621        open_row_attrs.insert("cdepth".to_string(), Stored::Dimension(d));
622      }
623      let open_row_fn = &self.open_row;
624      open_row_fn(document, open_row_attrs)?;
625      for before in row.before.iter() {
626        document.absorb(before, None)?;
627      }
628      for cell in row.get_columns_mut().iter_mut() {
629        if cell.skipped {
630          continue;
631        }
632        // Normalize the border attribute
633        // Perl: join(' ', sort(map { split(/ */, $_) } $$cell{border}));
634        //       $border =~ s/(.) \1/$1$1/g;
635        let mut border_chars: Vec<char> =
636          cell.border.chars().filter(|c| !c.is_whitespace()).collect();
637        border_chars.sort_unstable();
638        let mut border = String::new();
639        for (idx, &c) in border_chars.iter().enumerate() {
640          border.push(c);
641          // Space between different consecutive chars, no space between same chars
642          if idx + 1 < border_chars.len() && border_chars[idx + 1] != c {
643            border.push(' ');
644          }
645        }
646        let open_column_fn = &self.open_column;
647        let mut cell_attrs = HashMap::default();
648        // Perl Alignment.pm L358-359: pass position/size to openColumn callback
649        if let Some(x) = cell.x {
650          cell_attrs.insert("x".to_string(), format!("{}", x.px_value(None)));
651        }
652        if let Some(y) = cell.y {
653          cell_attrs.insert("y".to_string(), format!("{}", y.px_value(None)));
654        }
655        if let Some(w) = cell.cached_width {
656          cell_attrs.insert("cwidth".to_string(), format!("{}", w.px_value(None)));
657        }
658        if let Some(h) = cell.cached_height {
659          cell_attrs.insert("cheight".to_string(), format!("{}", h.px_value(None)));
660        }
661        if let Some(d) = cell.cached_depth {
662          cell_attrs.insert("cdepth".to_string(), format!("{}", d.px_value(None)));
663        }
664        // Perl: always passes align attribute (Alignment.pm L350).
665        // A paragraph column (p/m/b/tabularx-X/tabulary — all `Align::Justify`)
666        // gets `align="left"` on the `<td>` in Perl, regardless of any `>{}`
667        // alignment prefix (the prefix's `\centering`/`\raggedleft` goes on the
668        // INNER `<inline-block>`/`<p>`, not the cell). Map the Justify marker
669        // (kept for `is_pcol` detection in `\lx@alignment@multicolumn`) to the
670        // Perl-faithful `"left"` here on the cell attribute only (cluster-B Kind-B).
671        if let Some(ref align) = cell.align {
672          let td_align = if *align == Align::Justify {
673            "left".to_string()
674          } else {
675            align.name()
676          };
677          cell_attrs.insert(String::from("align"), td_align);
678        }
679        if let Some(ref vattach) = cell.vattach {
680          cell_attrs.insert(String::from("vattach"), vattach.clone());
681        }
682        if let Some(w) = cell.width {
683          cell_attrs.insert(String::from("width"), w.to_attribute());
684        }
685        if let Some(vpad) = vpad_opt {
686          cell_attrs.insert(String::from("cssstyle"), s!("padding-bottom: {vpad}"));
687        }
688        // colortbl: backgroundcolor from \columncolor/\cellcolor
689        if let Some(ref bg) = cell.backgroundcolor {
690          cell_attrs.insert(String::from("backgroundcolor"), bg.clone());
691        }
692        // Perl: colspan/rowspan attributes for spanning cells
693        if cell.colspan.unwrap_or(1) != 1 {
694          cell_attrs.insert(String::from("colspan"), cell.colspan.unwrap().to_string());
695        }
696        if cell.rowspan.unwrap_or(1) != 1 {
697          cell_attrs.insert(String::from("rowspan"), cell.rowspan.unwrap().to_string());
698        }
699        if !border.is_empty() {
700          cell_attrs.insert(String::from("border"), border);
701        }
702        if cell.thead_in_column || cell.thead_in_row {
703          let mut thead = String::new();
704          if cell.thead_in_column {
705            thead.push_str(Axis::Column.name());
706            if cell.thead_in_row {
707              thead.push(' ');
708            }
709          }
710          if cell.thead_in_row {
711            thead.push_str(Axis::Row.name());
712          }
713          if !thead.is_empty() {
714            cell_attrs.insert(String::from("thead"), thead);
715          }
716        }
717        // Perl Alignment.pm L332-347: ltx_nopad_l/ltx_nopad_r CSS classes
718        // Based on lspaces/rspaces width relative to 0.2em threshold
719        let mut classes: Vec<String> = Vec::new();
720        let empty = cell.empty;
721        // Perl: $$cell{boxes} — truthy when boxes field is defined (even if empty List)
722        let has_boxes = cell.boxes.is_some();
723        let mut pre_absorb: Option<Digested> = None;
724        let mut post_absorb: Option<Digested> = None;
725        if !ismath {
726          // 0.2em ≈ 131072 scaled points; 1.5em ≈ 983040 scaled points (at 10pt font)
727          let threshold_02em: i64 = 131072;
728          let threshold_15em: i64 = 983040;
729          // Perl: $lpad = ($$cell{lspaces} ? $$cell{lspaces}->getWidth->valueOf : 0)
730          // Note: In Perl, lspaces is populated from \lx@intercol (isSpace, width=tabcolsep)
731          // during cell content extraction. Rust doesn't populate lspaces, so we approximate:
732          // - If template has \lx@intercol → there IS intercolumn padding (assume 0.2em)
733          // - Else if template has \hfil/\hfill → centering fill, treat as padding (prevents
734          //   incorrect ltx_nopad_l for regular centered/right-aligned columns)
735          // - Else → no padding (assume 0, enables ltx_nopad_l)
736          // Perl L338-339: lpad/rpad from extracted lspaces/rspaces width.
737          // When lspaces/rspaces are populated (from cell extraction), use their
738          // actual width. When None, use template heuristic as fallback.
739          // Perl L338-339: lpad/rpad from lspaces/rspaces width.
740          // In Perl, lspaces is populated from the left-scan of digested cell boxes.
741          // The left-scan encounters template-injected boxes (vrules, intercol spaces,
742          // fills) and extracts spacing. When lspaces is undef, Perl returns 0.
743          // In Rust, lspaces is not always populated by extraction (extraction stores
744          // None for empty lspaces). We use intercol_reachable_in_before to distinguish:
745          // - Regular columns (|l|): \vrule then \lx@intercol → reachable → threshold
746          // - @{text} columns: text then \lx@intercol → NOT reachable → 0
747          //
748          // KNOWN LIMITATION: this fallback over-reports padding for some
749          // numprint cells (`\lx@intercol\nprt@begin\ignorespaces`-style
750          // before): Perl's extracted lspaces would be undef there → lpad=0 →
751          // ltx_nopad_l added; our heuristic returns threshold_02em → no nopad_l.
752          // Naively removing the heuristic regresses 21 other tabular tests
753          // because Rust's extraction doesn't always populate lspaces for
754          // cases where Perl's would (`|l|`, `|c|`, `|r|`, p/m/X, etc.). The
755          // proper fix is to make lspaces extraction reliable to match Perl's
756          // left-scan; until then, this heuristic is load-bearing.
757          let lpad = cell
758            .lspaces
759            .as_ref()
760            .and_then(|ls| ls.get_width(None).ok().flatten())
761            .map(|rv| rv.value_of())
762            .unwrap_or_else(|| {
763              if intercol_reachable_in_before(&cell.before) || template_has_fill(&cell.before) {
764                threshold_02em
765              } else {
766                0
767              }
768            });
769          let rpad = cell
770            .rspaces
771            .as_ref()
772            .and_then(|rs| rs.get_width(None).ok().flatten())
773            .map(|rv| rv.value_of())
774            .unwrap_or_else(|| {
775              // Perl: rpad from rspaces. When not extracted, use template.
776              // Only the after tokens determine right padding.
777              if template_has_intercol(&cell.after) {
778                threshold_02em
779              } else {
780                0
781              }
782            });
783          // Perl L340-341: ltx_nopad_l, unless math mode (Perl: `unless $ismath`)
784          if !ismath && (!empty || has_boxes) && lpad < threshold_02em {
785            classes.push("ltx_nopad_l".to_string());
786          } else if lpad < threshold_15em {
787            // In math mode, absorb named spacing (like \quad) as XMHint content
788            // even when below the 1.5em threshold. This preserves XMHint for
789            // the math parser to convert to lpadding.
790            if ismath && cell.lspaces.is_some() {
791              pre_absorb = cell.lspaces.take();
792            }
793          } else {
794            pre_absorb = cell.lspaces.take();
795          }
796          // Perl L344-345: ltx_nopad_r, unless math mode (Perl: `unless $ismath`)
797          if !ismath && (!empty || has_boxes) && rpad < threshold_02em {
798            classes.push("ltx_nopad_r".to_string());
799          } else if rpad < threshold_15em {
800            // do nothing — use CSS default padding
801          } else {
802            post_absorb = cell.rspaces.take();
803          }
804        }
805        if let Some(ref cell_class) = cell.class {
806          classes.insert(0, cell_class.clone());
807        }
808        let class_str: String = classes
809          .into_iter()
810          .filter(|s| !s.is_empty())
811          .collect::<Vec<_>>()
812          .join(" ");
813        if !class_str.is_empty() {
814          cell_attrs.insert(String::from("class"), class_str);
815        }
816        //       # Which properties do we expose to the constructor?
817        //       x      => $$cell{x}, y => $$cell{y},
818        //       cached_width => $$cell{cached_width}, cached_height => $$cell{cached_height},
819        // cached_depth => $$cell{cached_depth})
820        // token-locators: stamp this `td` with the cell's content span before it
821        // opens (its content's box_to_absorb is set just below, after the open).
822        #[cfg(feature = "token-locators")]
823        document.set_current_box_locator(cell_loc(cell));
824        cell.cell = open_column_fn(document, cell_attrs)?;
825        // Perl L362: absorb cell content only if !skippable (not just !empty)
826        if !cell.skippable {
827          let box_ref = cell.boxes.as_ref().unwrap();
828          // local $LaTeXML::BOX
829          document.set_box_to_absorb(Some(box_ref.clone()));
830          // Perl wraps cell content in XMArg for math alignments, but NOT for _Capture_ columns
831          // (_Capture_ is not in the schema, so Perl's openElement validation prevents XMArg there)
832          let cur_qname = get_node_qname(document.get_node());
833          let wrap_xmarg =
834            ismath && !crate::common::arena::with(cur_qname, |s| s.ends_with("_Capture_"));
835          if wrap_xmarg {
836            // Hacky!
837            document.open_element("ltx:XMArg", Some(string_map!("rule" => "Anything")), None)?;
838          }
839          // Perl L365: absorb pre-spacing (lspaces > 1.5em)
840          if let Some(ref pre) = pre_absorb {
841            document.absorb(pre, None)?;
842          }
843          // In math mode, absorb lspaces as content (creates XMHint for \quad etc.)
844          // This is needed for the math parser to convert XMHint → lpadding.
845          if ismath
846            && pre_absorb.is_none()
847            && let Some(ref lsp) = cell.lspaces
848          {
849            document.absorb(lsp, None)?;
850          }
851          document.absorb(box_ref, None)?;
852          // Perl L367: absorb post-spacing (rspaces > 1.5em)
853          if let Some(ref post) = post_absorb {
854            document.absorb(post, None)?;
855          }
856          if wrap_xmarg {
857            // Hacky!
858            document.close_element("ltx:XMArg")?;
859          }
860          // expire local $LaTeXML::BOX
861          document.expire_box_to_absorb();
862        } else if let Some(ref boxes) = cell.boxes {
863          // Cell is skippable but may contain preserved boxes (e.g. \label wrapped
864          // in \lx@hidden@noalign with alignmentPreserve=true). These boxes need
865          // to be absorbed so their constructors run (e.g. \label sets labels= on
866          // the parent equation element via float_to_label).
867          // In Perl, \hfil from the template contributes cell width, making such
868          // cells non-skippable. In Rust, \hfil doesn't contribute width.
869          for item in boxes.unlist() {
870            if item.get_property_bool("alignmentPreserve") {
871              document.absorb(&item, None)?;
872            }
873          }
874        }
875        let close_column_fn = &self.close_column;
876        close_column_fn(document)?;
877      }
878      for after in row.after.iter() {
879        document.absorb(after, None)?;
880      }
881      let close_row_fn = &self.close_row;
882      close_row_fn(document)?;
883    }
884    let close_container_fn = &self.close_container;
885    let node_opt = close_container_fn(document)?;
886
887    // If we're not nested inside another tabular
888    // [This should be an afterConstruct somewhere?]
889    // If requested to guess headers & we're not nested inside another tabular
890    if let Some(mut node) = node_opt {
891      if document
892        .findnodes("ancestor::ltx:tabular", Some(&node))
893        .is_empty()
894      {
895        let hashead = !document
896          .findnodes("descendant::ltx:td[@thead]", Some(&node))
897          .is_empty();
898        // If requested && no cells are already marked as being thead, apply heuristic
899        let guess_headers = self
900          .properties
901          .get("guess_headers")
902          .map(|v| !matches!(v, Stored::Bool(false)))
903          .unwrap_or(false);
904        if guess_headers && !hashead {
905          guess_alignment_headers(document, &mut node, self)?;
906        }
907        // Otherwise, if not a math array, group thead & tbody rows
908        // TODO: Re-design asking the outer Whatsit about "!body->isMath"
909        else if hashead && !ismath {
910          // in case already marked w/thead|tbody
911          alignment_regroup_rows(document, &node)?;
912        }
913      }
914      Ok(vec![node])
915    } else {
916      Ok(Vec::new())
917    }
918  }
919}
920
921impl Debug for Alignment {
922  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
923    write!(
924      f,
925      "Alignment{{template:{:?}, properties:{:?}, rows:{:?} }}",
926      self.template, self.properties, self.rows
927    )
928  }
929}
930
931impl Display for Alignment {
932  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{self:?}") }
933}
934impl PartialEq for Alignment {
935  fn eq(&self, other: &Alignment) -> bool {
936    // TODO: Is it enough to compare the owned template?
937    self.template == other.template
938  }
939}
940
941//%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
942// Dealing with templates
943
944// newcolumntype
945//  defines \NC@rewrite@<char>
946//    As macro
947//    or "constructor" (or just sub that creates a column)
948
949/// a reader for the Template parameter type
950pub fn read_alignment_template() -> Result<Template> {
951  gullet::skip_spaces()?;
952  local_build_template(Template::default());
953  let mut tokens = vec![T_BEGIN!()];
954  let mut nopens = 0;
955  while let Some(open) = gullet::read_token()? {
956    if open.get_catcode() == Catcode::BEGIN {
957      nopens += 1;
958    } else {
959      gullet::unread_one(open);
960      break;
961    }
962  }
963  while let Some(op) = gullet::read_token()? {
964    let cc = op.get_catcode();
965    if cc == Catcode::SPACE {
966    } else if cc == Catcode::END {
967      let mut last_op = op;
968      nopens -= 1;
969      while nopens > 0 {
970        if let Some(next_op) = gullet::read_token()? {
971          last_op = next_op;
972          if last_op.get_catcode() != Catcode::END {
973            break;
974          }
975        } else {
976          break;
977        }
978        nopens -= 1;
979      }
980      if nopens <= 0 {
981        break;
982      }
983      gullet::unread_one(last_op);
984    } else {
985      match lookup_expandable(&T_CS!(s!("\\NC@rewrite@{op}")), None)? {
986        Some(defn) => {
987          let invoked = defn.invoke(true)?;
988          gullet::unread(invoked);
989        },
990        _ => {
991          if cc == Catcode::BEGIN {
992            let balanced_arg = gullet::read_balanced(ExpansionLevel::Off, false, false)?;
993            if !balanced_arg.is_empty() {
994              gullet::unread(balanced_arg);
995            }
996          } else {
997            Warn!("unexpected", op, s!("Unrecognized tabular template {op:?}"));
998          }
999        },
1000      }
1001    }
1002    if nopens <= 0 {
1003      break;
1004    }
1005  }
1006  tokens.push(T_END!());
1007  with_current_build_template(|template_opt| {
1008    let t = template_opt.unwrap();
1009    t.set_reversion(Tokens::new(tokens));
1010    // Perl Alignment.pm L912: $BUILD_TEMPLATE->finish
1011    t.finish();
1012  });
1013  Ok(take_build_template().unwrap())
1014}
1015
1016pub fn parse_alignment_template(spec: &str) -> Result<Template> {
1017  let reader_mouth = Mouth::new(&s!("{{{spec}}}"), None)?;
1018  gullet::reading_from_mouth(reader_mouth, read_alignment_template)
1019}
1020
1021pub fn matrix_template() -> Template {
1022  Template::new(TemplateConfig {
1023    repeated: vec![Cell {
1024      before: Some(Tokens!(T_CS!("\\hfil"))),
1025      after: Some(Tokens!(T_CS!("\\hfil"))),
1026      ..Cell::default()
1027    }],
1028    ..TemplateConfig::default()
1029  })
1030}
1031
1032//%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
1033// Experimental alignment heading heuristications.
1034//%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
1035// We attempt to recognize patterns of rows/columns that indicate which might be headers.
1036// We'll characterize the cells by alignment, content and borders.
1037// Then, assuming that headers will be first and be noticably `different' from data lines,
1038// and also that the data lines will have similar structure,  we'll attempt to
1039// recognize groups of header lines and groups data lines, possibly alternating.
1040
1041/// Check whether a template token list contains fill/spacing commands
1042/// like \hfil, \hfill, \hskip, \lx@intercol. Previously used as fallback
1043/// for lpad/rpad; now superseded by template_has_intercol for better @{} handling.
1044#[allow(dead_code)]
1045fn template_has_fill(tokens: &Option<Tokens>) -> bool {
1046  if let Some(toks) = tokens {
1047    for tok in toks.unlist_ref() {
1048      let s = tok.to_string();
1049      if s == "\\hfil" || s == "\\hfill" || s == "\\hskip" {
1050        return true;
1051      }
1052    }
1053  }
1054  false
1055}
1056
1057/// Check if \lx@intercol in the before tokens is reachable by the left-scan.
1058/// The left-scan skips: isVerticalRule (\vrule), \relax, isFill (\hfil/\hfill),
1059/// isSpace, isHorizontalRule, alignmentSkippable, Comment.
1060/// It STOPS at real content (like text from @{text}).
1061/// For `\vrule\relax\lx@intercol\hfil`: reachable (vrule is skippable).
1062/// For `@{1}\lx@intercol\hfil`: NOT reachable ("1" blocks the scan).
1063fn intercol_reachable_in_before(tokens: &Option<Tokens>) -> bool {
1064  if let Some(toks) = tokens {
1065    for tok in toks.unlist_ref() {
1066      let s = tok.to_string();
1067      if s == "\\lx@intercol" || s.contains("intercol") {
1068        return true;
1069      }
1070      // These are skippable by the left-scan in Perl's extractAlignmentColumn
1071      if s == "\\vrule"
1072        || s == "\\relax"
1073        || s == "\\hfil"
1074        || s == "\\hfill"
1075        || s == "\\hskip"
1076        || s == "\\lx@column@trimright"
1077      {
1078        continue;
1079      }
1080      // Any other token blocks the scan
1081      return false;
1082    }
1083  }
1084  false
1085}
1086
1087/// Check if template tokens contain \lx@intercol (intercolumn spacing).
1088/// Unlike template_has_fill, this ignores \hfil/\hfill which are alignment fill.
1089/// \lx@intercol indicates actual intercolumn padding; \hfil is just centering.
1090/// For @{}c@{} columns, \lx@intercol is disabled but \hfil remains.
1091fn template_has_intercol(tokens: &Option<Tokens>) -> bool {
1092  if let Some(toks) = tokens {
1093    for tok in toks.unlist_ref() {
1094      let s = tok.to_string();
1095      if s == "\\lx@intercol" || s.contains("intercol") {
1096        return true;
1097      }
1098    }
1099  }
1100  false
1101}
1102
1103fn guess_alignment_headers(
1104  document: &mut Document,
1105  table: &mut Node,
1106  alignment: &mut Alignment,
1107) -> Result<()> {
1108  // Assume that headers don't make sense for nested tables.
1109  // OR Maybe we should only do this within table environments???
1110  if !document
1111    .findnodes("ancestor::ltx:tabular", Some(table))
1112    .is_empty()
1113  {
1114    return Ok(());
1115  }
1116  let tag = get_node_qname(table);
1117  // TODO
1118  //   Debug(('=' x 50) . "\nGuessing alignment headers for "
1119  //       . (($x = $document->findnode('ancestor-or-self::*[@xml:id]', $table)) ?
1120  // $x->getAttribute('xml:id') : $tag))     if $LaTeXML::DEBUG{alignment};
1121
1122  let ismath = tag == crate::pin!("ltx:XMArray");
1123  let reversed = false;
1124  // Attempt to recognize header lines.
1125  // Build a view of the table by extracting the rows, collecting & characterizing each cell.
1126  classify_alignment_rows(alignment);
1127
1128  {
1129    let mut rows = collect_alignment_rows(alignment);
1130    if rows.is_empty() {
1131      return Ok(());
1132    }
1133    alignment_characterize_lines(document, Axis::Row, false, rows.as_mut_slice())?;
1134  }
1135  // Flip the rows around to produce a column view.
1136  {
1137    let mut cols = collect_alignment_columns(alignment);
1138    if cols.is_empty() {
1139      return Ok(());
1140    }
1141    // This usually does something unpleasant
1142    alignment_characterize_lines(document, Axis::Column, false, cols.as_mut_slice())?;
1143  }
1144
1145  // Did we go overboard?
1146  let rows = collect_alignment_rows(alignment);
1147  let mut n_h = 0;
1148  let mut n_d = 0;
1149  for r in rows.iter() {
1150    for c in r {
1151      match c.cell_type {
1152        Some('h') => n_h += 1,
1153        Some('d') => n_d += 1,
1154        Some(other) => panic!("unexpected cell_type {}", other),
1155        None => {},
1156      }
1157    }
1158  }
1159  // dbg!((n_h, n_d));
1160  //   Debug("$n{h} header, $n{d} data cells") if $LaTeXML::DEBUG{alignment};
1161  if n_d == 1 {
1162    // Or any other heuristic?
1163    n_h = 0;
1164    for r in rows {
1165      for c in r {
1166        c.cell_type = Some('d');
1167        if let Some(ref mut cell) = c.cell {
1168          cell.remove_attribute("thead")?;
1169        }
1170      }
1171    }
1172  }
1173  // Regroup the rows into thead & tbody elements.
1174  // But not if it's a math array, or if reversed (since browsers get confused?)
1175  if !ismath && !reversed {
1176    alignment_regroup_rows(document, table)?;
1177  }
1178  if n_h > 0 {
1179    // Found some headers?
1180    document.add_class(table, "ltx_guessed_headers")?;
1181  }
1182
1183  //   # Debugging report!
1184  //   summarize_alignment([@rows], [@cols]) if $LaTeXML::DEBUG{alignment};
1185  Ok(())
1186}
1187
1188//======================================================================
1189// Regroup the rows into thead, tbody & tfoot
1190// Any leading rows, all of whose cells have attribute thead should be in thead.
1191// UNLESS any of them have a rowspan that extends PAST the end of the thead!!!!
1192// trailing rows marked as thead go into tfoot.
1193fn alignment_regroup_rows(document: &mut Document, table: &Node) -> Result<()> {
1194  let mut rows = document.findnodes("ltx:tr", Some(table));
1195  // `heads` is bounded by the initial thead-candidate rows; pre-size
1196  // to `rows.len()` as a conservative upper bound.
1197  let mut heads = Vec::with_capacity(rows.len());
1198  let mut maxreach = 0;
1199  // Scan initial rows as potential thead
1200  while !rows.is_empty() {
1201    let cells = document.findnodes("ltx:td", Some(&rows[0]));
1202    // Non header cells, done.
1203    if cells
1204      .iter()
1205      .any(|cell| cell.get_attribute("thead").is_none())
1206    {
1207      break;
1208    }
1209    let line = heads.len();
1210    heads.push(rows.remove(0));
1211    for cell in cells {
1212      // Malformed/non-numeric rowspan silently degrades to 0 — matches Perl's
1213      // lax numeric coercion and prevents crashes on unusual input XML.
1214      let this_rowspan = cell
1215        .get_attribute("rowspan")
1216        .and_then(|v| v.parse::<usize>().ok())
1217        .unwrap_or(0)
1218        + line;
1219      if this_rowspan > maxreach {
1220        maxreach = this_rowspan;
1221      }
1222    }
1223  }
1224  if maxreach > heads.len() {
1225    // rowspan crossed over thead boundary! Put head rows back at the FRONT of body rows.
1226    heads.append(&mut rows);
1227    rows = heads;
1228    heads = Vec::new();
1229  }
1230  // scan trailing rows as potential tfoot
1231  let mut foots = VecDeque::new();
1232  while !rows.is_empty() {
1233    let cells = document.findnodes("ltx:td", Some(rows.last().unwrap()));
1234    // Non header cells, done.
1235    if cells
1236      .iter()
1237      .any(|cell| cell.get_attribute("thead").is_none())
1238    {
1239      break;
1240    }
1241    foots.push_front(rows.pop().unwrap())
1242  }
1243  if !heads.is_empty() {
1244    document.wrap_nodes("ltx:thead", heads)?;
1245  }
1246  if !rows.is_empty() {
1247    document.wrap_nodes("ltx:tbody", rows)?;
1248  }
1249  if !foots.is_empty() {
1250    document.wrap_nodes("ltx:tfoot", foots.into_iter().collect())?;
1251  }
1252  Ok(())
1253}
1254
1255//======================================================================
1256/// Setup a View of the alignment, with characterized cells, for analysis -- modifying it in place.
1257fn classify_alignment_rows(alignment: &mut Alignment) {
1258  let mut ncols = 0;
1259  for arow in &mut alignment.rows {
1260    let n = arow.get_columns().len();
1261    if n > ncols {
1262      ncols = n;
1263    }
1264  }
1265  // eprintln!("classify_alignment_rows: {} rows, max {} cols", alignment.rows.len(), ncols);
1266  let (mut h, mut v) = (false, false);
1267  for arow in alignment.rows.iter_mut() {
1268    let cols = arow.get_columns_mut();
1269    let this_row_len = cols.len();
1270    // eprintln!("  row {_ri}: {this_row_len} cols");
1271    for col in cols.iter_mut() {
1272      col.cell_type = Some('d');
1273      col.content_class = Some(
1274        // Assume mixed content for any justified cell???
1275        if col.align == Some(Align::Justify) {
1276          ColumnSpec::MathAltText
1277        } else if col.cell.is_some() {
1278          classify_alignment_cell(col.cell.as_ref().unwrap())
1279        } else {
1280          ColumnSpec::Unknown
1281        },
1282      );
1283      // eprintln!("    cell: cell={}, class={:?}, border='{}'", col.cell.is_some(),
1284      // col.content_class, col.border);
1285      col.content_length = Some(if col.content_class == Some(ColumnSpec::Graphics) {
1286        1000
1287      } else if col.cell.is_some() {
1288        col.cell.as_ref().unwrap().get_content().chars().count()
1289      } else {
1290        0
1291      });
1292      let (mut border_top, mut border_bottom, mut border_left, mut border_right) = (0, 0, 0, 0);
1293      for c in col.border.chars() {
1294        match c {
1295          'l' | 'L' => border_left += 1,
1296          'r' | 'R' => border_right += 1,
1297          't' | 'T' => border_top += 1,
1298          'b' | 'B' => border_bottom += 1,
1299          _ => {}, // spaces etc.
1300        }
1301      }
1302      // Note: once h and v are set as true on any row, they remain globally true.
1303      if (border_top > 0) || (border_bottom > 0) {
1304        h = true;
1305      }
1306      if (border_right > 0) || (border_left > 0) {
1307        v = true;
1308      }
1309      col.border_top = Some(border_top);
1310      col.border_bottom = Some(border_bottom);
1311      col.border_left = Some(border_left);
1312      col.border_right = Some(border_right);
1313    }
1314    // pad the columns out.
1315    let to_pad = ncols - this_row_len;
1316    if to_pad > 0 {
1317      for _ in 0..to_pad {
1318        let col = Cell {
1319          align: Some(Align::Center),
1320          cell_type: Some('d'),
1321          content_class: Some(ColumnSpec::Empty),
1322          content_length: Some(0),
1323          ..Cell::default()
1324        };
1325        cols.push(col);
1326      }
1327    }
1328  }
1329  // Copy the characterizations to spanned cells, and move the outer borders of
1330  // span-origin cells onto their last spanned column/row.
1331  // Perl: collect_alignment_rows (Alignment.pm L1070-1093). This MUST run
1332  // in-place and sequentially: Perl mutates the shared row array as it scans,
1333  // so when it reaches a colspan-covered cell it reads the right border that
1334  // the spanning cell just wrote (and writes it back), keeping the bar on the
1335  // span boundary. An earlier Rust port DEFERRED these assignments (collected
1336  // them, applied after the loop) to sidestep cross-row borrow conflicts —
1337  // which broke that read-after-write chain: a `\multicolumn` lost the vertical
1338  // bar on its spanned-over neighbor, and the following cell lost the matching
1339  // left border. That inflated the row-difference score in alignment_compare
1340  // and defeated guess_alignment_headers on the common "header row over a
1341  // \multicolumn data row" table. Index-based single-cell access keeps each
1342  // &mut borrow scoped to one statement, so we can update in place faithfully.
1343  // Out-of-bounds spans (malformed rowspan/colspan past the table edge) are
1344  // skipped rather than auto-vivified as Perl would.
1345  let nrows_pre = alignment.rows.len();
1346  for r in 0..nrows_pre {
1347    let row_len = alignment.rows[r].get_columns().len();
1348    for c in 0..row_len {
1349      let (rs, cs, ca, cc, cl, rb, bb) = {
1350        let cell = &mut alignment.rows[r].get_columns_mut()[c];
1351        let rs = cell.rowspan.unwrap_or(1);
1352        let cs = cell.colspan.unwrap_or(1);
1353        let ca = cell.align.clone();
1354        let cc = cell.content_class;
1355        let cl = cell.content_length;
1356        let rb = cell.border_right;
1357        cell.border_right = Some(0);
1358        let bb = cell.border_bottom;
1359        cell.border_bottom = Some(0);
1360        (rs, cs, ca, cc, cl, rb, bb)
1361      };
1362      // colspan: copy characterizations to spanned-over cells in this row
1363      for sc in 1..cs {
1364        if let Some(cell) = alignment.rows[r].get_columns_mut().get_mut(c + sc) {
1365          cell.align = ca.clone();
1366          cell.content_class = cc;
1367          cell.content_length = cl;
1368        }
1369      }
1370      // rowspan: copy characterizations to cells covered in the rows below
1371      for sr in 1..rs {
1372        if r + sr >= nrows_pre {
1373          break;
1374        }
1375        for sc in 0..cs {
1376          if let Some(cell) = alignment.rows[r + sr].get_columns_mut().get_mut(c + sc) {
1377            cell.align = ca.clone();
1378            cell.content_class = cc;
1379            cell.content_length = cl;
1380          }
1381        }
1382      }
1383      // move the outer right border onto the last spanned column (every spanned row)
1384      for sr in 0..rs {
1385        if r + sr >= nrows_pre {
1386          break;
1387        }
1388        if let Some(cell) = alignment.rows[r + sr].get_columns_mut().get_mut(c + cs - 1) {
1389          cell.border_right = rb;
1390        }
1391      }
1392      // move the outer bottom border onto the last spanned row (every spanned column)
1393      if r + rs - 1 < nrows_pre {
1394        for sc in 0..cs {
1395          if let Some(cell) = alignment.rows[r + rs - 1].get_columns_mut().get_mut(c + sc) {
1396            cell.border_bottom = bb;
1397          }
1398        }
1399      }
1400    }
1401  }
1402  // Now, do some border massaging...
1403  // Empty-alignment guard: if ncols==0 (no columns in any row), there are no
1404  // borders to massage. Skip the whole block to avoid out-of-bounds panics on
1405  // `cols[0]` (witness: astro-ph0006087, garmire.tex deluxetable input).
1406  if ncols == 0 {
1407    return;
1408  }
1409  for row in alignment.rows.iter_mut() {
1410    let cols = row.get_columns_mut();
1411    cols[0].border_left = Some(if v { 1 } else { 0 });
1412    if ncols > 1 {
1413      if cols[1].border_left.unwrap_or(0) > 0 {
1414        cols[0].border_right = cols[1].border_left;
1415      }
1416      if cols[ncols - 2].border_right.unwrap_or(0) > 0 {
1417        cols[ncols - 1].border_left = cols[ncols - 2].border_right;
1418      }
1419    }
1420    cols[ncols - 1].border_right = Some(if v { 1 } else { 0 });
1421  }
1422  let nrows = alignment.rows.len();
1423  for c in 0..ncols {
1424    alignment.rows[0].get_columns_mut()[c].border_top = Some(if h { 1 } else { 0 });
1425    if nrows > 1 {
1426      if let Some(bt) = alignment.rows[1].get_columns_mut()[c].border_top
1427        && bt > 0
1428      {
1429        // only set if border is inked
1430        alignment.rows[0].get_columns_mut()[c].border_bottom = Some(bt);
1431      }
1432      if let Some(bb) = alignment.rows[nrows - 2].get_columns_mut()[c].border_bottom
1433        && bb > 0
1434      {
1435        // only set if border is inked
1436        alignment.rows[nrows - 1].get_columns_mut()[c].border_top = Some(bb);
1437      }
1438    }
1439    alignment.rows[nrows - 1].get_columns_mut()[c].border_bottom = Some(if h { 1 } else { 0 });
1440  }
1441  // Perl Alignment.pm L1106-1112: propagate inked interior borders between
1442  // adjacent interior cells (top←above.bottom, bottom←below.top,
1443  // left←left.right, right←right.left). Edge rows/cols are excluded (loop
1444  // bounds 1..n-1), matching Perl; their outer borders were handled above.
1445  for r in 1..nrows - 1 {
1446    for c in 1..ncols - 1 {
1447      if let Some(bb) = alignment.rows[r - 1].get_columns_mut()[c].border_bottom
1448        && bb > 0
1449      {
1450        // only set if border is inked
1451        alignment.rows[r].get_columns_mut()[c].border_top = Some(bb);
1452      }
1453      if let Some(bt) = alignment.rows[r + 1].get_columns_mut()[c].border_top
1454        && bt > 0
1455      {
1456        // only set if border is inked
1457        alignment.rows[r].get_columns_mut()[c].border_bottom = Some(bt);
1458      }
1459      if let Some(br) = alignment.rows[r].get_columns_mut()[c - 1].border_right
1460        && br > 0
1461      {
1462        // only set if border is inked
1463        alignment.rows[r].get_columns_mut()[c].border_left = Some(br);
1464      }
1465      if let Some(bl) = alignment.rows[r].get_columns_mut()[c + 1].border_left
1466        && bl > 0
1467      {
1468        // only set if border is inked
1469        alignment.rows[r].get_columns_mut()[c].border_right = Some(bl);
1470      }
1471    }
1472  }
1473  // debug info
1474  // eprintln!("Cell characterizations:");
1475  // for (row_index,row) in alignment.rows.iter().enumerate() {
1476  //   for (col_index, cell) in row.get_columns().iter().enumerate() {
1477  //     eprintln!("[{row_index},{col_index}]=>{}{}{} {} {} => {}{}{}{}",
1478  //       cell.cell_type.as_ref().unwrap_or(&'?'),
1479  //       cell.align.as_ref().map(|a| a.char_code()).unwrap_or(' '),
1480  //       cell.content_class.map(|a| a.to_string()).unwrap_or_else(|| String::from("?")),
1481  //       cell.content_length.unwrap_or(0),
1482  //       cell.border,
1483  //       if cell.border_top.unwrap_or(0) > 0 { "t" } else { "" },
1484  //       if cell.border_right.unwrap_or(0) > 0  { "r" } else { "" },
1485  //       if cell.border_bottom.unwrap_or(0) > 0 { "b" } else { "" },
1486  //       if cell.border_left.unwrap_or(0) > 0 { "l" } else {""}
1487  //     );
1488  //   }
1489  // }
1490}
1491
1492fn collect_alignment_rows(alignment: &mut Alignment) -> Vec<Vec<&mut Cell>> {
1493  alignment
1494    .rows
1495    .iter_mut()
1496    .map(|x| x.get_columns_mut().iter_mut().collect())
1497    .collect()
1498}
1499
1500fn collect_alignment_columns(alignment: &mut Alignment) -> Vec<Vec<&mut Cell>> {
1501  let mut row_cells: Vec<_> = alignment
1502    .rows
1503    .iter_mut()
1504    .map(|r| r.get_columns_mut().iter_mut())
1505    .collect();
1506  let n_cols = row_cells[0].len();
1507  let n_rows = row_cells.len();
1508  let mut columns = Vec::with_capacity(n_cols);
1509  for _ in 0..n_cols {
1510    let mut column = Vec::with_capacity(n_rows);
1511    for row_iter in row_cells.iter_mut() {
1512      column.push(row_iter.next().unwrap());
1513    }
1514    columns.push(column);
1515  }
1516  columns
1517}
1518
1519/// Return one of: i(nteger), t(ext), m(ath), ? (unknown) or '_' (empty) (or some combination)
1520///  or 'mx' for alternating text & math.
1521fn classify_alignment_cell(xcell: &Node) -> ColumnSpec {
1522  let content = xcell.get_content();
1523  let mut inferred_classes: Vec<ColumnSpec> = Vec::new();
1524  // Perl L1123: /^[\s\d]+$/ — Perl \d is ASCII-only (0-9).
1525  // Also include mathematical double-struck digits (U+1D7D8-U+1D7E1, 𝟘-𝟡) since these
1526  // are font-decoded equivalents of ASCII 0-9 in blackboard bold fonts. In Perl, these
1527  // appear as ASCII digits with font attributes; in Rust they're Unicode codepoints.
1528  // Exclude circled/enclosed numerals (❶❷❸ U+2776-, ①②③ U+2460-) which are symbols.
1529  if !content.is_empty()
1530    && content
1531      .chars()
1532      .all(|c| c.is_whitespace() || c.is_ascii_digit() || ('\u{1D7D8}'..='\u{1D7E1}').contains(&c))
1533  {
1534    inferred_classes.push(ColumnSpec::Integer);
1535  } else {
1536    let mut nodes = xcell.get_child_nodes();
1537    while !nodes.is_empty() {
1538      let ch = nodes.remove(0);
1539      match ch.get_type() {
1540        Some(NodeType::TextNode) => {
1541          let text = ch.get_content();
1542          if !(text.chars().all(|c| c.is_whitespace())
1543            || (inferred_classes.first() == Some(&ColumnSpec::Math)
1544              && SINGLE_PUNCT.is_match(&text)))
1545          {
1546            inferred_classes.push(ColumnSpec::Text);
1547          }
1548        },
1549        Some(NodeType::ElementNode) => {
1550          with_node_qname(&ch, |chtag| match chtag {
1551            "ltx:text" => {
1552              // Perl L1136-1137: $class .= 't' unless $class eq 't'
1553              // Only skip if the LAST class was also Text (not just first).
1554              // This preserves "tt" for cells with two text elements.
1555              if inferred_classes.last() != Some(&ColumnSpec::Text) {
1556                inferred_classes.push(ColumnSpec::Text);
1557              }
1558            },
1559            "ltx:graphics" => {
1560              if inferred_classes.first() != Some(&ColumnSpec::Graphics) {
1561                inferred_classes.push(ColumnSpec::Graphics);
1562              }
1563            },
1564            "ltx:Math" => {
1565              if inferred_classes.first() != Some(&ColumnSpec::Math) {
1566                inferred_classes.push(ColumnSpec::Math);
1567              }
1568            },
1569            "ltx:XMText" => {
1570              if inferred_classes.first() != Some(&ColumnSpec::Text) {
1571                inferred_classes.push(ColumnSpec::Text);
1572              }
1573            },
1574            "ltx:XMArg" | "ltx:inline-block" | "ltx:p" => {
1575              // Transparent containers: look through to classify children.
1576              // Perl's beAbsorbed creates <text> directly in td; Rust wraps in
1577              // <inline-block><p> from {turn}/{rotate}. Treat these as transparent
1578              // so the classification matches Perl's view of the cell content.
1579              let mut children = ch.get_child_nodes();
1580              children.append(&mut nodes);
1581              nodes = children;
1582            },
1583            other if other.starts_with("ltx:XM") => {
1584              if inferred_classes.first() != Some(&ColumnSpec::Math) {
1585                inferred_classes.push(ColumnSpec::Math);
1586              }
1587            },
1588            _ => {
1589              if inferred_classes.is_empty() {
1590                inferred_classes.push(ColumnSpec::Unknown);
1591              }
1592            },
1593          })
1594        },
1595        _ => {},
1596      }
1597    }
1598  }
1599
1600  // check if we have alternating math-and-text or text-and-math (only if 2+ classes)
1601  if inferred_classes.len() > 1 {
1602    let mut alt_peekable = inferred_classes.iter().peekable();
1603    let mut is_alternating = true;
1604    while let Some(c) = alt_peekable.next() {
1605      match c {
1606        ColumnSpec::Math | ColumnSpec::Integer => {
1607          if let Some(peek) = alt_peekable.peek()
1608            && !matches!(peek, ColumnSpec::Text)
1609          {
1610            is_alternating = false;
1611            break;
1612          }
1613        },
1614        ColumnSpec::Text => {
1615          if let Some(peek) = alt_peekable.peek()
1616            && !matches!(peek, ColumnSpec::Math | ColumnSpec::Integer)
1617          {
1618            is_alternating = false;
1619            break;
1620          }
1621        },
1622        _ => {
1623          is_alternating = false;
1624          break;
1625        },
1626      }
1627    }
1628    if is_alternating {
1629      inferred_classes = vec![ColumnSpec::MathAltText];
1630    }
1631  }
1632  // Default to empty and return
1633  if inferred_classes.is_empty() {
1634    ColumnSpec::Empty
1635  } else if inferred_classes.len() == 1 {
1636    inferred_classes[0]
1637  } else {
1638    // Perl L1151: multi-class detection.
1639    // "tt" (all Text) → MultiText, mixed math+text → MathAltText
1640    let all_text = inferred_classes
1641      .iter()
1642      .all(|c| matches!(c, ColumnSpec::Text));
1643    if all_text {
1644      ColumnSpec::MultiText
1645    } else {
1646      ColumnSpec::Unknown
1647    }
1648  }
1649}
1650
1651//======================================================================
1652// Scan pairs of rows/columns attempting to recognize differences that
1653// might indicate which are headers and which are data.
1654// Warning: This section is full of "magic numbers"
1655// guessed by sampling various test cases.
1656
1657const MIN_ALIGNMENT_DATA_LINES: usize = 1; //  (or 2?) [CONSTANT]
1658const MAX_ALIGNMENT_HEADER_LINES: usize = 4; // [CONSTANT]
1659
1660// We expect to find header lines at the beginning, noticably different from the eventual data
1661// lines. Both header lines and data lines can consist of several neighboring lines.
1662// Check that header lines are `similar' to each other.  So, the strategy is to look
1663// for a `hump' in the line differences and consider blocks containing these lines to be potential
1664// headers.
1665
1666fn alignment_characterize_lines(
1667  document: &mut Document,
1668  axis: Axis,
1669  reversed: bool,
1670  lines: &mut [Vec<&mut Cell>],
1671) -> Result<()> {
1672  let n = lines.len();
1673  if n < 2 {
1674    return Ok(());
1675  }
1676  // eprintln!("Characterizing {n} {}", if axis == Axis::Row {"rows"} else {"columns"});
1677
1678  // Establish a scale of differences for the table.
1679  let (mut max_diff, mut min_diff, _avg_diff) = (0.0, 99999999.0, 0.0);
1680  for l in 0..n - 1 {
1681    let d = alignment_compare(axis, true, reversed, l, l + 1, lines);
1682    // eprintln!("  compare({l},{}) = {d}", l + 1);
1683    // avg_diff += d;
1684    if d > max_diff {
1685      max_diff = d;
1686    }
1687    if d < min_diff {
1688      min_diff = d;
1689    }
1690  }
1691  // avg_diff = avg_diff / (n - 1) as f64;
1692  if max_diff < 0.05 {
1693    // virtually no differences.
1694    return Ok(());
1695  }
1696  if (n > 2) && ((max_diff - min_diff) < max_diff * 0.5) {
1697    // differences too similar to establish pattern
1698    return Ok(());
1699  }
1700  let tab_threshold = min_diff + 0.3 * (max_diff - min_diff);
1701
1702  // eprintln!("Differences {min_diff} -- {max_diff} => threshold = {tab_threshold}");
1703  // Find the first hump in differences. These are candidates for header lines.
1704  // eprintln!("Scanning for headers");
1705  let (minh, mut maxh) = (1, 1);
1706  let mut diff;
1707  loop {
1708    diff = alignment_compare(axis, true, reversed, maxh - 1, maxh, lines);
1709    if diff >= tab_threshold {
1710      break;
1711    }
1712    maxh += 1;
1713  }
1714  if maxh > MAX_ALIGNMENT_HEADER_LINES {
1715    // too many before even finding diffs? give up!
1716    return Ok(());
1717  }
1718  while alignment_compare(axis, true, reversed, maxh, maxh + 1, lines) > tab_threshold {
1719    maxh += 1;
1720  }
1721  if maxh > MAX_ALIGNMENT_HEADER_LINES {
1722    maxh = MAX_ALIGNMENT_HEADER_LINES;
1723  }
1724  // eprintln!("Found from {minh}--{maxh} potential headers");
1725
1726  let nn = lines[0].len() - 1;
1727  // The sets of lines 1--$minh, .. 1--$maxh are potential headers.
1728  for nh in (minh..=maxh).rev() {
1729    // Check whether the set 1..$nh is plausable.
1730    let heads = alignment_test_headers(nh, tab_threshold, axis, lines);
1731    if !heads.is_empty() {
1732      // Now, change all cells marked as header from td => th.
1733      for h in heads {
1734        for (i, cell) in lines[h].iter_mut().enumerate() {
1735          cell.cell_type = Some('h');
1736          if let Some(ref mut xcell) = cell.cell {
1737            // But NOT empty cells on outer edges.
1738            // Perl: !$$cell{l} is falsy for both undef AND 0.
1739            if (cell.content_class == Some(ColumnSpec::Empty))
1740              && ((i == 0
1741                && (if axis == Axis::Row {
1742                  cell.border_left.unwrap_or(0) == 0
1743                } else {
1744                  cell.border_top.unwrap_or(0) == 0
1745                }))
1746                || (i == nn
1747                  && (if axis == Axis::Row {
1748                    cell.border_right.unwrap_or(0) == 0
1749                  } else {
1750                    cell.border_bottom.unwrap_or(0) == 0
1751                  })))
1752            {
1753            } else {
1754              document.add_ss_values(xcell, "thead", axis.marker_name())?;
1755            }
1756          }
1757        }
1758      }
1759      return Ok(());
1760    }
1761  }
1762  Ok(())
1763}
1764
1765/// Test whether `nhead` lines makes a good fit for the headers
1766fn alignment_test_headers(
1767  nhead: usize,
1768  tab_threshold: f64,
1769  axis: Axis,
1770  lines: &[Vec<&mut Cell>],
1771) -> Vec<usize> {
1772  // eprintln!("Testing {nhead} headers with threshold {tab_threshold} for axis {:?}", axis);
1773  let mut heads: Vec<usize> = (0..nhead).collect(); // The indices of heading lines.
1774  let mut head_length = alignment_max_content_length(0, 0, nhead - 1, lines);
1775  let mut next_line = nhead; // Start from the end of the proposed headings.
1776
1777  // Watch out for the assumed header being really data that is a repeated pattern.
1778  let nrep = lines.len() / nhead;
1779  if nhead > 1 {
1780    //   Debug("Check for apparent header repeated $nrep times") if $LaTeXML::DEBUG{alignment};
1781    let mut matched = true;
1782    for r in 1..nrep {
1783      matched =
1784        matched && alignment_match_head(0, r * nhead, nhead, tab_threshold, axis, lines) > 0;
1785    }
1786    //   Debug("Repeated headers: " . ($matched ? "Matched=> Fail" : "Nomatch => Succeed"))
1787    //     if $LaTeXML::DEBUG{alignment};
1788    // eprintln!("  repeated pattern check: matched={matched}");
1789    if matched {
1790      return Vec::new();
1791    }
1792  }
1793
1794  // And find a following grouping of data lines.
1795  let ndata = alignment_skip_data(next_line, tab_threshold, axis, lines);
1796  // eprintln!("  ndata={ndata} from next_line={next_line}");
1797  if ndata < nhead {
1798    // ???? Well, maybe if _really_ convincing???
1799    return Vec::new();
1800  }
1801  if (ndata < nhead) && (ndata < 2) {
1802    return Vec::new();
1803  }
1804  // Check that the content of the headers isn't dramatically larger than the content in the data
1805  let mut data_length = alignment_max_content_length(0, next_line, next_line + ndata - 1, lines);
1806  next_line += ndata;
1807
1808  let mut nd;
1809  // If there are more lines, they should match either the previous data block, or the head/data
1810  // pattern.
1811  while next_line < lines.len() {
1812    // First try to match a repeat of the 1st data block;
1813    // This would be the case when groups of data have borders around them.
1814    // Could want to match a variable number of datalines, but they should be similar!!!??!?!?
1815    nd = if ndata > 1 {
1816      alignment_match_data(nhead, next_line, ndata, tab_threshold, axis, lines)
1817    } else {
1818      0
1819    };
1820    // eprintln!("  while: next_line={next_line}, nd={nd}");
1821    if nd > 0 {
1822      data_length = alignment_max_content_length(data_length, next_line, next_line + nd - 1, lines);
1823      next_line += nd;
1824    }
1825    // Else, try to match the first header block; less common.
1826    else if alignment_match_head(0, next_line, nhead, tab_threshold, axis, lines) > 0 {
1827      // eprintln!("  matched head at next_line={next_line}");
1828      for idx in next_line..next_line + nhead {
1829        heads.push(idx);
1830      }
1831      head_length =
1832        alignment_max_content_length(head_length, next_line, next_line + nhead - 1, lines);
1833      next_line += nhead;
1834      nd = alignment_match_data(nhead, next_line, ndata, tab_threshold, axis, lines);
1835      if nd == 0 {
1836        return Vec::new();
1837      }
1838      data_length = alignment_max_content_length(data_length, next_line, next_line + nd - 1, lines);
1839      next_line += nd;
1840    } else {
1841      // eprintln!("  no match at next_line={next_line} => fail");
1842      return Vec::new();
1843    }
1844  }
1845  // Header content seems too large relative to data?
1846  // eprintln!("  header content = {head_length}; data content = {data_length}");
1847  if (head_length > 10) && (head_length > 4 * data_length) {
1848    //   Debug("header content too much longer than data content")
1849    //     if $LaTeXML::DEBUG{alignment};
1850    return Vec::new();
1851  }
1852  // Or if a header cell has "large" content?
1853  if head_length >= 1000 {
1854    // Or if a header cell has "large" content?
1855    //   Debug("header content too large")
1856    //     if $LaTeXML::DEBUG{alignment};
1857    return Vec::new();
1858  }
1859
1860  // eprintln!("  Succeeded with {nhead} headers: {heads:?}");
1861  heads
1862}
1863
1864fn alignment_match_head(
1865  p1: usize,
1866  p2: usize,
1867  nhead: usize,
1868  tab_threshold: f64,
1869  axis: Axis,
1870  tablines: &[Vec<&mut Cell>],
1871) -> usize {
1872  let nh = alignment_match_lines(p1, p2, nhead, tab_threshold, axis, tablines);
1873  let ok = nhead == nh;
1874  // Debug("Matched $nh header lines => " . ($ok ? "Succeed" : "Failed")) if
1875  // $LaTeXML::DEBUG{alignment};
1876  if ok { nhead } else { 0 }
1877}
1878
1879fn alignment_match_data(
1880  p1: usize,
1881  p2: usize,
1882  n: usize,
1883  tab_threshold: f64,
1884  axis: Axis,
1885  tablines: &[Vec<&mut Cell>],
1886) -> usize {
1887  let nd = alignment_match_lines(p1, p2, n, tab_threshold, axis, tablines);
1888  let ok = (nd as f64 * 1.0) / n as f64 > 0.66;
1889  //   Debug("Matched $nd data lines => " . ($ok ? "Succeed" : "Failed"))
1890  //     if $LaTeXML::DEBUG{alignment};
1891  if ok { nd } else { 0 }
1892}
1893
1894// Match the $n lines starting at $i2 to those starting at $i1.
1895fn alignment_match_lines(
1896  p1: usize,
1897  p2: usize,
1898  n: usize,
1899  tab_threshold: f64,
1900  axis: Axis,
1901  tablines: &[Vec<&mut Cell>],
1902) -> usize {
1903  let max_n = tablines.len();
1904  for i in 0..n {
1905    if (p1 + i >= max_n)
1906      || (p2 + i >= max_n)
1907      || alignment_compare(axis, false, false, p1 + i, p2 + i, tablines) >= tab_threshold
1908    {
1909      return i;
1910    }
1911  }
1912  n
1913}
1914
1915/// Skip through a block of lines starting at $i that appear to be data, returning the number of
1916/// lines. We'll assume the 1st line is data, compare it to following lines,
1917/// but also accept `continuation' data lines.
1918///
1919/// Note: Perl's continuation-line logic (L1336-1339) is effectively dead code:
1920/// `scalar($::TABLINES[0])` evaluates to an array ref's memory address (huge number),
1921/// making `0.4 * huge` very large, so `count_empty <= huge` is always true.
1922/// The condition `($n < 2) || true` = true, so the `last if` simplifies to just
1923/// `last if diff >= threshold`. We match this behavior.
1924fn alignment_skip_data(
1925  i: usize,
1926  tab_threshold: f64,
1927  axis: Axis,
1928  tablines: &[Vec<&mut Cell>],
1929) -> usize {
1930  let tab_lines_length = tablines.len();
1931  if i >= tab_lines_length {
1932    return 0;
1933  }
1934  let _header_width = if !tablines.is_empty() {
1935    tablines[0].len()
1936  } else {
1937    1
1938  };
1939  let mut n = 1;
1940  while i + n < tab_lines_length {
1941    if alignment_compare(axis, true, false, i + n - 1, i + n, tablines) >= tab_threshold {
1942      // TODO: Perl Alignment.pm L1337-1339 has continuation line check here.
1943      // Applying it changes behavior for fonts/bbold tables (false positive headers).
1944      // Need to investigate further.
1945      break;
1946    }
1947    n += 1;
1948  }
1949  if n >= MIN_ALIGNMENT_DATA_LINES { n } else { 0 }
1950}
1951
1952/// Return the maximum "content length" for lines from $from to $to.
1953fn alignment_max_content_length(
1954  mut length: usize,
1955  from: usize,
1956  to: usize,
1957  tablines: &[Vec<&mut Cell>],
1958) -> usize {
1959  for item in tablines.iter().take(to + 1).skip(from) {
1960    let mut l = 0;
1961    for cell in item.iter() {
1962      l += cell.content_length.unwrap_or(0);
1963    }
1964    if l > length {
1965      length = l;
1966    }
1967  }
1968  length
1969}
1970
1971//======================================================================
1972
1973/// Compare two lines along `Axis` (0=row,1=column), returning a measure of the difference.
1974/// The borders are compared differently if
1975///  `for_adjacency`: we adjacent lines that might belong to the same block,
1976///  otherwise    : comparing two lines that ought to have identical patterns (eg. in a repeated
1977/// block)
1978fn alignment_compare(
1979  axis: Axis,
1980  for_adjacency: bool,
1981  reversed: bool,
1982  p1: usize,
1983  p2: usize,
1984  lines: &[Vec<&mut Cell>],
1985) -> f64 {
1986  let max_guard = lines.len();
1987  if p1 >= max_guard || p2 >= max_guard {
1988    return 0.0;
1989  }
1990  let line1 = &lines[p1];
1991  let line2 = &lines[p2];
1992  if line1.is_empty() && line2.is_empty() {
1993    return 0.0;
1994  } else if line1.is_empty() || line2.is_empty() {
1995    return 99999.0;
1996  }
1997  let ncells = line1.len();
1998  let mut diff = 0.0;
1999
2000  for (cell1, cell2) in line1.iter().zip(line2.iter()) {
2001    // Annoying test avoids warnings if cells inconsistent; likely due to incorrect row/col spans
2002    if cell1.content_class.is_none()
2003      || cell2.content_class.is_none()
2004      || cell1.border_left.is_none()
2005      || cell2.border_left.is_none()
2006      || cell1.border_right.is_none()
2007      || cell2.border_right.is_none()
2008      || cell1.border_bottom.is_none()
2009      || cell2.border_bottom.is_none()
2010      || cell1.border_top.is_none()
2011      || cell2.border_top.is_none()
2012    {
2013      continue;
2014    }
2015    if cell1.align != cell2.align
2016      && cell1.content_class != Some(ColumnSpec::Empty)
2017      && cell2.content_class != Some(ColumnSpec::Empty)
2018    {
2019      diff += 0.75;
2020    }
2021    let d = cell1
2022      .content_class
2023      .as_ref()
2024      .unwrap()
2025      .difference_heuristic(cell2.content_class.as_ref().unwrap());
2026    if d > 0.0 {
2027      diff += d;
2028    }
2029    // compare certain edges
2030    if for_adjacency {
2031      // Compare edges for adjacent rows of potentially different purpose
2032      let mut inner_diffs = 0.0;
2033      if axis == Axis::Row {
2034        if cell1.border_right != cell2.border_right {
2035          inner_diffs += 1.0;
2036        }
2037        if cell1.border_left != cell2.border_left {
2038          inner_diffs += 1.0;
2039        }
2040      } else {
2041        if cell1.border_top != cell2.border_top {
2042          inner_diffs += 1.0;
2043        }
2044        if cell1.border_bottom != cell2.border_bottom {
2045          inner_diffs += 1.0;
2046        }
2047      };
2048      diff += 0.3 * inner_diffs;
2049      // Penalty for apparent divider between.
2050      let pedge = if axis == Axis::Row {
2051        if reversed {
2052          BorderSpec::Top
2053        } else {
2054          BorderSpec::Bottom
2055        }
2056      } else if reversed {
2057        BorderSpec::Left
2058      } else {
2059        BorderSpec::Right
2060      };
2061      let border1_pedge = cell1.border_at(pedge);
2062      let border2_pedge = cell2.border_at(pedge);
2063      if let Some(b1p) = border1_pedge
2064        && b1p > 0
2065        && (border1_pedge != border2_pedge)
2066      {
2067        diff += (b1p as i64 - border2_pedge.unwrap_or(0) as i64).abs() as f64;
2068      }
2069    } else {
2070      // Compare edges for rows from diff places for potential similarity
2071      let mut inner_diffs = 0.0;
2072      if cell1.border_right != cell2.border_right {
2073        inner_diffs += 1.0;
2074      }
2075      if cell1.border_left != cell2.border_left {
2076        inner_diffs += 1.0;
2077      }
2078      if cell1.border_top != cell2.border_top {
2079        inner_diffs += 1.0;
2080      }
2081      if cell1.border_bottom != cell2.border_bottom {
2082        inner_diffs += 1.0;
2083      }
2084      diff += 0.3 * inner_diffs;
2085    }
2086  }
2087  diff /= ncells as f64;
2088  // eprintln!("alignment_compare: {p1} - {p2} => {diff};");
2089  // Debug("$p1-$p2 => $diff; ") if $LaTeXML::DEBUG{alignment};
2090  diff
2091}