Skip to main content

latexml_core/
list.rs

1use std::{borrow::Cow, fmt, rc::Rc};
2
3use libxml::tree::Node;
4
5use crate::{
6  BoxOps, Digested, TexMode,
7  common::{
8    arena::SymHashMap as HashMap, dimension::Dimension, error::*, font::Font, locator::Locator,
9    object::Object, store::Stored,
10  },
11  document::Document,
12  pin,
13  tokens::Tokens,
14};
15
16/// Lists can contain any Digested items, such as boxes, whatsits or other lists
17#[derive(Clone, Default)]
18pub struct List {
19  pub boxes:      Vec<Digested>,
20  pub mode:       Option<TexMode>,
21  /// The list's font. `Rc`-shared (like [`crate::tbox::Tbox::font`]): fonts
22  /// repeat massively across a document, and storing the 328-byte [`struct@Font`]
23  /// by value in every list box dominated the digested-box footprint (issue #361
24  /// memory pass). Set once at construction; never mutated in place.
25  pub font:       Option<Rc<Font>>,
26  pub locator:    Option<Locator>,
27  pub properties: HashMap<Stored>,
28}
29
30impl fmt::Debug for List {
31  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
32    write!(
33      f,
34      "{}",
35      self
36        .boxes
37        .iter()
38        .map(|d| d.stringify())
39        .collect::<Vec<_>>()
40        .join(", ")
41    )
42  }
43}
44
45impl fmt::Display for List {
46  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
47    for inner in self.boxes.iter() {
48      write!(f, "{inner}")?;
49    }
50    Ok(())
51  }
52}
53
54impl PartialEq for List {
55  fn eq(&self, other: &Self) -> bool {
56    self.boxes.len() == other.boxes.len()
57      && self
58        .boxes
59        .iter()
60        .zip(other.boxes.iter())
61        .all(|(box1, box2)| box1 == box2)
62  }
63}
64
65impl Object for List {
66  fn stringify(&self) -> String { format!("List[{self:?}]") }
67  fn get_locator(&self) -> Option<Locator> { self.locator }
68
69  fn revert(&self) -> Result<Tokens> {
70    // Seed with one-token-per-box (a lower bound) so the per-box `extend` loop
71    // starts past the first few doubling reallocations (a `grow_one` site).
72    let mut reverted = Vec::with_capacity(self.boxes.len());
73    for tbox in self.boxes.iter() {
74      reverted.extend(tbox.revert()?.unlist());
75    }
76    Ok(Tokens::new(reverted))
77  }
78}
79impl BoxOps for List {
80  fn unlist(&self) -> Vec<Digested> { self.boxes.clone() }
81  fn unlist_ref(&self) -> Vec<Cow<'_, Digested>> { self.boxes.iter().map(Cow::Borrowed).collect() }
82  fn get_properties(&self) -> &HashMap<Stored> { &self.properties }
83  fn get_property(&self, key: &str) -> Option<Cow<'_, Stored>> {
84    self.properties.get(key).map(Cow::Borrowed)
85  }
86  fn with_properties<R, FnR>(&self, caller: FnR) -> R
87  where FnR: FnOnce(&HashMap<Stored>) -> R {
88    caller(&self.properties)
89  }
90  fn get_properties_mut(&mut self) -> &mut HashMap<Stored> { &mut self.properties }
91  fn set_property<T: Into<Stored>>(&mut self, key: &str, value: T) {
92    self.properties.insert(key, value.into());
93  }
94  fn get_string(&self) -> Result<Cow<'_, str>> { Ok(Cow::Owned(self.to_string())) }
95  /// NOTE: No longer used; Document->absorb bypasses this for stack efficiency.
96  /// If called directly, absorb each box individually.
97  fn be_absorbed(&self, document: &mut Document) -> Result<Vec<Node>> {
98    for box_item in &self.boxes {
99      document.absorb(box_item, None)?;
100    }
101    Ok(Vec::new())
102  }
103
104  fn get_font(&self) -> Result<Option<Rc<Font>>> { Ok(self.font.clone()) }
105  fn compute_size(
106    &self,
107    mut options: HashMap<Stored>,
108  ) -> Result<(Dimension, Dimension, Dimension)> {
109    let font = self
110      .font
111      .clone()
112      .unwrap_or_else(|| Rc::new(Font::text_default()));
113    // Perl: pass mode, vattach, and width from List properties through options
114    // so that compute_boxes_size can determine layout mode
115    //
116    // In Perl, List stores mode as a property string ("horizontal", "restricted_horizontal",
117    // "internal_vertical"). In Rust, we have: list.mode = TexMode::Text for horizontal modes.
118    // The actual mode string may be stored as a property, OR we infer from context:
119    //  - If "width" property is set, this is a horizontal-mode List (paragraph layout)
120    //  - Otherwise, default to "restricted_horizontal"
121    if let Some(mode_str) = self.properties.get("mode") {
122      if let Stored::String(s) = mode_str {
123        options.insert("mode", Stored::String(*s));
124      }
125    } else if self.properties.get("width").is_some() && matches!(self.mode, Some(TexMode::Text)) {
126      // Lists with width property set are from horizontal mode (paragraph layout)
127      options.insert("mode", Stored::String(pin!("horizontal")));
128    }
129    if let Some(Stored::String(s)) = self.properties.get("vattach") {
130      options.insert("vattach", Stored::String(*s));
131    }
132    if let Some(width) = self.properties.get("width")
133      && options.get("width").is_none()
134    {
135      options.insert("width", width.clone());
136    }
137    // Perl #2798 (S6): pass the List's recorded \baselineskip (set by S4 in
138    // repack_horizontal) so compute_boxes_size can stack lines with the right
139    // inter-line spacing.
140    if let Some(baseline) = self.properties.get("baseline")
141      && options.get("baseline").is_none()
142    {
143      options.insert("baseline", baseline.clone());
144    }
145    font.compute_boxes_size(&self.boxes, options)
146  }
147}
148
149impl List {
150  pub fn new(boxes: Vec<Digested>) -> Self {
151    // Perl: `$locator = $bx->getLocator unless defined $locator` โ€” the first
152    // box (walking back-to-front) that has a locator. Now that locators are
153    // `Option<Locator>`, this is a clean `find_map` (no default-sentinel hack).
154    //
155    // Under the `token-locators` precision build we instead want the run's full
156    // *extent* โ€” the span from the first contributing box's start to the last
157    // box's end โ€” so a text run carries its true `(from..to)` range
158    // (docs/performance/SOURCE_PROVENANCE.md ยง3.1.1, Tbox consumer). Boxes are in source
159    // order, so folding `new_range` keeps the first `from` and extends to the
160    // latest `to`. Gated at *compile time* (not the runtime `source_map`
161    // switch): `List::new` runs deep in digestion where `State` is already
162    // mutably borrowed, so a `state!()` read here double-borrows the RefCell.
163    // Off the feature this stays the byte-identical Perl representative-locator
164    // behavior and never touches `State`.
165    #[cfg(feature = "token-locators")]
166    let locator: Option<Locator> = boxes.iter().fold(None, |acc, bx| match bx.get_locator() {
167      Some(l) if l.from_line != 0 => match acc {
168        None => Some(l),
169        Some(a) => Locator::new_range(a, l).or(Some(a)),
170      },
171      _ => acc,
172    });
173    #[cfg(not(feature = "token-locators"))]
174    let locator: Option<Locator> = boxes.iter().rev().find_map(|bx| bx.get_locator());
175    // Maybe the most representative font for a List is the font of the LAST box (that _has_ a
176    // font!) ???
177    // Walk boxes back-to-front for the most representative font.
178    // A single box whose font resolution errors (e.g. FontDirective::Closure
179    // returning Err) shouldn't crash the whole List; treat it as "no font"
180    // and keep walking.
181    // The handle is SHARED, not deep-copied. This is the hottest caller of
182    // `get_font` in the whole conversion: `Document::append_node_box` builds a
183    // fresh `List` for EVERY box absorbed into a node, so while `get_font`
184    // returned an owned `Font` this line paid a struct clone plus an `Rc::new`
185    // per box. Measured with `--features dhat-heap` on 100k words of plain
186    // prose: 392 MB of an 840 MB peak (47 %), 1,196,000 allocations of 344 B,
187    // all attributed here.
188    let mut font: Option<Rc<Font>> = None;
189    for bx in boxes.iter().rev() {
190      if let Ok(Some(bx_font)) = bx.get_font() {
191        font = Some(bx_font);
192        break;
193      }
194    }
195    List {
196      boxes,
197      font,
198      mode: None,
199      locator,
200      properties: HashMap::default(),
201    }
202  }
203
204  pub fn is_empty(&self) -> bool {
205    // 1. A space-like thing
206    // 2. empty contents
207    self.get_property_bool("isEmpty")
208      || self.get_property_bool("isSpace")
209      || self
210        .boxes
211        .iter()
212        .all(|item| item.is_empty().unwrap_or(false))
213  }
214}
215
216impl From<List> for Result<Vec<Digested>> {
217  fn from(list: List) -> Result<Vec<Digested>> {
218    let tmp: Digested = list.into();
219    tmp.into()
220  }
221}
222
223impl From<List> for Result<Digested> {
224  fn from(value: List) -> Result<Digested> {
225    let tmp: Digested = value.into();
226    tmp.into()
227  }
228}
229
230#[cfg(test)]
231mod tests {
232  use super::*;
233
234  #[test]
235  fn list_default_is_empty() {
236    let l = List::default();
237    assert!(l.is_empty());
238    assert_eq!(l.boxes.len(), 0);
239    assert_eq!(l.mode, None);
240    assert_eq!(l.font, None);
241  }
242
243  #[test]
244  fn list_new_from_empty_vec() {
245    let l = List::new(vec![]);
246    assert!(l.is_empty());
247    assert_eq!(l.boxes.len(), 0);
248  }
249
250  #[test]
251  fn list_display_empty_is_empty_string() {
252    let l = List::default();
253    assert_eq!(format!("{l}"), "");
254  }
255
256  #[test]
257  fn list_equality_same_empty() {
258    let a = List::default();
259    let b = List::default();
260    assert_eq!(a, b);
261  }
262
263  #[test]
264  fn list_stringify_wraps_in_brackets() {
265    let l = List::default();
266    let s = l.stringify();
267    assert!(s.starts_with("List["), "got {s:?}");
268    assert!(s.ends_with(']'));
269  }
270
271  #[test]
272  fn list_get_properties_empty_by_default() {
273    let l = List::default();
274    assert_eq!(l.get_properties().len(), 0);
275  }
276
277  #[test]
278  fn list_set_property_persists() {
279    let mut l = List::default();
280    l.set_property("testkey", Stored::Bool(true));
281    assert!(l.get_properties().contains_key("testkey"));
282  }
283
284  #[test]
285  fn list_revert_empty_is_empty_tokens() {
286    let l = List::default();
287    let t = l.revert().expect("empty list reverts cleanly");
288    assert_eq!(t.len(), 0);
289  }
290
291  #[test]
292  fn list_unlist_ref_returns_borrowed_boxes() {
293    let l = List::default();
294    let refs = l.unlist_ref();
295    assert_eq!(refs.len(), 0);
296  }
297
298  #[test]
299  fn list_get_font_default_none() {
300    let l = List::default();
301    let f = l.get_font().unwrap();
302    assert!(f.is_none());
303  }
304}