Skip to main content

latexml_core/
tbox.rs

1use std::{borrow::Cow, fmt, rc::Rc};
2
3use libxml::tree::Node;
4
5use crate::{
6  BoxOps, Digested,
7  common::{
8    arena::{self, SymHashMap as HashMap, SymStr},
9    dimension::Dimension,
10    error::*,
11    font::Font,
12    locator::Locator,
13    object::Object,
14    store::Stored,
15  },
16  document::Document,
17  gullet, pin,
18  state::{lookup_font, with_value},
19  token::{Catcode, Token},
20  tokens::Tokens,
21};
22
23/// Box is a Rust keyword, so we use "Tbox" instead, as in "TeX Box"
24#[derive(Debug, Clone)]
25pub struct Tbox {
26  /// plain-text content
27  pub text:       SymStr,
28  /// associated font for `text`
29  pub font:       Rc<Font>,
30  /// source location where the box originated (`None` = unknown)
31  pub locator:    Option<Locator>,
32  /// misc properties, such as sizing information
33  pub properties: HashMap<Stored>,
34  /// a Tokens list containing the TeX that created (or could have) the Tbox.
35  pub tokens:     Tokens,
36}
37
38impl Default for Tbox {
39  fn default() -> Self {
40    Tbox {
41      text:       pin!(""),
42      font:       Rc::new(Font::text_default()),
43      locator:    None,
44      properties: HashMap::default(),
45      tokens:     Tokens!(),
46    }
47  }
48}
49
50impl PartialEq for Tbox {
51  // Should this compare fonts too?
52  fn eq(&self, other: &Self) -> bool { self.text == other.text && *self.font == *other.font }
53}
54
55//======================================================================
56// Exported constructors
57impl fmt::Display for Tbox {
58  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
59    arena::with(self.text, |text| write!(f, "{}", text))
60  }
61}
62impl Object for Tbox {
63  fn get_locator(&self) -> Option<Locator> { self.locator }
64  fn revert(&self) -> Result<Tokens> { Ok(self.tokens.clone()) }
65  fn stringify(&self) -> String { format!("{self:?}") }
66}
67impl Tbox {
68  /// creates a new Tbox.
69  /// If `font_opt` or `locator_opt` are None, they are obtained from the
70  /// currently active state? Note that `text` can
71  /// be empty, which contributes nothing to the generated document,
72  /// but does record the TeX code (in the tokens).
73  pub fn new(
74    text: SymStr,
75    font_opt: Option<Rc<Font>>,
76    locator_opt: Option<Locator>,
77    tokens_opt: Tokens,
78    mut properties: HashMap<Stored>,
79  ) -> Self {
80    // A text box always has a source position (Perl parity): use the passed
81    // locator, else the gullet's current one. Stored as `Some`.
82    let locator = Some(locator_opt.unwrap_or_else(gullet::get_locator));
83    let mut font = match font_opt {
84      Some(f) => f,
85      None => lookup_font().unwrap(),
86    };
87    let empty_sym = pin!("");
88    let tokens = if text != empty_sym && tokens_opt.is_empty() {
89      Tokens!(Token {
90        text,
91        code: Catcode::OTHER,
92        #[cfg(feature = "token-locators")]
93        loc: 0
94      })
95    } else {
96      tokens_opt
97    };
98
99    // Perl: if ((!defined $properties{isSpace}) && (defined $string) && ($string =~ /^\s*$/)) {
100    //         $properties{isSpace} = 1; }
101    // Auto-mark all-whitespace text as isSpace (matches Perl Box() behavior)
102    if !properties.contains_key("isSpace") && text != empty_sym {
103      let is_all_ws = arena::with(text, |s| {
104        !s.is_empty() && s.chars().all(|c| c.is_whitespace())
105      });
106      if is_all_ws {
107        properties.insert("isSpace", Stored::Bool(true));
108      }
109    }
110
111    if properties.contains_key("isSpace")
112      && (properties.contains_key("width")
113        || properties.contains_key("height")
114        || properties.contains_key("depth"))
115    {
116      properties
117        .entry("width")
118        .or_insert_with(|| Stored::Dimension(Dimension::default()));
119      properties
120        .entry("height")
121        .or_insert_with(|| Stored::Dimension(Dimension::default()));
122      properties
123        .entry("depth")
124        .or_insert_with(|| Stored::Dimension(Dimension::default()));
125    }
126    if crate::state::lookup_bool_sym(crate::pin!("IN_MATH")) {
127      properties.insert("mode", Stored::String(pin!("math")));
128      if text != empty_sym {
129        with_value(
130          &arena::with(text, |text_str| s!("math_token_attributes_{}", text_str)),
131          |value_opt| {
132            if let Some(Stored::HashString(attr)) = value_opt {
133              for (key, value) in attr.iter() {
134                properties
135                  .entry(key)
136                  .or_insert_with(|| Stored::String(arena::pin(value)));
137              }
138            }
139          },
140        );
141      }
142      font = Rc::new(arena::with(text, |text_str| font.specialize(text_str)));
143    }
144    Tbox {
145      text,
146      font,
147      locator,
148      properties,
149      tokens,
150    }
151  }
152  /// checks if the text content is empty
153  pub fn is_empty(&self) -> bool {
154    // 1. A space-like thing
155    // 2. empty (or whitespace) text content
156    self.get_property_bool("isEmpty")
157      || self.get_property_bool("isSpace")
158      || arena::with(self.text, |text| text.trim().is_empty())
159  }
160
161  /// Whether this box is in math mode.
162  /// Perl: Box.pm::isMath (L79-81): `($mode || 'restricted_horizontal') =~ /math$/`.
163  /// Matches "math", "inline_math", "display_math".
164  pub fn is_math(&self) -> bool {
165    match self.properties.get("mode") {
166      Some(Stored::String(s)) => arena::with(*s, |m| m.ends_with("math")),
167      _ => false,
168    }
169  }
170
171  /// Batch-insert properties. Equivalent to calling `set_property` for each
172  /// entry, but avoids re-looking up the properties map per call.
173  /// Perl: Box.pm::setProperties (L171-175).
174  pub fn set_properties<I>(&mut self, entries: I)
175  where I: IntoIterator<Item = (&'static str, Stored)> {
176    for (key, value) in entries {
177      self.properties.insert(key, value);
178    }
179  }
180
181  /// height + depth of this box (the TeX "total height").
182  /// Returns Dimension::default() (0pt) when either component is absent.
183  /// Perl: Box.pm::getTotalHeight (L202-210).
184  pub fn total_height(&self) -> Dimension {
185    let h = match self.properties.get("height") {
186      Some(Stored::Dimension(d)) => d.0,
187      _ => 0,
188    };
189    let d = match self.properties.get("depth") {
190      Some(Stored::Dimension(d)) => d.0,
191      _ => 0,
192    };
193    Dimension(h + d)
194  }
195}
196
197impl BoxOps for Tbox {
198  fn get_tokens(&self) -> Option<&Tokens> { Some(&self.tokens) }
199  fn get_properties(&self) -> &HashMap<Stored> { &self.properties }
200  fn get_property(&self, key: &str) -> Option<Cow<'_, Stored>> {
201    let props = &self.properties;
202    if key == "isSpace" {
203      match props.get(key) {
204        Some(value) => Some(Cow::Owned(value.clone())),
205        None => {
206          let tex = self
207            .get_tokens()
208            .map(|tks| tks.clone().untex())
209            .unwrap_or_default(); // !
210          if !tex.is_empty() && tex.chars().all(char::is_whitespace) {
211            // Check the TeX code, not (just) the string!
212            Some(Cow::Owned(Stored::Bool(true)))
213          } else {
214            None
215          }
216        },
217      }
218    } else {
219      props.get(key).map(|v| Cow::Owned(v.clone()))
220    }
221  }
222  fn with_properties<R, FnR>(&self, caller: FnR) -> R
223  where FnR: FnOnce(&HashMap<Stored>) -> R {
224    caller(&self.properties)
225  }
226  fn get_properties_mut(&mut self) -> &mut HashMap<Stored> { &mut self.properties }
227  fn get_string(&self) -> Result<Cow<'_, str>> {
228    // TODO: Should we switch these to symbols? are they used often?
229    Ok(Cow::Owned(arena::with(self.text, |text| text.to_string())))
230  }
231
232  fn be_absorbed(&self, document: &mut Document) -> Result<Vec<Node>> {
233    let text = self.get_string()?;
234    let font = &self.font;
235    let mode = match self.properties.get("mode") {
236      Some(Stored::String(s)) => *s,
237      _ => pin!("text"),
238    };
239
240    if !text.is_empty() {
241      // Perl Box::isMath: `mode =~ /math$/` matches "math" / "inline_math" / "display_math".
242      let mode_is_math = arena::with(mode, |m| m.ends_with("math"));
243      if mode_is_math {
244        // Perl: DefMath ?#isMath — in text context, produce plain text.
245        // Check if we're inside a math element by walking up the DOM.
246        // This handles \And in author frontmatter (text) while preserving
247        // \times inside XMText within math.
248        let in_math_context = {
249          let mut node = document.node.clone();
250          let mut found = false;
251          loop {
252            let qname = crate::document::get_node_qname(&node);
253            let hit = arena::with(qname, |s| s.contains("XM") || s.contains("Math"));
254            if hit {
255              found = true;
256              break;
257            }
258            match node.get_parent() {
259              Some(parent) => node = parent,
260              None => break,
261            }
262          }
263          found
264        };
265        if in_math_context {
266          Ok(vec![document.insert_math_token(
267            &text,
268            Stored::cast_to_string_hash(&self.properties),
269            Some(font),
270          )?])
271        } else {
272          // Text fallback: produce plain text (Perl's ?#isMath else branch)
273          match document.open_text(&text, font)? {
274            None => Ok(Vec::new()),
275            Some(node) => Ok(vec![node]),
276          }
277        }
278      } else {
279        match document.open_text(&text, font)? {
280          None => Ok(Vec::new()),
281          Some(node) => Ok(vec![node]),
282        }
283      }
284    } else {
285      Ok(Vec::new())
286    }
287  }
288
289  fn get_font(&self) -> Result<Option<Rc<Font>>> { Ok(Some(Rc::clone(&self.font))) }
290
291  fn compute_size(&self, options: HashMap<Stored>) -> Result<(Dimension, Dimension, Dimension)> {
292    match self.get_property("body") {
293      Some(body_stored) => {
294        if let Stored::Digested(ref body) = *body_stored {
295          body.compute_size(options)
296        } else {
297          panic!("the stored 'body' property should always be a Stored::Digested enum case.");
298        }
299      },
300      _ => Ok(self.font.compute_string_size(&self.get_string()?, options)),
301    }
302  }
303}
304
305impl From<Tbox> for Result<Vec<Digested>> {
306  fn from(tbox: Tbox) -> Result<Vec<Digested>> { Ok(vec![Digested::from(tbox)]) }
307}
308impl From<Tbox> for Option<Digested> {
309  fn from(tbox: Tbox) -> Option<Digested> { Some(Digested::from(tbox)) }
310}
311
312#[cfg(test)]
313mod tests {
314  use super::*;
315
316  #[test]
317  fn tbox_default_has_empty_text() {
318    let t = Tbox::default();
319    assert_eq!(arena::to_string(t.text), "");
320    assert_eq!(t.properties.len(), 0);
321    assert_eq!(t.tokens.len(), 0);
322  }
323
324  #[test]
325  fn tbox_display_of_default_is_empty() {
326    let t = Tbox::default();
327    assert_eq!(format!("{t}"), "");
328  }
329
330  #[test]
331  fn tbox_display_of_text_content() {
332    let t = Tbox {
333      text: arena::pin("hello"),
334      ..Default::default()
335    };
336    assert_eq!(format!("{t}"), "hello");
337  }
338
339  #[test]
340  fn tbox_partial_eq_same_text_same_font() {
341    let a = Tbox::default();
342    let b = Tbox::default();
343    assert_eq!(
344      a, b,
345      "two default Tboxes have same text '' and same text_default font"
346    );
347  }
348
349  #[test]
350  fn tbox_partial_eq_different_text() {
351    let a = Tbox::default();
352    let b = Tbox {
353      text: arena::pin("X"),
354      ..Default::default()
355    };
356    assert_ne!(a, b);
357  }
358
359  #[test]
360  fn tbox_default_font_is_text_default() {
361    let t = Tbox::default();
362    // Font::text_default is the Rc backing the default.
363    assert_eq!(*t.font, Font::text_default());
364  }
365
366  #[test]
367  fn tbox_default_locator_is_default() {
368    let t = Tbox::default();
369    // A Default locator points at the crate source file/line where
370    // Default::default was called; just verify it's not nonsense.
371    let _ = t.locator;
372  }
373}