Skip to main content

latexml_core/
whatsit.rs

1use std::{borrow::Cow, collections::VecDeque, fmt, rc::Rc};
2
3// use std::cell::RefCell;
4use libxml::tree::Node;
5
6use crate::{
7  BoxOps, Digested, DigestedData, TexMode,
8  common::{
9    arena::{self, SymHashMap as HashMap},
10    dimension::Dimension,
11    error::{emit_warn, *},
12    font::Font,
13    locator::Locator,
14    object::Object,
15    store::Stored,
16  },
17  definition::{Definition, Reversion, expandable::Expandable},
18  document::Document,
19  list::List,
20  state::{get_dual_branch, lookup_font},
21  token::{Catcode, Token},
22  tokens::Tokens,
23};
24
25/// Represents a digested object that can generate arbitrary elements in the XML Document.
26#[derive(Clone)]
27pub struct Whatsit {
28  /// arguments
29  pub args:           Vec<Option<Digested>>,
30  /// additional properties, such as font information or sizing
31  pub properties:     HashMap<Stored>,
32  /// the definition responsible for creating this object
33  pub definition:     Rc<dyn Definition>,
34  /// cached tokens for reverting back
35  ///  (note that the "reversion" _property_ is currently also used)
36  ///
37  /// Boxed: this is the memo slot of a reversion cache that Rust cannot yet
38  /// fill — `revert(&self)` has no mutability, so the write-back in `revert`
39  /// stays commented out (Perl does cache here, `Whatsit.pm` L136-138). Held
40  /// inline it charged 24 B to every Whatsit for a value measured `None` on
41  /// 100 % of ~601 K whatsits across five documents. `Option<Box<_>>` is 8 B
42  /// via the null-pointer niche and allocates nothing while `None`, so the slot
43  /// costs almost nothing until the cache is actually wired up (issue #361 M4).
44  pub reversion:      Option<Box<Tokens>>,
45  /// special-case reversion tokens for whatsits representing Dual math
46  /// structures. Boxed for the same reason as [`Whatsit::reversion`] — see there.
47  pub dual_reversion: Option<Box<HashMap<Tokens>>>,
48  /// point of origin in the source file (`None` = not recorded; set under
49  /// `--source-map` at constructor digest, Perl `Constructor.pm` L106)
50  pub locator:        Option<Locator>,
51}
52
53impl Default for Whatsit {
54  fn default() -> Self {
55    Whatsit {
56      args:           Vec::new(),
57      properties:     HashMap::default(),
58      definition:     Rc::new(Expandable::default()),
59      reversion:      None,
60      dual_reversion: None,
61      locator:        None,
62    }
63  }
64}
65impl PartialEq for Whatsit {
66  fn eq(&self, other: &Whatsit) -> bool {
67    // identical definition, argument list and body
68    *self.definition == *other.definition
69      && self.args == other.args
70      && if let Some(Stored::Digested(body1)) = self.properties.get("body") {
71        if let Some(Stored::Digested(body2)) = other.properties.get("body") {
72          *body1 == *body2
73        } else {
74          false
75        }
76      } else {
77        !other.properties.contains_key("body")
78      }
79  }
80}
81
82impl Whatsit {
83  /// checks the "isMath" property was set to true
84  pub fn is_math(&self) -> bool {
85    #[allow(clippy::manual_unwrap_or_default)]
86    match self.properties.get("isMath") {
87      Some(&Stored::Bool(v)) => v,
88      _ => false,
89    }
90  }
91
92  /// A Whatsit is empty if it is marked empty, or space-like, or has an empty body.
93  pub fn is_empty(&self) -> Result<bool> {
94    Ok(
95      // 1. A space-like thing
96      // 2. An environment-like structure with an empty body
97      // TODO: For now it is difficult to pass in a state with an initialized TeX.pool.
98      self.get_property_bool("isEmpty")
99        || self.get_property_bool("isSpace")
100        || (self.get_definition().get_cs_name() == "Begin"
101          && match self.get_body()? {
102            Some(b) => b
103              .unlist_ref()
104              .iter()
105              .all(|inner| inner.is_empty().unwrap_or(false)),
106            None => true,
107          }),
108    )
109  }
110  /// sets a pre-assembled HashMap of properties
111  pub fn set_properties(&mut self, props: HashMap<Stored>) {
112    for (key, value) in props {
113      self.properties.insert_sym(key, value);
114    }
115  }
116  /// accessor for the definition which built this Whatsit
117  pub fn get_definition(&self) -> Rc<dyn Definition> { Rc::clone(&self.definition) }
118  /// accessor for the argument at index `n` (starting from 1)
119  /// Access argument at 1-based index `n` (matching Perl's `$whatsit->getArg(n)`).
120  /// Returns None for n == 0 (defensive — Perl convention uses 1-based indexing).
121  pub fn get_arg(&self, n: usize) -> Option<&Digested> {
122    if n == 0 {
123      emit_warn(
124        "internal",
125        "get_arg",
126        "get_arg(0) called — Perl convention uses 1-based indexing",
127      );
128      return None;
129    }
130    match self.args.get(n - 1) {
131      Some(Some(opt)) => Some(opt),
132      _ => None,
133    }
134  }
135  /// Mutably borrow argument at 1-based index `n` (matching Perl's `$whatsit->getArg(n)`).
136  /// Panics if n == 0 — use 1-based indexing.
137  pub fn get_arg_mut(&mut self, n: usize) -> Option<&mut Digested> {
138    assert!(
139      n > 0,
140      "get_arg_mut() uses 1-based indexing (Perl convention). Use get_arg_mut(1) for the first argument."
141    );
142    match self.args.get_mut(n - 1) {
143      Some(Some(opt)) => Some(opt),
144      _ => None,
145    }
146  }
147  /// accessor for the full list of arguments
148  pub fn get_args(&self) -> &Vec<Option<Digested>> { &self.args }
149  /// Sets the list of arguments for this whatsit (each arg should be `Digested::List`).
150  pub fn set_args(&mut self, args: Vec<Option<Digested>>) { self.args = args; }
151  /// accessor for the `trailer` property. See `whatsit::set_body`
152  pub fn get_trailer(&self) -> Option<Digested> {
153    match self.properties.get("trailer") {
154      Some(Stored::Digested(trailer)) => Some(trailer.clone()),
155      _ => None,
156    }
157  }
158  /// Sets the body of the `whatsit` to the boxes in `body`.
159  /// The last box in `body` is assumed to represent the `trailer`, that is the result of the
160  /// invocation that closed the environment or math.  It is stored separately in the properties
161  /// under "trailer".
162  pub fn set_body(&mut self, mut body: Vec<Digested>) {
163    let trailer_opt = body.pop();
164    // Perl: get mode from whatsit's own properties (not just isMath binary)
165    let mode_opt: Option<String> = self.get_property("mode").and_then(|p| match &*p {
166      Stored::String(s) => Some(arena::to_string(*s)),
167      _ => None,
168    });
169    let mut list = List::new(body);
170    // Set mode from whatsit's own mode property (Perl: $mode from $$self{properties}{mode})
171    if let Some(ref mode_str) = mode_opt {
172      list.set_property("mode", Stored::String(arena::pin(mode_str)));
173      if mode_str.contains("math") {
174        list.mode = Some(TexMode::Math);
175      }
176    } else if self.is_math() {
177      list.mode = Some(TexMode::Math);
178    }
179    self.properties.insert("body", Digested::from(list).into());
180    if let Some(digested) = trailer_opt {
181      self.properties.insert("trailer", digested.clone().into());
182      // Perl `Whatsit.pm` L84: the whatsit's locator becomes the RANGE from its
183      // own start to the trailer's end, so an environment reports the extent it
184      // actually covers instead of collapsing to its `\begin`. Perl writes
185      // `$$self{properties}{locator}`, which is what its inherited
186      // `Box::getLocator` reads (`Box.pm` L85-87); our `get_locator` reads the
187      // struct field, so the field is the faithful sink here — a `"locator"`
188      // property would be inert.
189      //
190      // `new_range` yields `None` when the two ends sit in different sources (an
191      // environment spanning an `\input`), and Perl's `newRange` likewise
192      // declines to fuse them; keep the opening locator in that case rather than
193      // inventing a cross-file span.
194      if let (Some(from), Some(to)) = (self.locator, digested.get_locator())
195        && let Some(range) = Locator::new_range(from, to)
196      {
197        self.locator = Some(range);
198      }
199      // And copy any otherwise undefined properties from the trailer
200      // Perl: copies properties from trailer (typically a Whatsit for \end{...})
201      match digested.data() {
202        DigestedData::Whatsit(trailer) => {
203          let trailer_val = trailer.borrow();
204          let props = trailer_val.get_properties();
205          for (prop, value) in props {
206            self
207              .properties
208              .entry_sym(*prop)
209              .or_insert_with(|| value.clone());
210          }
211        },
212        DigestedData::TBox(tbox) => {
213          let tbox_val = tbox.borrow();
214          let props = tbox_val.get_properties();
215          for (prop, value) in props {
216            self
217              .properties
218              .entry_sym(*prop)
219              .or_insert_with(|| value.clone());
220          }
221        },
222        DigestedData::List(list) => {
223          let list_val = list.borrow();
224          let props = list_val.get_properties();
225          for (prop, value) in props {
226            self
227              .properties
228              .entry_sym(*prop)
229              .or_insert_with(|| value.clone());
230          }
231        },
232        _ => {},
233      }
234    }
235  }
236
237  /// Like Tokens-substituteParameters, but substitutes in the Whatsit's arguments OR properties!
238  /// #<digit> is the standard TeX positional argument
239  /// # followed by a T_OTHER(propname) specifies the property propname!!
240  fn substitute_parameters(&self, spec: Tokens) -> Result<Vec<Token>> {
241    // TODO: This is kind of unfortunate -- I am not sure what are the reasonable "entryways" into
242    // the Whatsit substituteParameters. For Expandable we now have guarantees that "#,i" has
243    // been mapped into a single T_ARG(#i), but not here. so for now run on each call?
244    let mut in_toks = VecDeque::from(spec.unlist());
245    let args = self.get_args();
246    let props = &self.properties;
247    // Pre-size: `result` is at least as long as the template; args
248    // substitute 1:1 or 1:N. Modest over-allocation beats repeated
249    // doublings on reversion of large whatsits.
250    let mut result = Vec::with_capacity(in_toks.len());
251    while let Some(token) = in_toks.pop_front() {
252      if token.get_catcode() != Catcode::ARG {
253        // Non '#'; copy it
254        result.push(token);
255      } else {
256        let arg_opt = token.with_str(|s| {
257          let n = s.parse::<usize>().unwrap() - 1;
258          if n < args.len() {
259            args[n].clone()
260          } else if n < 10 {
261            // `#N` where N ≤ 10 but fewer args were passed.
262            // Perl returns undef; we return None so the arg is simply omitted
263            // from the reversion stream. Fixes out-of-bounds panic when a
264            // reversion template references more params than the call-site
265            // supplied (sandbox paper 0803.4485).
266            None
267          } else {
268            match props.get(s) {
269              Some(Stored::Digested(v)) => Some((*v).clone()),
270              Some(other) => {
271                panic!("unexpected prop in substitute_parameters, needed Digested, got: {other:?}")
272              },
273              None => None,
274            }
275          }
276        });
277        if let Some(arg) = arg_opt {
278          result.extend(arg.revert()?.unlist());
279        }
280      }
281    }
282    Ok(result)
283  }
284}
285
286impl fmt::Debug for Whatsit {
287  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
288    write!(f, "Whatsit[")?;
289    let mut pieces = Vec::new();
290    pieces.push(
291      self
292        .get_definition()
293        .get_cs()
294        .with_cs_name(ToString::to_string),
295    );
296    for arg_opt in self.get_args() {
297      if let Some(arg) = arg_opt {
298        pieces.push(arg.stringify());
299      } else {
300        pieces.push(String::new());
301      }
302    }
303    if self.properties.contains_key("body") {
304      pieces.push(self.properties.get("body").unwrap().to_string());
305      if let Some(trailer) = self.properties.get("trailer") {
306        pieces.push(trailer.to_string());
307      }
308    }
309    write!(f, "{}]", pieces.join(","))
310  }
311}
312
313impl fmt::Display for Whatsit {
314  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.revert().unwrap()) }
315}
316
317impl Object for Whatsit {
318  fn get_locator(&self) -> Option<Locator> { self.locator }
319
320  fn stringify(&self) -> String { format!("{self:?}") }
321
322  fn revert(&self) -> Result<Tokens> {
323    // WARNING: Forbidden knowledge?
324    // (2) caching the reversion (which is a big performance boost)
325    let saved_opt = if let Some(this_branch) = get_dual_branch() {
326      if let Some(ref dual_reversion) = self.dual_reversion {
327        dual_reversion.get(this_branch).cloned()
328      } else {
329        self.reversion.as_deref().cloned()
330      }
331    } else {
332      self.reversion.as_deref().cloned()
333    };
334    if let Some(saved) = saved_opt {
335      return Ok(saved);
336    }
337
338    let mut tokens = Vec::new();
339    let defn = &self.definition;
340    if defn.get_reversion_spec().is_none()
341      && let Some(Stored::Digested(digested)) = self.properties.get("alignment")
342      && let DigestedData::Alignment(alignment) = digested.data()
343    {
344      return alignment.borrow().revert();
345    }
346    // Find the appropriate reversion spec;
347    // content_reversion or presntation_reversion if on dual branch
348    // or (general) reversion, or the reversion from the definition
349    let spec_opt = if let Some(rev) = self.properties.get("reversion") {
350      match rev {
351        Stored::Tokens(tks) => Some(Cow::Owned(Reversion::Tokens(tks.clone()))),
352        Stored::Reversion(rev) => Some(Cow::Borrowed(rev)),
353        other => panic!("TODO: Unexpected reversion directive {other:?}"),
354      }
355    } else {
356      defn.get_reversion_spec().map(Cow::Owned)
357    };
358    let mut is_closure = false;
359    match spec_opt.as_deref() {
360      Some(Reversion::Closure(spec)) => {
361        is_closure = true;
362        let spec_tokens = spec(self, self.get_args()).unwrap();
363        tokens = self.substitute_parameters(spec_tokens)?;
364      },
365      Some(Reversion::Tokens(spec)) => {
366        if !spec.is_empty() {
367          tokens = self.substitute_parameters(spec.clone())?;
368        }
369      },
370      None => {
371        if let Some(alias) = defn.get_alias() {
372          if !alias.is_empty() {
373            // Use From<&str> which maps single characters to their proper catcodes
374            // (e.g. "$" -> T_MATH!(), "{" -> T_BEGIN!(), etc.)
375            // This matches Perl's coerceCS which calls TokenizeInternal for single chars.
376            tokens.push(Token::from(alias.as_str()));
377          }
378        } else {
379          tokens.push(defn.get_cs().into_owned());
380        }
381        if let Some(parameters) = defn.get_parameters() {
382          // Use revert_digested_arguments which checks for digested_reversion
383          // closures on each parameter, allowing parameter types like BoxSpecification
384          // to format their reversion from the structured digested data.
385          // Perl: push(@tokens, $parameters->revertArguments($self->getArgs));
386          tokens.extend(parameters.revert_digested_arguments(self.get_args())?)
387        }
388      },
389    };
390
391    if !is_closure && let Some(body) = self.get_body()? {
392      tokens.extend(body.revert()?.unlist());
393      if let Some(trailer) = self.get_trailer() {
394        tokens.extend(trailer.revert()?.unlist());
395      }
396    }
397
398    // Now cache it, in case it's needed again (Perl does: `Whatsit.pm` L134-138).
399    // TODO: DG: We can't yet cache reversions, because we lack mutability on .revert()
400    //       should we reorganize? is it worth it?
401    //
402    // MEASURED — **not worth it**. Classifying each `revert()` at call time as a
403    // first (a memo would MISS) or a repeat (would HIT) gives the exact hit rate
404    // a cache would achieve:
405    //
406    //   #361 witness, 232 K lines / 11.5 M boxes / 37 s :  78 572 calls,  0.0 % hit
407    //   equality_big (math benchmark)                    :   1 106 calls,  0.0 % hit
408    //   si (siunitx)                                     :  22 728 calls, 63.8 % hit
409    //   mathtools                                        :   1 867 calls, 50.5 % hit
410    //
411    // Repeats track *packages that re-read their arguments* (siunitx, mathtools),
412    // not document scale — and on the large document, the one case where time
413    // actually matters, the cache would never fire once in 78 K reverts. Where it
414    // does hit, the volume is trivial. Perl's "big performance boost" reflects
415    // Perl's per-call cost (interpreted, object-heavy), not call volume; our
416    // per-call cost is far lower, so the same hit rate buys far less. Don't build
417    // this without a payload showing BOTH a high hit rate and a material share of
418    // runtime (`revert` does not appear in the #361 profile's top self-time).
419    //
420    // NB: both slots are `Option<Box<_>>` (issue #361 M4) — keep the `Box::new`
421    // below if this is ever re-enabled.
422    //
423    // if let Some(this_branch) = state!().get_dual_branch() {
424    //   if self.dual_reversion.is_none() {
425    //     self.dual_reversion = Some(Box::new(HashMap::default()));
426    //   }
427    //   self.dual_reversion.as_mut().unwrap()
428    //     .insert(this_branch.to_string(), Tokens::new(tokens.clone()));
429    // } else {
430    //   self.reversion = Some(Box::new(Tokens::new(tokens.clone())));
431    // }
432    Ok(Tokens::new(tokens))
433  }
434}
435
436impl BoxOps for Whatsit {
437  fn get_properties(&self) -> &HashMap<Stored> { &self.properties }
438  fn with_properties<R, FnR>(&self, caller: FnR) -> R
439  where FnR: FnOnce(&HashMap<Stored>) -> R {
440    caller(&self.properties)
441  }
442  fn get_properties_mut(&mut self) -> &mut HashMap<Stored> { &mut self.properties }
443  fn get_property(&self, key: &str) -> Option<Cow<'_, Stored>> {
444    self.properties.get(key).map(Cow::Borrowed)
445  }
446  fn get_property_mut(&mut self, key: &str) -> Option<&mut Stored> { self.properties.get_mut(key) }
447  fn get_string(&self) -> Result<Cow<'_, str>> { Ok(Cow::Owned(self.revert()?.to_string())) }
448
449  fn be_absorbed(&self, document: &mut Document) -> Result<Vec<Node>> {
450    // Significant time is consumed here, and associated with a specific CS,
451    // so we should be profiling as well!
452    // Hopefully the csname is the same that was charged in the digestioned phase!
453
454    // my $profiled = $state->lookupValue('PROFILING') && $defn->getCS;
455    // LaTeXML::Definition::startProfiling($profiled, 'absorb') if $profiled;
456    // info!(target:"whatsit:be_absorbed", "{:?}", self);
457
458    self.definition.do_absorption(document, self)
459    // LaTeXML::Definition::stopProfiling($profiled, 'absorb') if $profiled;
460  }
461  fn get_body(&self) -> Result<Option<Digested>> {
462    Ok(match self.properties.get("body") {
463      Some(Stored::Digested(body)) => Some(body.clone()),
464      _ => None,
465    })
466  }
467
468  fn get_font(&self) -> Result<Option<Rc<Font>>> {
469    match self.properties.get("font") {
470      Some(Stored::Font(font)) => Ok(Some(Rc::clone(font))),
471      Some(Stored::FontDirective(fd)) => fd.get_font(Some(self)).map(Some),
472      _ => Ok(None),
473    }
474  }
475
476  fn set_font(&mut self, font: Rc<Font>) { self.properties.insert("font", Stored::Font(font)); }
477
478  fn compute_size(
479    &self,
480    mut options: HashMap<Stored>,
481  ) -> Result<(Dimension, Dimension, Dimension)> {
482    let defn = self.get_definition();
483    match defn.get_sizer() {
484      Some(sizer) => sizer(self),
485      _ => {
486        if self.has_property("cached_width") || self.has_property("cached_height") {
487          // Perl: when after_digest sets cached dimensions (e.g. image_graphicx_sizer),
488          // compute_size should return them instead of falling through to body/args sum.
489          let w = match self.get_property("cached_width").as_deref() {
490            Some(Stored::Dimension(d)) => *d,
491            _ => Dimension::default(),
492          };
493          let h = match self.get_property("cached_height").as_deref() {
494            Some(Stored::Dimension(d)) => *d,
495            _ => Dimension::default(),
496          };
497          let d = match self.get_property("cached_depth").as_deref() {
498            Some(Stored::Dimension(d)) => *d,
499            _ => Dimension::default(),
500          };
501          Ok((w, h, d))
502        } else {
503          // Nothing specified? use #body if any, else sum all box args
504          // Perl: Whatsit.pm L252-255 — if body exists, pass it to computeBoxesSize
505          // which unlists it internally (Font.pm L650-651). We replicate by extracting
506          // properties from the body (mode, vattach, width) into options, then unlisting.
507          let mut boxes = Vec::new();
508          if let Some(body_stored) = self.get_property("body")
509            && let Stored::Digested(ref body) = *body_stored
510          {
511            // Perl: computeBoxesSize reads mode/vattach/width from $boxes before unlisting
512            for key in &["mode", "vattach", "width"] {
513              if options.get(key).is_none()
514                && let Some(prop) = body.get_property(key)
515              {
516                options.insert(key, (*prop).clone());
517              }
518            }
519            let unlist_boxes = body.unlist();
520            boxes.extend(unlist_boxes);
521          }
522          if boxes.is_empty() {
523            // no body
524            for arg in self.args.iter().flatten() {
525              boxes.extend(arg.unlist());
526            }
527          }
528          let font = match *self.get_property("font").unwrap() {
529            Stored::Font(ref sf) => sf.clone(),
530            _ => lookup_font().unwrap(),
531          };
532          font.compute_boxes_size(&boxes, options)
533        }
534      },
535    }
536  }
537}
538
539#[cfg(test)]
540mod tests {
541  use super::*;
542
543  #[test]
544  fn whatsit_default_has_empty_args_and_properties() {
545    let w = Whatsit::default();
546    assert_eq!(w.args.len(), 0);
547    assert_eq!(w.properties.len(), 0);
548    assert!(w.reversion.is_none());
549    assert!(w.dual_reversion.is_none());
550  }
551
552  #[test]
553  fn is_math_false_by_default() {
554    let w = Whatsit::default();
555    assert!(!w.is_math());
556  }
557
558  #[test]
559  fn is_math_reads_bool_property() {
560    let mut w = Whatsit::default();
561    w.properties.insert("isMath", Stored::Bool(true));
562    assert!(w.is_math());
563    w.properties.insert("isMath", Stored::Bool(false));
564    assert!(!w.is_math());
565  }
566
567  #[test]
568  fn is_math_non_bool_is_false() {
569    // If the property exists but isn't a Bool, is_math reports false.
570    let mut w = Whatsit::default();
571    w.properties.insert("isMath", Stored::Int(1));
572    assert!(!w.is_math());
573  }
574
575  #[test]
576  fn get_arg_zero_returns_none_and_warns() {
577    let w = Whatsit::default();
578    assert!(w.get_arg(0).is_none());
579  }
580
581  #[test]
582  fn get_arg_out_of_range_returns_none() {
583    let w = Whatsit::default();
584    assert!(w.get_arg(1).is_none(), "empty args vec");
585    assert!(w.get_arg(100).is_none());
586  }
587
588  #[test]
589  fn set_args_stores_vec() {
590    let mut w = Whatsit::default();
591    w.set_args(vec![None, None, None]);
592    assert_eq!(w.args.len(), 3);
593  }
594
595  #[test]
596  fn set_properties_merges_into_existing() {
597    let mut w = Whatsit::default();
598    let mut extra = HashMap::default();
599    extra.insert("foo", Stored::Bool(true));
600    extra.insert("bar", Stored::Int(42));
601    w.set_properties(extra);
602    assert_eq!(w.properties.len(), 2);
603  }
604
605  #[test]
606  fn whatsit_default_equality() {
607    let a = Whatsit::default();
608    let b = Whatsit::default();
609    assert_eq!(a, b);
610  }
611
612  #[test]
613  fn get_trailer_none_by_default() {
614    let w = Whatsit::default();
615    assert!(w.get_trailer().is_none());
616  }
617}