Skip to main content

latexml_core/binding/counter/
dialect.rs

1//! # Counters
2//!
3//! This is modelled on LaTeX's counter mechanisms, but since it also
4//! provides support for ID's, even where there is no visible reference number,
5//! it is defined in general.
6//! These id's should be both unique, and parallel the visible reference numbers
7//! (as much as possible).  Also, for consistency, we add id's to unnumbered
8//! document elements (eg from \section*); this requires an additional counter
9//! (eg. UNsection) and  mechanisms to track it.
10
11use std::{collections::VecDeque, rc::Rc};
12
13use crate::{
14  BoxOps,
15  binding::{
16    content::{build_invocation, digest_literal, digest_text},
17    def::dialect::{RegisterOptions, def_macro, def_register, is_defined},
18  },
19  common::{
20    arena::{self, SymHashMap as HashMap, SymStr},
21    cleaners::{clean_id, clean_label, roman_aux},
22    error::*,
23    number::Number,
24    numeric_ops::NumericOps,
25  },
26  definition::{Definition, ExpansionBody, expandable::ExpandableOptions},
27  mouth, state,
28  state::*,
29  stomach,
30  token::*,
31  tokens::{TeXString, Tokens},
32  whatsit::Whatsit,
33};
34
35/// configuration for new_counter
36#[derive(Default)]
37pub struct NewCounterOptions<'ct> {
38  /// specifies a prefix to be used in formatting ID's for document structure elements
39  /// counted by this counter.  Ie. subsection 3 in section 2 might get: id="S2.SS3"
40  pub idprefix: &'ct str,
41  /// specifies that the ID is composed from $idwithin's ID,, even though
42  /// the counter isn't numbered within it.  (mainly to avoid duplicated ids)
43  pub idwithin: &'ct str,
44  /// a list of counters that correspond to scopes which are "inside" this one.
45  /// Whenever any definitions scoped to this counter are deactivated,
46  /// the inner counter's scopes are also deactivated.
47  // NOTE: I'm not sure this is even a sensible implementation,
48  // or why inner should be different than the counters reset by incrementing this counter.
49  pub nested: Vec<&'ct str>,
50}
51
52/// Defines a new counter named $ctr.
53/// If `within` is defined, `ctr` will be reset whenever `within` is incremented.
54pub fn new_counter(ctr: &str, within: &str, options_opt: Option<NewCounterOptions>) -> Result<()> {
55  let unctr = s!("UN{ctr}"); // UNctr is counter for generating ID's for UN-numbered items.
56  if !within.is_empty()
57    && within != "document"
58    && lookup_definition(&T_CS!(s!("\\c@{within}")))?.is_none()
59  {
60    new_counter(within, "", None)?;
61  }
62  let cctr = s!("\\c@{ctr}");
63  let clctr = s!("\\cl@{ctr}");
64  let cunctr = s!("\\c@{unctr}");
65  let clunctr = s!("\\cl@{unctr}");
66  // Perl Package.pm L660-672: Check if counter already defined. Skip register if already a
67  // Register. Warn if previously defined as something other than \relax.
68  let cs_cctr = T_CS!(&cctr);
69  let prev_defn = lookup_definition(&cs_cctr)?;
70  if let Some(ref defn) = prev_defn {
71    if defn.is_register() {
72      // Counter already exists as a register — fine, just continue (may change within/nesting)
73    } else {
74      // Warn unless the previous definition was \relax
75      let relax_meaning = lookup_meaning(&T_RELAX!());
76      let prev_meaning = lookup_meaning(&cs_cctr);
77      if prev_meaning != relax_meaning {
78        Warn!(
79          "unexpected",
80          &cctr,
81          s!("Counter {} was already defined; redefining", cctr)
82        );
83      }
84      def_register(
85        cs_cctr,
86        None,
87        Number::new(0),
88        Some(RegisterOptions {
89          allocate: Some(String::from("\\count")),
90          ..RegisterOptions::default()
91        }),
92      )?;
93    }
94  } else {
95    def_register(
96      cs_cctr,
97      None,
98      Number::new(0),
99      Some(RegisterOptions {
100        allocate: Some(String::from("\\count")),
101        ..RegisterOptions::default()
102      }),
103    )?;
104  }
105  after_assignment();
106  if !has_value(&clctr) {
107    assign_value(&clctr, Tokens!(), Some(Scope::Global));
108  }
109  def_register(T_CS!(&cunctr), None, Number::new(0), None)?;
110  if !has_value(&clunctr) {
111    assign_value(&clunctr, Tokens!(), Some(Scope::Global));
112  }
113
114  if !within.is_empty() {
115    let clwithin = s!("\\cl@{within}");
116    let clunwithin = s!("\\cl@UN{within}");
117    let x = if let Some(cl) = lookup_tokens(&clwithin) {
118      cl.unlist()
119    } else {
120      Vec::new()
121    };
122    let mut clwithin_tokens = vec![T_CS!(ctr), T_CS!(&unctr)];
123    clwithin_tokens.extend(x);
124    assign_value(
125      &clwithin,
126      Stored::Tokens(Tokens::new(clwithin_tokens)),
127      Some(Scope::Global),
128    );
129
130    let mut unx = if let Some(clun) = lookup_tokens(&clunwithin) {
131      clun.unlist()
132    } else {
133      Vec::new()
134    };
135    let mut clunwithin_tokens = vec![T_CS!(unctr)];
136    clunwithin_tokens.append(&mut unx);
137
138    assign_value(
139      &clunwithin,
140      Stored::Tokens(Tokens::new(clunwithin_tokens)),
141      Some(Scope::Global),
142    )
143  }
144
145  if let Some(ref options) = options_opt
146    && !options.nested.is_empty()
147  {
148    assign_value(
149      &s!("nested_counters_{}", ctr),
150      options.nested.clone(),
151      Some(Scope::Global),
152    )
153  }
154
155  // default is equivalent to \arabic{ctr}, but w/o using the LaTeX macro!
156  let ctr_string = ctr.to_string();
157  def_macro(
158    T_CS!(s!("\\the{}", ctr)),
159    None,
160    Some(ExpansionBody::Closure(Rc::new(move |_args| {
161      let counter_value = counter_value(&ctr_string)?.value_of();
162      Ok(Tokens::new(ExplodeText!(counter_value)))
163    }))),
164    Some(ExpandableOptions {
165      scope: Some(Scope::Global),
166      ..ExpandableOptions::default()
167    }),
168  )?;
169  let p_ctr_cs = T_CS!(&s!("\\p@{}", ctr));
170  if lookup_definition(&p_ctr_cs)?.is_none() {
171    def_macro(
172      p_ctr_cs,
173      None,
174      Tokens::default(),
175      Some(ExpandableOptions {
176        scope: Some(Scope::Global),
177        ..ExpandableOptions::default()
178      }),
179    )?;
180  }
181
182  let mut prefix = match options_opt {
183    None => String::new(),
184    Some(ref opt) => opt.idprefix.to_string(),
185  };
186  if !prefix.is_empty() {
187    assign_value(
188      &s!("@ID@prefix@{}", ctr),
189      prefix.clone(),
190      Some(Scope::Global),
191    );
192  } else {
193    prefix = lookup_string(&s!("@ID@prefix@{}", ctr));
194    if prefix.is_empty() {
195      prefix = ctr.to_string();
196    }
197  }
198  prefix = clean_id(&prefix);
199
200  if !prefix.is_empty() {
201    let idwithin = match options_opt {
202      Some(ref opts) => {
203        if opts.idwithin.is_empty() {
204          within
205        } else {
206          opts.idwithin
207        }
208      },
209      None => within,
210    }
211    .to_string();
212
213    let ctr_string = ctr.to_string();
214    let thectrid = s!("\\the{}@ID", ctr);
215    if !idwithin.is_empty() {
216      def_macro(
217        T_CS!(thectrid),
218        None,
219        Some(ExpansionBody::Closure(Rc::new(move |_args| {
220          // Perl Package.pm L696 probes `\lx@empty`, NOT `\@empty` — and
221          // deliberately so: `\@empty` is LaTeX-pool-only (latex_base.rs
222          // aliases it to `\lx@empty`), while `\lx@empty` is engine-level
223          // (base_schema.rs) and exists in PLAIN TeX documents too. With
224          // `\@empty` here, a plain-TeX document's `\the<ctr>@ID` probe
225          // compared `\thedocument@ID` (an alias of `\lx@empty`) against an
226          // UNDEFINED `\@empty` → unequal → the \else branch re-expands the
227          // parent's @ID formatter, spinning the gullet into a 4-token
228          // expansion cycle (caught by the cycle guard as a phantom
229          // `Fatal:Timeout:Recursion`, swallowed inside math-parser
230          // semantics — `create_xmrefs`/`get_xmarg_id`). Witness
231          // math0402448 (plain TeX + 3464 formulae): "Conversion failed:
232          // 1 fatal error" with no Fatal: line in the log.
233          Ok(mouth::tokenize_internal(TeXString::assembled(s!(
234            "\\expandafter\\ifx\\csname the{}@ID\\endcsname\\lx@empty\\else\\csname the{}@ID\\endcsname.\\fi {}\\csname @{}@ID\\endcsname",
235            idwithin,
236            idwithin,
237            prefix,
238            ctr_string
239          ))))
240        }))),
241        Some(ExpandableOptions {
242          scope: Some(Scope::Global),
243          ..ExpandableOptions::default()
244        }),
245      )?;
246    } else {
247      def_macro(
248        T_CS!(thectrid),
249        None,
250        Some(ExpansionBody::Closure(Rc::new(move |_args| {
251          Ok(mouth::tokenize_internal(TeXString::assembled(s!(
252            "{prefix}\\csname @{ctr_string}@ID\\endcsname",
253          ))))
254        }))),
255        Some(ExpandableOptions {
256          scope: Some(Scope::Global),
257          ..ExpandableOptions::default()
258        }),
259      )?;
260    }
261    def_macro(
262      T_CS!(s!("\\@{}@ID", ctr)),
263      None,
264      Some(ExpansionBody::Tokens(Tokens!(T_OTHER!("0")))),
265      Some(ExpandableOptions {
266        scope: Some(Scope::Global),
267        ..ExpandableOptions::default()
268      }),
269    )?;
270  }
271
272  Ok(())
273}
274/// Fetches the value associated with the counter C<$ctr>.
275pub fn counter_value(ctr: &str) -> Result<Number> {
276  match lookup_register(&s!("\\c@{ctr}"), Vec::new())? {
277    None => {
278      // Perl Package.pm L712 quotes the counter name: `Counter '$ctr' ...`.
279      let message = s!("Counter '{}' was not defined; assuming 0", ctr);
280      Warn!("undefined", ctr, message);
281      Ok(Number::new(0))
282    },
283    Some(value) => Ok(Number::new(value.value_of())),
284  }
285}
286/// increments a named counter by a `Number`
287pub fn add_to_counter(ctr: &str, value: Number) -> Result<()> {
288  let v = counter_value(ctr)?.add(value);
289  assign_register(&s!("\\c@{ctr}"), v.into(), Some(Scope::Global), Vec::new())?;
290  after_assignment();
291  let id_cs = T_CS!(s!("\\@{ctr}@ID"));
292  def_macro(
293    id_cs,
294    None,
295    Tokens::new(Explode!(v.value_of())),
296    Some(ExpandableOptions {
297      scope: Some(Scope::Global),
298      ..ExpandableOptions::default()
299    }),
300  )
301}
302
303/// Analog of `\stepcounter`, steps the counter and returns the expansion of
304/// `\the$ctr`  Usually you should use `ref_step_counter(ctr)` instead.
305pub fn step_counter(ctr: &str, noreset: bool) -> Result<()> {
306  let value = counter_value(ctr)?;
307  let newvalue = value.add(Number::new(1));
308  let c_ctr = s!("\\c@{ctr}");
309  assign_register(&c_ctr, newvalue.into(), Some(Scope::Global), Vec::new())?;
310  after_assignment();
311  let token_value = Tokens::new(Explode!(newvalue.value_of()));
312  def_macro(
313    T_CS!(s!("\\@{ctr}@ID")),
314    None,
315    token_value,
316    Some(ExpandableOptions {
317      scope: Some(Scope::Global),
318      ..ExpandableOptions::default()
319    }),
320  )?;
321
322  // and reset any within counters!
323  if !noreset && let Some(nested) = lookup_tokens(&s!("\\cl@{ctr}")) {
324    for c in nested.unlist() {
325      reset_counter(&c)?;
326    }
327  }
328  Ok(())
329}
330
331/// Analog of `\refstepcounter`, steps the counter and returns a hash
332/// containing the keys `refnum` and `id`.
333///
334/// This makes it
335/// suitable for use in a `properties` option to constructors.
336/// The `id` is generated in parallel with the reference number
337/// to assist debugging.
338// TODO: Maybe these should be specialized types in Rust, rather than hashmaps?
339pub fn ref_step_counter(ctype: &str, noreset: bool) -> Result<HashMap<Stored>> {
340  // Defensive: under some upstream conditions the {} parameter reader pulls a
341  // trailing `\par` (or similar trailing CS) into a counter-type identifier
342  // before it reaches us (Cluster A: math0010095, hep-ph0204075). Strip it
343  // here so the downstream `\csname @<ctype>...@ID\endcsname` and similar
344  // constructions stay well-formed. We strip the same well-known sentinels
345  // as latex_constructs::strip_trailing_cs.
346  let ctype = {
347    let mut s = ctype;
348    for tail in ["\\par", "\\@startsection@hook", "\\relax"] {
349      if let Some(stripped) = s.strip_suffix(tail) {
350        s = stripped;
351        break;
352      }
353    }
354    s
355  };
356  let ctr = with_mapping("counter_for_type", ctype, |meaning| match meaning {
357    Some(Stored::String(ctr)) => arena::to_string(*ctr),
358    _ => ctype.to_string(),
359  });
360  step_counter(&ctr, noreset)?;
361  maybe_preempt_refnum(&ctr, false);
362
363  let the_ctr_id = s!("\\the{ctr}@ID");
364  let the_ctr = s!("\\the{ctr}");
365
366  let has_id: bool = match lookup_definition(&T_CS!(&the_ctr_id))? {
367    Some(iddef) => {
368      if let Some(params) = iddef.get_parameters() {
369        params.get_num_args() == 0
370      } else {
371        true
372      }
373    },
374    _ => false,
375  };
376
377  let the_ctr_cs = T_CS!(&the_ctr);
378  let the_ctr_id_cs = T_CS!(&the_ctr_id);
379  def_macro(
380    T_CS!("\\@currentlabel"),
381    None,
382    the_ctr_cs,
383    Some(ExpandableOptions {
384      scope: Some(Scope::Global),
385      ..ExpandableOptions::default()
386    }),
387  )?;
388  if has_id {
389    def_macro(
390      T_CS!("\\@currentID"),
391      None,
392      the_ctr_id_cs,
393      Some(ExpandableOptions {
394        scope: Some(Scope::Global),
395        ..ExpandableOptions::default()
396      }),
397    )?;
398  }
399
400  let id = if has_id {
401    digest_literal(Tokens!(T_CS!(&the_ctr_id)))?.to_string()
402  } else {
403    String::new()
404  };
405
406  let refnum = digest_text(Tokens!(T_CS!(&the_ctr)))?;
407  let invocation;
408  {
409    invocation = build_invocation(T_CS!("\\lx@make@tags"), vec![Some(Tokens!(T_OTHER!(
410      ctype
411    )))])?;
412  }
413
414  let tags = stomach::digest(invocation)?;
415
416  // Any scopes activated for previous value of this counter (& any nested counters) must be
417  // removed. This may also include scopes activated for \label
418  deactivate_counter_scope(arena::pin(&ctr));
419
420  // And install the scope (if any) for this reference number.
421  assign_value("current_counter", ctr.clone(), Some(Scope::Local));
422
423  let scope = arena::pin(format!("{ctr}:{refnum}"));
424  let mut receiver = VecDeque::new();
425  receiver.push_front(Stored::String(scope));
426  assign_value(
427    &s!("scopes_for_counter:{ctr}"),
428    receiver,
429    Some(Scope::Local),
430  );
431  activate_scope(scope);
432
433  Ok(stored_map!(
434    "tags" => Stored::Digested(tags),
435    "id" => Stored::String(arena::pin(id))
436  ))
437}
438
439/// Internal: Use a label-derived reference number and/or ID
440/// instead of the traditional counter based ones.
441/// Since the \label{} determins the reference number and ID,
442/// we MUST sniff out the label BEFORE we call RefStepCounter/RefStepID !!!!!
443/// (see MaybePeekLabel below; and also MaybeNoteLabel for use within
444/// captions & certain equation environments)
445/// Assign a sub to LABEL_MAPPING_HOOK: &sub($label,$counter,$norefnum)
446/// to return the desired refnum and id for a given object.
447fn maybe_preempt_refnum(ctr: &str, norefnum: bool) {
448  if let Some(mapper) = get_label_mapping_hook() {
449    let hj_refnum = T_CS!(s!("\\_PREEMPTED_REFNUM_{ctr}"));
450    let hj_id = T_CS!(s!("\\_PREEMPTED_ID_{ctr}"));
451    // First, restore the \the<ctr> and \the<ctr>@ID macros to defaults
452    if !norefnum && has_meaning(&hj_refnum) {
453      let_i(&T_CS!(s!("\\the{ctr}")), &hj_refnum, Some(Scope::Global));
454    }
455    if has_meaning(&hj_id) {
456      let_i(&T_CS!(s!("\\the{ctr}@ID")), &hj_id, Some(Scope::Global));
457    }
458    let label = lookup_string("PEEKED_LABEL");
459    let (fixedrefnum, fixedid) = mapper(&label, ctr, norefnum);
460    if let Some(refnum) = fixedrefnum
461      && !norefnum
462    {
463      if !has_meaning(&hj_refnum) {
464        // Save for later
465        let_i(&hj_refnum, &T_CS!(s!("\\the{ctr}")), Some(Scope::Global));
466      }
467      let _ = def_macro(
468        T_CS!(s!("\\the{ctr}")),
469        None,
470        ExpansionBody::Tokens(Tokens::new(Explode!(&refnum))),
471        Some(ExpandableOptions {
472          scope: Some(Scope::Global),
473          ..Default::default()
474        }),
475      );
476    }
477    if let Some(id) = fixedid {
478      if !has_meaning(&hj_id) {
479        // Save for later
480        let_i(&hj_id, &T_CS!(s!("\\the{ctr}@ID")), Some(Scope::Global));
481      }
482      let _ = def_macro(
483        T_CS!(s!("\\the{ctr}@ID")),
484        None,
485        ExpansionBody::Tokens(Tokens::new(Explode!(&id))),
486        Some(ExpandableOptions {
487          scope: Some(Scope::Global),
488          ..Default::default()
489        }),
490      );
491    }
492    remove_value("PEEKED_LABEL"); // CONSUME the label
493    assign_value(
494      "PROCESSED_LABEL",
495      Stored::String(arena::pin(label)),
496      Some(Scope::Global),
497    );
498  }
499}
500
501/// Use to peek for FOLLOWING \label{...} to support label-derived reference numbers
502/// (Perl: MaybePeekLabel)
503pub fn maybe_peek_label() -> Result<()> {
504  if get_label_mapping_hook().is_some() {
505    let peek = crate::gullet::read_non_space()?;
506    if let Some(ref token) = peek {
507      if x_equals(token, &T_CS!("\\label")) {
508        begin_semiverbatim(None);
509        let arg = crate::gullet::read_arg(crate::gullet::ExpansionLevel::Off)?;
510        end_semiverbatim()?;
511        let arg_str = arg.to_string();
512        let label = clean_label(&arg_str, Some("")).into_owned();
513        assign_value(
514          "PEEKED_LABEL",
515          Stored::String(arena::pin(&label)),
516          Some(Scope::Global),
517        );
518        // Put back the arg wrapped in braces so \label can re-read it
519        crate::gullet::unread(Tokens!(T_BEGIN!(), arg, T_END!()));
520      } else {
521        remove_value("PROCESSED_LABEL");
522        remove_value("PEEKED_LABEL");
523      }
524    }
525    if let Some(token) = peek {
526      crate::gullet::unread_one(token);
527    }
528  }
529  Ok(())
530}
531
532/// Use to note a discovered label to support label-derived refererence numbers
533/// Can by used by \label, among others. Note we only record the label
534/// if it hasn't already been peeked, and consumed.
535pub fn maybe_note_label(label: &str) {
536  if get_label_mapping_hook().is_some() {
537    let label = clean_label(label, Some(""));
538    let processed = lookup_string("PROCESSED_LABEL");
539    if processed.is_empty() || processed != label {
540      // Only if not already processed
541      remove_value("PROCESSED_LABEL");
542      assign_value(
543        "PEEKED_LABEL",
544        Stored::String(arena::pin(label)),
545        Some(Scope::Global),
546      );
547    }
548  }
549}
550
551fn deactivate_counter_scope(ctr: SymStr) {
552  let (scopes_for_counter, nested_counters) = arena::with(ctr, |cstr| {
553    (
554      s!("scopes_for_counter:{cstr}"),
555      s!("nested_counters_{cstr}"),
556    )
557  });
558  // with_value avoids the outer Stored::clone by reading through a
559  // borrow; we still collect the scope SymStrs (Copy) or panic-pointers
560  // into owned Vecs to outlive the borrow.
561  let scope_syms: Vec<SymStr> = with_value(&scopes_for_counter, |v| match v {
562    Some(Stored::VecDequeStored(stored_scopes)) => stored_scopes
563      .iter()
564      .map(|s| match s {
565        Stored::String(scope) => *scope,
566        _ => panic!("assignment scopes should be stored as strings, got: {s:?}"),
567      })
568      .collect(),
569    _ => Vec::new(),
570  });
571  for scope in scope_syms {
572    deactivate_scope(scope);
573  }
574
575  // TODO: if we ever want to unshift from the nested_counters, we'll need to also use
576  // Stored::VecDequeStored for them.
577  let inner_ctrs: Vec<SymStr> = with_value(&nested_counters, |v| match v {
578    Some(Stored::Strings(stored_counters)) => stored_counters.iter().copied().collect(),
579    _ => Vec::new(),
580  });
581  for inner_ctr in inner_ctrs {
582    deactivate_counter_scope(inner_ctr);
583  }
584}
585
586/// For UN-numbered units.
587/// Like `RefStepCounter`, but only steps the "uncounter",
588/// and returns only the id;  This is useful for unnumbered cases
589/// of objects that normally get both a refnum and id.
590pub fn ref_step_id(ctype: &str) -> Result<HashMap<Stored>> {
591  let ctr = with_mapping("counter_for_type", ctype, |mapping| match mapping {
592    Some(map) => map.to_string(),
593    None => ctype.to_string(),
594  });
595  let unctr = s!("UN{ctr}");
596  // Perl Package.pm L863-864 ("Avoid fatals..."): if `\c@UN<ctr>` isn't
597  // defined as a register, `NewCounter(ctr)` creates both `\c@<ctr>` and
598  // `\c@UN<ctr>` and the associated `\the<ctr>@ID` expansion. Without it,
599  // unnumbered-section callers like `\specialsection*{}` (which amsart
600  // Let's to `\chapter*{}` even though amsart has no chapter counter) hit
601  // Error:undefined:\thechapter@ID because the counter was never created.
602  let unctr_cmd = s!("\\c@{unctr}");
603  let unctr_defined = lookup_register(&unctr_cmd, Vec::new())
604    .ok()
605    .flatten()
606    .is_some();
607  if !unctr_defined {
608    let _ = new_counter(&ctr, "document", None);
609  }
610  step_counter(&unctr, false)?;
611  maybe_preempt_refnum(&ctr, true);
612  let cunctr_val = lookup_number(&s!("\\c@{unctr}"))
613    .unwrap_or_default()
614    .value_of();
615  def_macro(
616    T_CS!(s!("\\@{ctr}@ID")),
617    None,
618    Tokens!(T_OTHER!("x"), Explode!(cunctr_val)),
619    Some(ExpandableOptions {
620      scope: Some(Scope::Global),
621      ..ExpandableOptions::default()
622    }),
623  )?;
624
625  let the_ctr_id = s!("\\the{ctr}@ID");
626  def_macro(T_CS!("\\@currentID"), None, T_CS!(&the_ctr_id), None)?;
627  Ok(stored_map!("id" =>
628    clean_id(&digest_literal(T_CS!(the_ctr_id))?.to_string())))
629}
630
631/// Recycle the last ID without incrementing (Perl: RefCurrentID)
632/// Useful if the last ID-ed box got pruned.
633pub fn ref_current_id(ctype: &str) -> Result<HashMap<Stored>> {
634  let ctr = with_mapping("counter_for_type", ctype, |mapping| match mapping {
635    Some(map) => map.to_string(),
636    None => ctype.to_string(),
637  });
638  let the_ctr_id = s!("\\the{ctr}@ID");
639  let id = clean_id(&digest_literal(T_CS!(the_ctr_id))?.to_string());
640  Ok(stored_map!("id" => id))
641}
642
643/// Resets the counter `ctr` to zero.
644pub fn reset_counter(ctr: &Token) -> Result<()> {
645  let (c_ctr, c_un_ctr, ctr_id) =
646    ctr.with_str(|ctr| (s!("\\c@{ctr}"), s!("\\c@UN{ctr}"), s!("\\@{ctr}@ID")));
647  assign_register(
648    &c_ctr,
649    Number::new(0).into(),
650    Some(Scope::Global),
651    Vec::new(),
652  )?;
653  if !ctr.with_str(|cstr| cstr.starts_with("UN")) {
654    // but not UN
655    assign_register(
656      &c_un_ctr,
657      Number::new(0).into(),
658      Some(Scope::Global),
659      Vec::new(),
660    )?;
661  }
662  def_macro(
663    T_CS!(ctr_id),
664    None,
665    Tokens!(T_OTHER!("0")),
666    Some(ExpandableOptions {
667      scope: Some(Scope::Global),
668      ..ExpandableOptions::default()
669    }),
670  )?;
671  // and reset any within counters!
672  if let Some(nested) = lookup_tokens(&s!("\\cl@{ctr}")) {
673    for c in nested.unlist() {
674      reset_counter(&c)?;
675    }
676  }
677  Ok(())
678}
679
680/// Create id, and tags for an itemize type \item
681pub fn ref_step_item_counter(tag_opt: Option<&Tokens>) -> Result<HashMap<Stored>> {
682  let counter = lookup_string("itemcounter");
683  let n = lookup_int("itemization_items");
684  assign_value("itemization_items", n + 1, None);
685  let mut attr: HashMap<Stored> = HashMap::default();
686  if n > 0
687    && let Some(sep) = lookup_dimension("\\itemsep")
688  {
689    let default_opt = lookup_dimension("\\lx@default@itemsep");
690    if default_opt.is_none() || sep.value_of() != default_opt.unwrap().value_of() {
691      attr.insert("itemsep", sep.into());
692    }
693  }
694
695  let mut result = if let Some(tag) = tag_opt {
696    let mut props = ref_step_id(&counter)?;
697    if tag.is_empty() {
698      return Ok(props);
699    }
700    let formatter = if counter.starts_with("@desc") {
701      T_CS!("\\descriptionlabel")
702    } else {
703      T_CS!("\\makelabel")
704    };
705    let counter_name = s!("\\{counter}name");
706    let typename = if is_defined(&counter_name) {
707      T_CS!(counter_name)
708    } else {
709      T_CS!("\\itemtyperefname")
710    };
711
712    let mut tag_tokens = vec![
713      T_BEGIN!(),
714      T_CS!("\\let"),
715      T_CS!(s!("\\the{counter}")),
716      T_CS!("\\@empty"),
717      T_CS!("\\def"),
718      T_CS!(s!("\\fnum@{counter}")),
719      T_BEGIN!(),
720      formatter,
721      T_BEGIN!(),
722    ];
723    // TODO: Another iffy clone...
724    let reverted_tag = (*tag).clone().revert();
725    tag_tokens.extend(reverted_tag.clone());
726    tag_tokens.extend(vec![
727      T_END!(),
728      T_END!(),
729      T_CS!("\\def"),
730      T_CS!(s!("\\typerefnum@{counter}")),
731      T_BEGIN!(),
732      typename,
733      T_SPACE!(),
734    ]);
735    tag_tokens.extend(reverted_tag);
736    tag_tokens.push(T_END!());
737    tag_tokens.extend(
738      build_invocation(T_CS!("\\lx@make@tags"), vec![Some(Tokens!(T_OTHER!(
739        counter
740      )))])?
741      .unlist(),
742    );
743    tag_tokens.push(T_END!());
744
745    let tags = stomach::digest(tag_tokens)?;
746    if !tags.is_empty()? {
747      props.insert("tags", tags.into());
748    }
749    props
750  } else {
751    ref_step_counter(&counter, false)?
752  };
753  for (k, v) in attr.into_iter() {
754    result.insert_sym(k, v);
755  }
756  Ok(result)
757}
758
759/// configuration for begin_itemize
760#[derive(Debug, Default, Clone)]
761pub struct BeginItemizeOptions {
762  /// disable nested id suffix based on stacking level
763  pub nolevel:     bool,
764  /// enumitem series
765  pub series:      Option<Tokens>,
766  /// start at a custom value
767  pub start:       Option<Number>,
768  /// enumitem resume?
769  pub resume:      Option<String>,
770  /// enumitem resume* ?
771  pub resume_star: Option<String>,
772}
773
774/// Prepare for an list (itemize/enumerate/description/etc)
775/// by determining the right counter (level)
776/// and binding the right \item ( \$type@item, if $type is defined)
777pub fn begin_itemize(
778  itype: &str,
779  counter: Option<&str>,
780  options: BeginItemizeOptions,
781) -> Result<HashMap<Stored>> {
782  // The list-type and level of the *containing* list (if any!)
783  let outercounter = lookup_string("itemcounter");
784  let outerlevel = if !outercounter.is_empty() {
785    lookup_int(&s!("{outercounter}level"))
786  } else {
787    0
788  };
789  let counter = counter.unwrap_or("@item");
790  let listlevel = lookup_int("itemization_level") + 1; // level for this list overall
791  let level = lookup_int(&s!("{counter}level")) + // level for lists of specific type
792    (if options.nolevel { 0 } else { 1 });
793  AssignRegister!(
794    "\\itemsep",
795    lookup_dimension("\\lx@default@itemsep")
796      .unwrap_or_default()
797      .into()
798  );
799  assign_value("itemization_level", listlevel, None);
800  assign_value(&s!("{counter}level"), level, None);
801  assign_value("itemization_items", 0, None);
802  let listpostfix = roman!(listlevel).to_string();
803  let postfix = roman!(level).to_string();
804  let mut usecounter = counter.to_string();
805  if !options.nolevel && !postfix.is_empty() {
806    usecounter.push_str(&postfix);
807  }
808  if !itype.is_empty() {
809    let itype_cs = T_CS!(s!("\\{itype}@item"));
810    let_i(&T_CS!("\\item"), &itype_cs, None);
811  }
812  // In case within odd environment.
813  let_i(&T_CS!("\\par"), &T_CS!("\\lx@normal@par"), None);
814  def_macro(
815    T_CS!("\\@listctr"),
816    None,
817    Tokens!(Explode!(usecounter)),
818    None,
819  )?;
820  // Now arrange that this list's id's are relative to the current (outer) item (if any)
821  // And that the items within this list's id's are relative to this (new) list.
822  assign_value("itemcounter", Stored::String(arena::pin(&usecounter)), None);
823  let listcounter = s!("@itemize{listpostfix}");
824  if lookup_definition(&T_CS!(s!("\\c@{listcounter}")))?.is_none() {
825    //Create new list counters as needed
826    new_counter(&listcounter, "", None)?;
827  }
828  if !outercounter.is_empty() {
829    // Make this list's ID relative to outer list's ID
830    let outerusecounter = s!("{outercounter}{}", roman!(outerlevel).to_string());
831    let thectr = s!("\\the{listcounter}@ID");
832    let theexpansion = s!("\\the{outerusecounter}@ID.I\\arabic{{{listcounter}}}");
833    def_macro(
834      T_CS!(thectr),
835      None,
836      mouth::tokenize_internal(TeXString::assembled(theexpansion)),
837      None,
838    )?;
839
840    // AND reset this list's counter when the outer item is stepped
841    let mut cl_toks = vec![T_CS!(&listcounter)];
842    let cl_name = s!("\\cl@{outerusecounter}");
843    let existing = with_value(&cl_name, |v| match v {
844      Some(Stored::Tokens(tks)) => tks.clone().unlist(),
845      _ => Vec::new(),
846    });
847    cl_toks.extend(existing);
848    assign_value(
849      &cl_name,
850      Stored::Tokens(Tokens::new(cl_toks)),
851      Some(Scope::Global),
852    );
853  }
854  // format the id of \item's relative to the id of this list.
855  // Perl: Tokens(T_CS('\the' . $listcounter . '@ID'), T_OTHER('.i'),
856  //              T_CS('\@' . $usecounter . '@ID'))
857  // — build the Tokens array directly from 3 explicit tokens rather than
858  // round-tripping through a string tokenizer. This matters when `usecounter`
859  // contains digits (e.g. "count1" from \usecounter{count1}): `tokenize_internal`
860  // on a literal string "\@count1@ID" would split into \@count + "1" + "@" + "ID"
861  // because TeX's CS reader stops at digits (digits are catcode OTHER even in
862  // the style table). Perl uses T_CS($name) to build a single CS token directly
863  // by name, bypassing tokenization.
864  let useexp = Tokens::new(vec![
865    T_CS!(s!("\\the{listcounter}@ID")),
866    T_OTHER!(".i"),
867    T_CS!(s!("\\@{usecounter}@ID")),
868  ]);
869  def_macro(T_CS!(s!("\\the{usecounter}@ID")), None, useexp, None)?;
870
871  let mut series = if let Some(s) = options.series {
872    s.to_string()
873  } else {
874    String::new()
875  };
876  if let Some(start) = options.start {
877    SetCounter!(usecounter, start);
878    add_to_counter(&usecounter, Number(-1))?;
879  } else if let Some(s) = match options.resume {
880    Some(s) => Some(s),
881    None => options.resume_star,
882  } {
883    if s != "noseries" {
884      series = s.clone();
885      let last_val = lookup_int(&s!("enumitem_series_{s}_last"));
886      if last_val != 0 {
887        SetCounter!(usecounter, Number(last_val));
888      }
889    }
890  } else {
891    reset_counter(&T_OTHER!(&usecounter))?;
892  }
893
894  let mut rsc = ref_step_counter(&s!("@itemize{listpostfix}"), false)?;
895  rsc.insert("counter", usecounter.into());
896  rsc.insert("series", series.into());
897  // Perl latex_constructs.pool L1354-1356: the list carries its TeX vertical
898  // padding (\topsep + \parskip + \partopsep) as padtop/padbottom sizing
899  // properties; Box::computeSizeStore adds them to the computed height/depth
900  // (compute_size_and_cache here). Omitting them under-measured every list,
901  // clipping tcolorbox frames drawn from the estimate (2605.02240).
902  let pad = lookup_dimension_cs("\\topsep", false)
903    .unwrap_or_default()
904    .add(lookup_dimension_cs("\\parskip", false).unwrap_or_default())
905    .add(lookup_dimension_cs("\\partopsep", false).unwrap_or_default());
906  rsc.insert("padtop", Stored::Dimension(pad));
907  rsc.insert("padbottom", Stored::Dimension(pad));
908  Ok(rsc)
909}
910
911/// Set the itemization style for a given level.
912/// Perl: setItemizationStyle($stuff, $level)
913/// If $level is not given, uses the current @itemlevel.
914/// Defines \labelitem$level to $stuff.
915pub fn set_itemization_style(stuff: Option<&Tokens>, level: Option<i32>) -> Result<()> {
916  if let Some(stuff) = stuff {
917    if stuff.is_empty() {
918      return Ok(());
919    }
920    let level = level.unwrap_or_else(|| lookup_int("@itemlevel").max(0) as i32);
921    let level_str = roman_aux(level);
922    let cs_name = s!("\\labelitem{level_str}");
923    def_macro(T_CS!(&cs_name), None, stuff.clone(), None)?;
924  }
925  Ok(())
926}
927
928/// Set the enumeration style for a given level.
929/// Perl: setEnumerationStyle($stuff, $level)
930/// Parses the style tokens to detect A/a/I/i/1 patterns
931/// and defines \theenum$level and \labelenum$level accordingly.
932pub fn set_enumeration_style(stuff: Option<&Tokens>, level: Option<i32>) -> Result<()> {
933  if let Some(stuff) = stuff {
934    if stuff.is_empty() {
935      return Ok(());
936    }
937    let level = level.unwrap_or_else(|| lookup_int("enumlevel").max(0) as i32);
938    let level_str = roman_aux(level);
939    // Iterate the borrowed token slice — no clone needed, only reads.
940    let tokens = stuff.unlist_ref();
941    let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
942    let ctr = T_OTHER!(s!("enum{level_str}"));
943    let mut i = 0;
944    while i < tokens.len() {
945      let t = tokens[i];
946      if t.get_catcode() == Catcode::BEGIN {
947        // Copy braced groups verbatim
948        out.push(t);
949        let mut brlevel = 1i32;
950        i += 1;
951        while brlevel > 0 && i < tokens.len() {
952          let tt = tokens[i];
953          if tt.get_catcode() == Catcode::BEGIN {
954            brlevel += 1;
955          } else if tt.get_catcode() == Catcode::END {
956            brlevel -= 1;
957          }
958          out.push(tt);
959          i += 1;
960        }
961      } else {
962        let ch = char::from_u32(t.get_charcode()).unwrap_or('\0');
963        let cat = t.get_catcode();
964        match (ch, cat) {
965          ('A', Catcode::LETTER) => {
966            // \Alph{enum$level}
967            def_macro(
968              T_CS!(s!("\\theenum{level_str}")),
969              None,
970              Tokens::new(vec![T_CS!("\\Alph"), T_BEGIN!(), ctr, T_END!()]),
971              None,
972            )?;
973            out.push(T_CS!(s!("\\theenum{level_str}")));
974          },
975          ('a', Catcode::LETTER) => {
976            // \alph{enum$level}
977            def_macro(
978              T_CS!(s!("\\theenum{level_str}")),
979              None,
980              Tokens::new(vec![T_CS!("\\alph"), T_BEGIN!(), ctr, T_END!()]),
981              None,
982            )?;
983            out.push(T_CS!(s!("\\theenum{level_str}")));
984          },
985          ('I', Catcode::LETTER) => {
986            // \Roman{enum$level}
987            def_macro(
988              T_CS!(s!("\\theenum{level_str}")),
989              None,
990              Tokens::new(vec![T_CS!("\\Roman"), T_BEGIN!(), ctr, T_END!()]),
991              None,
992            )?;
993            out.push(T_CS!(s!("\\theenum{level_str}")));
994          },
995          ('i', Catcode::LETTER) => {
996            // \roman{enum$level}
997            def_macro(
998              T_CS!(s!("\\theenum{level_str}")),
999              None,
1000              Tokens::new(vec![T_CS!("\\roman"), T_BEGIN!(), ctr, T_END!()]),
1001              None,
1002            )?;
1003            out.push(T_CS!(s!("\\theenum{level_str}")));
1004          },
1005          ('1', Catcode::OTHER) => {
1006            // \arabic{enum$level}
1007            def_macro(
1008              T_CS!(s!("\\theenum{level_str}")),
1009              None,
1010              Tokens::new(vec![T_CS!("\\arabic"), T_BEGIN!(), ctr, T_END!()]),
1011              None,
1012            )?;
1013            out.push(T_CS!(s!("\\theenum{level_str}")));
1014          },
1015          _ => {
1016            out.push(t);
1017          },
1018        }
1019        i += 1;
1020      }
1021    }
1022    // Define \labelenum$level = { out }
1023    let mut label_tokens = vec![T_BEGIN!()];
1024    label_tokens.extend(out);
1025    label_tokens.push(T_END!());
1026    def_macro(
1027      T_CS!(s!("\\labelenum{level_str}")),
1028      None,
1029      Tokens::new(label_tokens),
1030      None,
1031    )?;
1032  }
1033  Ok(())
1034}
1035
1036/// Copies the current id, tags, and inlist counter values into whatsit properties
1037/// Perl: RescueCaptionCounters (latex_constructs.pool.ltxml L3260-3271)
1038pub fn rescue_caption_counters(captype: &str, whatsit: &mut Whatsit) {
1039  let tagskey = &s!("{captype}_tags");
1040  if let Some(tags) = remove_value(tagskey) {
1041    whatsit.set_property("tags", tags);
1042  }
1043  let idkey = s!("{captype}_id");
1044  if let Some(id) = remove_value(&idkey) {
1045    whatsit.set_property("id", id);
1046  }
1047  let inlistkey = s!("{captype}_inlist");
1048  if let Some(inlist) = remove_value(&inlistkey) {
1049    whatsit.set_property("inlist", inlist);
1050  }
1051}