latexml_core/binding/content.rs
1use std::{borrow::Cow, collections::VecDeque, path::Path, rc::Rc};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
6
7// use crate::util::pathname::PathnameFindOptions;
8use crate::Digested;
9use crate::{
10 binding::def::dialect::def_macro,
11 common::{
12 BindingSource, arena,
13 arena::SymStr,
14 error::{emit_error, *},
15 font::{Font, Fontmap},
16 model,
17 },
18 definition::expandable::ExpandableOptions,
19 document::{
20 resource::*,
21 tag::{TagOptionName, TagOptions},
22 },
23 gullet,
24 gullet::do_expand,
25 mouth::{Mouth, MouthOptions},
26 parameter::{Parameter, Parameters},
27 pin,
28 state::{let_i, *},
29 stomach::*,
30 token::*,
31 tokens::{TeXString, Tokens},
32 util::pathname::{self, PathnameFindOptions},
33};
34
35static QUOTE_WRAPPED: Lazy<Regex> = Lazy::new(|| Regex::new("^\"(.+)\"$").unwrap());
36
37/// Maximum nesting depth for package/class loading to prevent infinite recursion.
38/// Perl LaTeXML has no explicit limit but rarely exceeds 20 levels in practice.
39const MAX_INPUT_DEPTH: usize = 500;
40
41thread_local! {
42 /// Current nesting depth of input_definitions calls.
43 static INPUT_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
44}
45
46/// a configuration for loading LaTeX definition files (such as .sty, .cls, and their bindings)
47pub struct InputDefinitionOptions {
48 /// an optional extension (such as "sty")
49 pub extension: Option<Cow<'static, str>>,
50 /// package options to pass into the loaded library
51 pub options: Vec<String>,
52 /// Tokens to process after the definition is loaded
53 pub after: Tokens,
54 /// flag to forbid raw TeX sources
55 pub notex: bool,
56 /// flag to forbid errors ?
57 pub noerror: bool,
58 /// flag to forbid binding dispatch
59 pub noltxml: bool,
60 /// collection of (package) options to process when loading the dependency
61 pub withoptions: Option<Vec<String>>,
62 /// flag to handle options, or ignore them
63 pub handleoptions: bool,
64 /// flag to process in .cls mode (default: false)
65 pub as_class: bool,
66 /// flag to indicate reading the file raw in Gullet
67 pub raw: bool,
68 /// flag to allow reloading a previously loaded definitions file
69 pub reloadable: bool,
70 /// flag: set @ catcode to LETTER during loading (default true).
71 /// Set to false for packages like xy.tex that need @ to stay as OTHER.
72 pub at_letter: bool,
73 /// When set, raw-file lookup is restricted to the user-supplied
74 /// SEARCHPATHS (SOURCEDIRECTORY + graphicspaths), skipping the
75 /// kpsewhich fallback into system texmf. Matches Perl Package.pm's
76 /// `searchpaths_only => 1` — enabled by the `localrawstyles` option
77 /// to latexml.sty. Perl ref: Package.pm L2135, L2674.
78 pub searchpaths_only: bool,
79}
80impl Default for InputDefinitionOptions {
81 fn default() -> Self {
82 InputDefinitionOptions {
83 extension: None,
84 options: Vec::new(),
85 after: Tokens!(),
86 notex: false,
87 noerror: false,
88 noltxml: false,
89 raw: false,
90 reloadable: false,
91 withoptions: None,
92 handleoptions: false,
93 as_class: false,
94 at_letter: true,
95 searchpaths_only: false,
96 }
97 }
98}
99
100/// TODO: Flesh out with the full infrastructure, incremental functionality for now.
101pub fn input_definitions(raw_file: &str, mut options: InputDefinitionOptions) -> Result<()> {
102 let trimmed = raw_file.trim();
103 // A compiled binding keyed by BASENAME raw-loads its own name (`noltxml`), but
104 // the dispatch dropped any directory the user wrote — so `\usepackage{DIR/pkg}`
105 // would raw-load a bare `pkg.sty` and miss the author's bundled `DIR/pkg.sty`.
106 // `\@currname` still holds the full request (`DIR/pkg`) from the OUTER load
107 // (set at `before_input_handle_options`, later than here), so load the file the
108 // user actually asked for — exactly as Perl does (it has no such binding and
109 // raw-loads the dir-prefixed path directly, 0 errors; witness 2510.09534,
110 // `\usepackage[preprint]{AISTATS/aistats2026}`). Scoped to `noltxml` + a bare
111 // basename that matches `\@currname`'s basename, so an unrelated `\input` in the
112 // package is NOT auto-resolved in the subdir (stock LaTeX does not do that —
113 // that is what the `import` package is for). This replaces the former
114 // SearchPathGuard, which injected the subdir into SEARCHPATHS instead.
115 let currname_request: Option<String> = if options.noltxml && !trimmed.contains(['/', '\\']) {
116 do_expand(T_CS!("\\@currname"))
117 .ok()
118 .map(|toks| toks.to_string())
119 .filter(|currname| {
120 currname
121 .rsplit_once(['/', '\\'])
122 .is_some_and(|(dir, base)| base == trimmed && !dir.is_empty())
123 })
124 } else {
125 None
126 };
127 let name: &str = currname_request.as_deref().unwrap_or(trimmed);
128
129 // Guard: prevent infinite recursion from circular or runaway package loading.
130 // When a binding is missing, raw TeX loading can trigger macro loops.
131 let depth = INPUT_DEPTH.with(|d| {
132 let current = d.get();
133 d.set(current + 1);
134 current + 1
135 });
136 if depth > MAX_INPUT_DEPTH {
137 INPUT_DEPTH.with(|d| d.set(d.get() - 1));
138 Fatal!(
139 Stomach,
140 Recursion,
141 s!(
142 "Package loading depth exceeded {} (loading '{}').\
143 This usually means a missing binding causes infinite recursion.",
144 MAX_INPUT_DEPTH,
145 name
146 )
147 );
148 }
149
150 // Ensure depth cleanup on all exit paths via a guard
151 struct InputDepthGuard;
152 impl Drop for InputDepthGuard {
153 fn drop(&mut self) { INPUT_DEPTH.with(|d| d.set(d.get() - 1)); }
154 }
155 let _guard = InputDepthGuard;
156
157 // (A directory-prefixed package name — `\usepackage{DIR/pkg}` — that dispatches
158 // to a basename-keyed binding is resolved above by rewriting `name` to the
159 // `\@currname` request, so the binding's own raw-load targets `DIR/pkg`
160 // directly, as Perl does. No SEARCHPATHS injection is needed.)
161
162 // Note: we always need a gullet to expand, and we sometimes need a stomach to load_definitions...
163 // so let's make stomach a mandatory option.
164 //
165 // Snapshot \@currname/\@currext only when handleoptions=true. Perl
166 // Package.pm:2549-2550 does the same — both prevname and prevext are
167 // gated on options{handleoptions}. The handleoptions=false branch
168 // does NOT mutate \@currname/\@currext (mirrors plain LaTeX `\input`
169 // semantics; Perl L2580 likewise only mutates them inside its
170 // handleoptions=true branch).
171 let prevname = if options.handleoptions && lookup_definition(&T_CS!("\\@currname"))?.is_some() {
172 do_expand(T_CS!("\\@currname"))?.to_string()
173 } else {
174 String::new()
175 };
176 let prevext = if options.handleoptions && lookup_definition(&T_CS!("\\@currext"))?.is_some() {
177 do_expand(T_CS!("\\@currext"))?.to_string()
178 } else {
179 String::new()
180 };
181 // This file will be treated somewhat as if it were a class
182 // IF as_class is true
183 // OR if it is loaded by such a class, and has withoptions true!!! (yikes)
184 if options.handleoptions && options.withoptions.is_some() {
185 with_vecdeque("@masquerading@as@class", |vdq_opt| {
186 if let Some(vdq) = vdq_opt
187 && vdq.iter().any(|x| {
188 if let Stored::String(v) = x {
189 arena::with(*v, |str| str == prevname)
190 } else {
191 false
192 }
193 })
194 {
195 options.as_class = true;
196 }
197 });
198 }
199 if options.noltxml {
200 options.raw = true; // so it will be read as raw by Gullet.
201 }
202 let as_type = if options.as_class {
203 Cow::Borrowed("cls")
204 } else {
205 options
206 .extension
207 .as_ref()
208 .cloned()
209 .unwrap_or(Cow::Borrowed(""))
210 };
211
212 // If loading a class, store class options (Perl Package.pm lines 2561-2564).
213 // Perl L2561: `if ($astype eq 'cls' and $options{options})` — only
214 // (re)define `\@classoptionslist` when THIS cls load actually carries
215 // options. A nested `\LoadClass` with empty options (e.g. amsart →
216 // ams_core; our `*_cls.rs` bindings pass `Tokens!()` rather than forwarding
217 // the outer options as Perl's `withoptions=>1` does) must NOT clobber the
218 // document class's option list — babel iterates `\@classoptionslist`
219 // (`\bbl@foreach`, babel.sty L4270) to pick up a GLOBAL language option such
220 // as `\documentclass[french]{amsart}` and only then declares/loads that
221 // language. Clobbering it to empty silently dropped global babel languages
222 // for every bound class. Witness 1911.07001 (`[oneside,french,titlepage]
223 // {amsart}` + bare `\usepackage{babel}` → french.ldf must load).
224 //
225 // DIVERGENCE retained from Perl: still define `\@classoptionslist` as EMPTY
226 // for an option-less *document* class so babel's
227 // `\csname\ds@\@classoptionslist\endcsname` doesn't run away (the kernel
228 // default `\let\@classoptionslist\relax`; witness 2504.00009). We gate that
229 // on "no class options recorded yet", so it fires for the first/outermost
230 // class load but never clobbers an already-populated list on a nested load.
231 if as_type == "cls" {
232 for opt in &options.options {
233 push_value("class_options", arena::pin(opt))?;
234 }
235 let class_opts_str = options.options.join(",");
236 let have_recorded_opts = lookup_vecdeque("class_options")
237 .map(|v| !v.is_empty())
238 .unwrap_or(false);
239 if !class_opts_str.is_empty() || !have_recorded_opts {
240 def_macro(
241 T_CS!("\\@classoptionslist"),
242 None,
243 Tokens!(Explode!(class_opts_str)),
244 None,
245 )?;
246 }
247 }
248
249 // Compute the exact name based on the type
250 let filename = match &options.extension {
251 None => name.to_string(),
252 Some(ext) => s!("{}.{}", name, ext),
253 };
254 // Store the document class filename for xkeyval's isInClassFile check
255 if as_type == "cls" && options.handleoptions {
256 assign_value(
257 "document_class_filename",
258 filename.clone(),
259 Some(Scope::Global),
260 );
261 }
262 let current_options = options.options.join(",");
263 if !current_options.is_empty()
264 && let Some(Stored::String(prevoptions)) = lookup_value(&s!("{filename}_loaded_with_options"))
265 && arena::with(prevoptions, |prev_str| current_options != prev_str)
266 {
267 let message = s!(
268 "Option clash for file {} with options {:?}, previously loaded with {:?}",
269 filename,
270 current_options,
271 prevoptions
272 );
273 Info!("unexpected", "options", message);
274 }
275
276 // TODO: This needs reorganization, bindings are not found as "files" in rust,
277 // we need to have a registry (we don't yet)
278
279 // Perl: early-stop if already loaded (checks request_loaded, name_loaded, etc.)
280 // This prevents double-loading and breaks circular loading chains.
281 // IMPORTANT: check BEFORE printing "Loading..." message to avoid spurious output.
282 //
283 // Per OXIDIZED_DESIGN #23: gate on the flag matching the load path
284 // we'll actually take. CRITICAL invariant: a binding `<file>.rs` is
285 // allowed to call `InputDefinitions(noltxml=>1)` for its same-named
286 // raw .sty/.cls/.def AFTER its own `_loaded` flag was set — the raw
287 // load gates on `_raw_loaded`, not `_loaded`. Examples: babel_sty
288 // → raw babel.sty; cite_sty → raw cite.sty.
289 let opt_noltxml = options.noltxml;
290 let opt_notex = options.notex;
291 // Rust-only `_load_attempted` flag: set in the miss-handler below to
292 // prevent retry loops while keeping `_loaded` reserved for genuine
293 // binding success. Without this split the `_loaded`-on-miss hack
294 // shadowed `require_package`'s `!_loaded && !_raw_loaded`
295 // post-call check, disabling `maybe_require_dependencies` for any
296 // package that had no binding (e.g. paper-local `jinstpub.sty`).
297 let already_handled = |fkey: &str| -> bool {
298 if opt_noltxml {
299 lookup_bool(&s!("{fkey}_raw_loaded"))
300 } else if opt_notex {
301 lookup_bool(&s!("{fkey}_loaded")) || lookup_bool(&s!("{fkey}_load_attempted"))
302 } else {
303 lookup_bool(&s!("{fkey}_loaded"))
304 || lookup_bool(&s!("{fkey}_raw_loaded"))
305 || lookup_bool(&s!("{fkey}_load_attempted"))
306 }
307 };
308 // Modern-kernel repeat-load option semantics (OXIDIZED_DESIGN #43).
309 // Classic \DeclareOption packages: after ProcessOptions the `\ds@<opt>`
310 // handlers are \relax, so digesting them on a repeat load is a NO-OP —
311 // i.e. LaTeX's own clash-and-drop outcome (and Perl's silent skip; the
312 // Info above is the shared diagnostic). Packages using the key-value
313 // option processor (\ProcessKeyOptions, LaTeX kernel 2022-06+; e.g.
314 // xcolor v3.02 whose `table` key loads colortbl) raise NO clash in real
315 // LaTeX — the repeat load PROCESSES the new keys. A binding models that
316 // by re-asserting a durable `\ds@<opt>` after ProcessOptions! (first
317 // adopter: xcolor's `table`; witness 2605.00310 — \usepackage{xcolor}
318 // then \usepackage[table]{xcolor} builds cleanly under pdflatex, and a
319 // ~483-paper \cellcolor error cluster in sandbox-arxiv-2605). For each
320 // option of a repeat load that the first load did not have, digest the
321 // surviving handler.
322 let apply_new_options_on_reload = |fkey: &str| -> Result<()> {
323 if options.options.is_empty() || !options.handleoptions {
324 return Ok(());
325 }
326 let prev: HashSet<String> =
327 if let Some(Stored::String(prevoptions)) = lookup_value(&s!("{fkey}_loaded_with_options")) {
328 arena::with(prevoptions, |p| {
329 p.split(',').map(|o| o.trim().to_string()).collect()
330 })
331 } else {
332 HashSet::default()
333 };
334 for opt in &options.options {
335 let opt = opt.trim();
336 if opt.is_empty() || prev.contains(opt) {
337 continue;
338 }
339 let handler = T_CS!(s!("\\ds@{}", opt));
340 if lookup_definition(&handler)?.is_some() {
341 Info!(
342 "unexpected",
343 "options",
344 s!("Applying option '{}' from a repeat load of {}", opt, fkey)
345 );
346 digest(Tokens!(handler))?;
347 }
348 }
349 Ok(())
350 };
351 if !options.reloadable && already_handled(&filename) {
352 apply_new_options_on_reload(&filename)?;
353 return Ok(());
354 }
355 // Also check without extension (Perl checks name_loaded too)
356 if !options.reloadable && name != filename && already_handled(name) {
357 apply_new_options_on_reload(&filename)?;
358 return Ok(());
359 }
360
361 // The per-file load note is emitted ONLY when a load truly happens, so
362 // CorTeX's `loaded_file` extraction (which parses the log) counts genuine
363 // loads and nothing else:
364 // * a NATIVE BINDING load announces `(Loading <name>…)` at its success
365 // point below (Perl loadLTXML "(Loading <path>…)" analog);
366 // * a RAW .sty/.cls/.def load is announced by its Mouth's
367 // `(Processing definitions <path>…)` (fires only on a real read);
368 // * the early-return paths — already-loaded skip and missing-file — reach
369 // NEITHER, so they emit no note (matching Perl, whose loadLTXML returns
370 // before its note when the binding was already loaded).
371 // (Previously a `(Loading …)` banner was emitted unconditionally here,
372 // before those early-returns — over-reporting already-loaded/missing files.)
373
374 // Snapshot options.after / options.options BEFORE handleoptions consumes
375 // them so the fallback-binding recursive call (Step 3 below) can forward
376 // both to the fallback. Without this snapshot, mn1 → mn.cls.ltxml fallback
377 // ran with empty options/after and the user's `[epsfig]` was lost (see
378 // astro-ph0002213 root cause).
379 let original_after = options.after.clone();
380 let original_options = options.options.clone();
381 // Snapshot the GRANDPARENT's expl3 state BEFORE `\@pushfilename`'s
382 // `\ExplSyntaxOff` flips `_` to SUB. The post-load cleanup hook in
383 // load_tex_definitions uses this to know whether the calling context
384 // was in expl3 mode (so it can skip the `\ExplSyntaxOff` cleanup that
385 // would otherwise stick post-`\@popfilename`). Witness cluster:
386 // arXiv:2509.05997 / .07893 / .02344, 2510.13206/.13942/.17317
387 // (xsavebox + sys_load_backend + l3backend-dvips.def chain — minimal
388 // repro: \usepackage{xsavebox}).
389 let grandparent_in_expl3 = lookup_catcode('_') == Some(Catcode::LETTER);
390 // Strict-LaTeX-kernel order (latex.ltx `\@onefilewithoptions`, L15518-L15519):
391 // \@pushfilename % capture OLD \@currname / \@currext
392 // \xdef\@currname{ <new name> } % then update to NEW
393 //
394 // We previously set \@currname/\@currext to the new file's name BEFORE
395 // calling `before_input_handle_options` (which performs the push). That
396 // captured the NEW name in the pushed triple, so `\@currnamestack` never
397 // held the empty `{}{}{<catcode>}` initial-state triple that
398 // expl3-code.tex's `\__file_tmp:w` recursion uses as its termination
399 // sentinel. Result: under raw expl3-code.tex load (LATEXML_NODUMP=1),
400 // the recursion ate past `\group_end:` into subsequent
401 // `\seq_new:N` / `\cs_new:Npn …` lines, producing the cs_end:
402 // cascade documented in .investigation/cs_end_bisect_round22/.
403 //
404 // Now: push first (uses current/OLD \@currname), then update inside
405 // before_input_handle_options (line 756-757). For the
406 // `handleoptions == false` path no push happens, so set the names
407 // directly here.
408 if options.handleoptions {
409 before_input_handle_options(&mut options, &prevname, &prevext, name, &as_type)?;
410 def_macro(
411 T_CS!(s!("\\{}.{}-h@@k", name, as_type)),
412 None,
413 options.after,
414 None,
415 )?;
416 }
417 // No `else` branch: Perl Package.pm L2580-2611 only mutates
418 // \@currname/\@currext inside the handleoptions=true block. The
419 // handleoptions=false path mirrors plain LaTeX `\input`, which leaves
420 // them untouched. Mutating them here breaks \@currnamestack
421 // discipline: a subsequent inner \RequirePackage's \@pushfilename
422 // captures the leaked name instead of the empty initial-state value,
423 // and expl3-code.tex's \__file_tmp:w stack walk over-reads.
424 // Witnesses: 0805.4519 (inputenc+ansinew), 1705.00041
425 // (\usetikzlibrary{calligraphy}+spath3+expl3).
426
427 if !current_options.is_empty() {
428 assign_value(
429 &s!("{}_loaded_with_options", filename),
430 current_options,
431 Some(Scope::Global),
432 );
433 }
434
435 // Track loaded files in \@filelist BEFORE loading (Perl: Package.pm calls
436 // \@addtofilelist before reading the file, so \@filelist is available inside)
437 if options.handleoptions && lookup_definition(&T_CS!("\\@addtofilelist"))?.is_some() {
438 digest(Tokens!(
439 T_CS!("\\@addtofilelist"),
440 T_BEGIN!(),
441 Explode!(filename),
442 T_END!()
443 ))?;
444 }
445
446 // Skip loading entirely if already loaded (unless reloadable)
447 // This prevents double-loading when e.g. smfart calls load_class("amsart")
448 // after the binding already set the _loaded flag.
449 // Per OXIDIZED_DESIGN #23: gate by the load path's flag — same
450 // path-aware logic as the early-skip above. Allows a binding to
451 // load its same-named raw counterpart via `noltxml=>1`.
452 if !options.reloadable && already_handled(&filename) {
453 // Already-loaded early return: emit no load note (nothing was loaded).
454 return Ok(());
455 }
456
457 // Catch Fatal errors during binding loading (e.g., token limit exceeded during
458 // expl3 kernel loading). Convert to non-fatal so document processing continues.
459 // `Some(source)` = a binding was loaded, carrying its on-disk source path
460 // when it is a runtime file binding (`.rhai`) or `None` for a compiled-in one;
461 // outer `None` = no binding loaded. The two `_load_binding` attempts (extra
462 // slot, then the main resolving chain) short-circuit on the first that loads.
463 let loaded = if options.noltxml {
464 None
465 } else {
466 match _load_binding(false, &filename, options.reloadable).and_then(|ext| match ext {
467 Some(source) => Ok(Some(source)),
468 None => _load_binding(true, &filename, options.reloadable),
469 }) {
470 Ok(v) => v,
471 Err(e) => {
472 Error!(
473 "unexpected",
474 &filename,
475 s!("Error loading binding for '{}': {}", filename, e)
476 );
477 // Mark as loaded even on error to prevent re-loading via raw path
478 assign_value(&s!("{filename}_loaded"), true, Some(Scope::Global));
479 None
480 },
481 }
482 };
483 let mut is_found_raw = false;
484 // Retain the "a binding loaded" fact for the later hook-firing check (`loaded`
485 // is consumed by the announce branch just below).
486 let is_binding = loaded.is_some();
487 if let Some(binding_source) = loaded {
488 // A native binding was truly loaded: announce it (Perl loadLTXML
489 // "(Loading <path>…)" analog) so CorTeX's `loaded_file` log-parser counts
490 // it. Self-contained begin+end note (timing is currently disabled).
491 // A runtime `.rhai` file binding threads its resolved on-disk path through
492 // the dispatch result (`BindingSource`, converter.rs `rhai_dispatch`), so it
493 // is announced by that real path — more useful, and closer to Perl, whose
494 // note names the actual binding file (#560). A compiled-in binding carries
495 // no source, so it is announced under its module-proxy name — `tcolorbox.sty`
496 // → `tcolorbox_sty.rs`, `aa.cls` → `aa_cls.rs` — mirroring Perl's distinct
497 // `.ltxml` path. This keeps the entry distinguishable from the raw twin's
498 // "(Processing definitions <path>…)" when a binding raw-loads its same-named
499 // file, so cortex's loaded_file stats record two artifacts as two entries
500 // instead of double-counting one file (user, 2026-07-04).
501 let binding_name = if let Some(path) = binding_source {
502 path
503 } else if let Some(stem) = filename.strip_suffix(".sty") {
504 s!("{stem}_sty.rs")
505 } else if let Some(stem) = filename.strip_suffix(".cls") {
506 s!("{stem}_cls.rs")
507 } else {
508 filename.clone()
509 };
510 note_begin(&s!("Loading {binding_name}"));
511 note_end("");
512 // We found and loaded a binding successfully, mark it as such.
513 // Perl Package.pm::loadLTXML L2315-2316 sets TWO flags: `$request`_loaded
514 // (e.g. `color.sty_loaded`) AND `$ltxname`_loaded (`color.sty.ltxml_loaded`),
515 // where `.ltxml` is the suffix of the Perl binding file. Rust's port
516 // keeps only the former — `.ltxml` is not a suffix in the Rust world, so
517 // binding-vs-raw-tex distinction is queryable via `*_loaded` directly.
518 // See OXIDIZED_DESIGN.md. Callers of the legacy `.ltxml_loaded` form
519 // must be migrated to `_loaded`.
520 // Per OXIDIZED_DESIGN #23: binding success → `<filename>_loaded`.
521 // Raw load tracks separately via `<filename>_raw_loaded` (see
522 // load_tex_definitions). The `_found_loaded` Rust-only flag is
523 // dropped — read sites check `_loaded || _raw_loaded` instead.
524 let loaded_flag = format!("{filename}_loaded");
525 assign_value(&loaded_flag, true, Some(Scope::Global));
526 // Also set the Perl-equivalent `<filename>.ltxml_loaded` flag —
527 // some callers (e.g. require_package's deps-scan gate) need to
528 // distinguish binding-loaded from raw-loaded. Without this,
529 // paper-bundled .sty files that load natbib (which has a
530 // binding) re-trigger deps-scan on natbib.sty, which re-finds
531 // \usepackage{natbib} in natbib.sty's own warning text, looping
532 // infinitely. Witness 2111.01269 (TIMEOUT from natbib deps loop).
533 assign_value(&s!("{filename}.ltxml_loaded"), true, Some(Scope::Global));
534 // Perl L2326: Let(T_CS('\ver@'.$trequest), T_CS('\fmtversion'), 'global');
535 // Set \ver@name.ext to \fmtversion so LaTeX's \RequirePackage guard works.
536 // Without this, \RequirePackage date checks fail and packages get re-loaded.
537 if options.handleoptions {
538 let ver_cs = T_CS!(s!("\\ver@{}", filename));
539 if lookup_definition(&ver_cs).ok().flatten().is_none() {
540 let fmtversion_cs = T_CS!("\\fmtversion");
541 let_i(&ver_cs, &fmtversion_cs, Some(Scope::Global));
542 }
543 }
544 } else {
545 // We're inverting the control flow, because it is near-instant to check whether we have an
546 // available binding dispatcher, in both contributed and core binding names
547 // Now that we have ensured there is no compiled target of this name, we can start the file
548 // system search dance, call to kpsewhich, etc.
549 //
550 // Perl Package.pm FindFile search order (L2109-2139):
551 // 1. .ltxml binding (handled above by load_binding/load_external_binding)
552 // 2. Raw TeX in search paths, BUT only if INTERPRETING_DEFINITIONS is true (i.e. we're inside
553 // recursive loading from another raw TeX file)
554 // 3. FindFile_fallback — strip version suffixes, find generic .ltxml binding (e.g.
555 // icml2024.sty → icml.sty.ltxml)
556 // 4. Raw TeX in search paths (without INTERPRETING_DEFINITIONS gate)
557 // 5. kpsewhich
558 //
559 // This ordering ensures versioned-package fallback bindings take priority
560 // over raw .sty files that may contain layout checks (like ICML's \ifdim
561 // page-margin checks) that produce spurious warnings.
562 let interpreting = lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS"));
563 // Perl Package.pm:FindFile_aux L2107: `$interpretable =
564 // LookupMapping('INTERPRETABLE_SOURCES', $file)`. A binding may
565 // register specific raw sources (keyed by `name.ext`) that MUST be
566 // raw-loaded directly rather than version-stripped to a fallback
567 // binding. Driver: xparse.sty.ltxml registers `xparse-2018-04-12.sty`
568 // so the rollback `\file_input` from the real xparse.sty loads the
569 // actual definitions (`\NewDocumentCommand`) — WITHOUT this, the
570 // version-suffix fallback strips `xparse-2018-04-12` → `xparse`, which
571 // re-enters the in-progress xparse binding (short-circuited) and the
572 // rollback file is never loaded (`\NewDocumentCommand` undefined →
573 // xparse/l3 cascade; canvas witness 2309.17288, tests xparse/regex_match).
574 let interpretable = lookup_mapping("INTERPRETABLE_SOURCES", &filename).is_some();
575
576 // Step 2: If we're interpreting raw TeX definitions (or this file is
577 // explicitly INTERPRETABLE), look for the file directly.
578 // Perl L2115: `(!notex && ($interpreting || $interpretable) && pathname_find)`.
579 // Perl Package.pm L2117-2119: `pathname_find($file, paths => $paths)` —
580 // LOCAL PATHS ONLY, no kpsewhich. Rust must mirror this: kpsewhich
581 // here would short-circuit Step 3 (fallback ltxml) for any TeX-Live-
582 // shipped raw file. Witness: `\RequirePackage{caption3}` from raw
583 // floatrow.sty — Perl finds caption3.sty NOT in user paths, falls
584 // through to Step 3 → caption.sty.ltxml. Rust with kpsewhich here
585 // returned the real caption3.sty from TL, raw-loading it and
586 // triggering the `\DeclareCaptionFormat{hang}[#1#2#3\par]{...}`
587 // PARAM-leak cascade (arXiv:2506.19291: Rust=30 vs Perl=2).
588 let found_raw = if (interpreting || interpretable) && !options.notex {
589 find_file(
590 &filename,
591 Some(FindFileOptions {
592 forbid_ltxml: options.noltxml,
593 notex: false,
594 ext_type: options.extension.as_ref().cloned(),
595 search_paths_only: true,
596 }),
597 )
598 } else {
599 None
600 };
601
602 // Step 3: Try fallback (strip version suffixes / dir prefix) before raw TeX.
603 // Perl Package.pm L2118-2121: FindFile_fallback.
604 //
605 // Design policy: bindings ALWAYS win over local raw .sty/.cls files.
606 // The `.rs` bindings are hand-tuned for the conversion, so if a
607 // fallback name resolves to a registered binding we dispatch there
608 // unconditionally. Raw TeX is the last-resort path (Step 4).
609 //
610 // Two flavors are recorded via [`FallbackKind`] for informational
611 // log messages only — both always fire when the binding exists:
612 // - Versioned: suffix/prefix actually stripped (Perl-faithful). Drivers: 1206.0536 (mysvjour3
613 // → svjour3), astro-ph0005021 (./aaspp4 → ./aaspp — aaspp4.sty ships locally with plain-TeX
614 // `\startdata`; the engine's alignment-aware binding still wins, matching Perl).
615 // - BasenameOnly: only directory prefix removed. Rust-specific extension keyed to our
616 // contrib-binding registry. Drivers: 2105.02087 (misc/ieeetran → IEEEtran binding);
617 // 2405.18387 (assets/equations → equations binding, because we ship a tuned binding for
618 // this name).
619 let found_raw = if found_raw.is_some() {
620 found_raw
621 } else if !options.noltxml && !interpretable {
622 // Perl L2119: `(!noltxml && !$interpretable && FindFile_fallback)` —
623 // an INTERPRETABLE source must NOT be version-stripped to a fallback
624 // binding; it falls through to the raw-TeX/kpsewhich path below.
625 if let Some((fallback, _kind)) = find_file_fallback(name, &as_type) {
626 Info!(
627 "fallback",
628 name,
629 s!("Interpreted as versioned package, falling back to {fallback}")
630 );
631 // Load the fallback binding — use reloadable since we already marked original as "loaded"
632 let ext_suffix = if as_type == "sty" { ".sty" } else { ".cls" };
633 let fallback_name = fallback.trim_end_matches(ext_suffix).to_string();
634 // Forward the original options + after-hook so fallback bindings see
635 // user-supplied class/package options (Perl-faithful: in Perl FindFile
636 // returns a path and the caller's options/after stay attached to the
637 // ORIGINAL `\@currname`-frame). Without this, `\documentstyle[epsfig]{mn1}`
638 // fell back to mn.cls.ltxml with empty options → mn.cls's option-handler
639 // never saw `epsfig` → `\compat@loadpackages` after-hook never fired
640 // → `\psfig` undefined. Witness: astro-ph0002213.
641 let fb_result = input_definitions(&fallback_name, InputDefinitionOptions {
642 extension: Some(Cow::Borrowed(if as_type == "sty" { "sty" } else { "cls" })),
643 options: original_options,
644 after: original_after,
645 handleoptions: options.handleoptions,
646 noerror: true,
647 reloadable: true,
648 ..InputDefinitionOptions::default()
649 });
650 if fb_result.is_ok() {
651 assign_value(&s!("{filename}_loaded"), true, Some(Scope::Global));
652 // NOTE: do NOT set `{filename}.ltxml_loaded` here. The
653 // fallback name is a DIFFERENT binding (e.g. article for
654 // myclass); the original `{filename}` (myclass.cls) has
655 // no binding. Setting it would suppress the downstream
656 // deps-scan check that picks up myclass's
657 // \RequirePackage{caption}.
658 }
659 None // fallback handled the loading; no raw file to load
660 } else {
661 None
662 }
663 } else {
664 None
665 };
666
667 // Step 4: Raw TeX in search paths (without INTERPRETING_DEFINITIONS gate)
668 // Perl Package.pm L2122-2125
669 //
670 // Per OXIDIZED_DESIGN #23: gate by `_raw_loaded` only — when a binding
671 // explicitly loads its raw counterpart via `noltxml=>1`, the binding's
672 // own `_loaded` flag is already set, but we MUST still proceed.
673 //
674 // EXCEPTION: if Step 3 (fallback ltxml binding) just succeeded, Perl's
675 // `if/elsif` flow (Package.pm:2118-2125) RETURNS on success and skips
676 // the raw-tex branch entirely. Rust's port uses sequential `let`
677 // bindings, so we must explicitly check `_loaded` here. Without this
678 // gate, `\RequirePackage{caption2}` loads `caption.sty.ltxml` via
679 // `find_file_fallback` (caption2 → caption strips trailing digit) AND
680 // then ALSO loads raw `caption2.sty`, which fires its
681 // `\@ifpackageloaded{caption}` mutual-exclusivity error. Same pattern
682 // applies to any package whose name ends in `[vV]?[-_.\d]+` and whose
683 // unsuffixed form has its own .ltxml binding.
684 let found_raw = if found_raw.is_some() {
685 found_raw
686 } else if lookup_bool(&s!("{filename}_loaded")) {
687 // Fallback ltxml binding already loaded — don't double-load the raw.
688 None
689 } else if !options.notex && (options.reloadable || !lookup_bool(&s!("{filename}_raw_loaded"))) {
690 // Perl Package.pm L2121-2125 + L2131-2136: combined raw-search
691 // step. Tries local paths first, then kpsewhich. Mirrors Perl's
692 // Step 4 (`!interpreting` local raw) PLUS Step 5 (kpsewhich
693 // unconditionally — note Perl's kpsewhich block lacks the
694 // interpreting gate). The previous `!interpreting` guard here
695 // was wrong: Step 2 now uses `search_paths_only=true`, so
696 // under interpreting=true we still need kpsewhich for raw
697 // files that have no fallback ltxml binding.
698 find_file(
699 &filename,
700 Some(FindFileOptions {
701 forbid_ltxml: options.noltxml,
702 notex: false,
703 ext_type: options.extension.as_ref().cloned(),
704 search_paths_only: options.searchpaths_only,
705 }),
706 )
707 } else {
708 None
709 };
710
711 if let Some(file) = found_raw {
712 is_found_raw = true;
713 // The raw load itself sets `<filename>_raw_loaded` via
714 // load_tex_definitions (per OXIDIZED_DESIGN #23). Read sites
715 // check `_loaded || _raw_loaded` to detect "any load happened".
716 load_tex_definitions(
717 &filename,
718 &file,
719 options.reloadable,
720 options.at_letter,
721 grandparent_in_expl3,
722 )?;
723 } else if !lookup_bool(&s!("{filename}_loaded")) && !lookup_bool(&s!("{filename}_raw_loaded")) {
724 if options.noerror {
725 // With noerror: don't mark as loaded and return Err so callers can
726 // try fallback names (e.g. tikzlibrary → pgflibrary). Matches Perl's
727 // InputDefinitions which returns undef on not-found even with noerror=>1.
728 // Nothing loaded → emit no load note.
729 return Err(s!("File not found: {}", filename).into());
730 }
731 // Perl Package.pm L2679 / L2715: maybeRequireDependencies($name, $type)
732 // is invoked when InputDefinitions returned undef ($success false).
733 // We mirror that here in the miss-handler, which is the only point
734 // where we know neither binding nor raw load occurred. Doing the
735 // dependency-scan BEFORE marking `_load_attempted` keeps the call
736 // exactly once-per-package and lets paper-local `.sty` files
737 // (e.g. jinstpub.sty bundling natbib + amsmath dependencies) wire
738 // up their transitively-bound prerequisites even when raw .sty
739 // loading is disabled (`INCLUDE_STYLES=false`, the default).
740 let scan_type =
741 options
742 .extension
743 .as_deref()
744 .unwrap_or(if options.as_class { "cls" } else { "sty" });
745 maybe_require_dependencies(name, scan_type);
746 // Rust-only retry guard: prevents re-attempting a missing file in
747 // a loop (raw TeX repeatedly calling \RequirePackage). Use a
748 // dedicated `_load_attempted` flag — NOT `_loaded` — so the
749 // post-input_definitions success check in `require_package`
750 // remains honest about whether anything actually loaded.
751 //
752 // Gate the guard on raw-loading having actually been POSSIBLE
753 // (INCLUDE_STYLES on, or noltxml forced). A miss while raw `.sty` loading
754 // was OFF is a deliberate DEFERRAL, not a genuine "file absent" — and must
755 // NOT block a later load once INCLUDE_STYLES turns on (e.g. inside another
756 // package's raw read). Otherwise a bare `\RequirePackage{pgfcore}` (no
757 // binding, INCLUDE_STYLES off) permanently STARVES tcolorbox's `skins`
758 // library, which raw-loads pgfcore under INCLUDE_STYLES=true — whereas
759 // pdflatex loads pgfcore fine in any order. The guard still fires exactly
760 // where it is needed: the loop it prevents happens DURING a raw read,
761 // which is itself INCLUDE_STYLES=true. Witness: nicematrix-then-
762 // tcolorbox[most] (fairmeta.cls, ar5iv #520/#567/#576): 49 → 0 pgf errors.
763 if lookup_bool("INCLUDE_STYLES") || options.noltxml {
764 assign_value(&s!("{filename}_load_attempted"), true, Some(Scope::Global));
765 }
766 // Say which of the two things actually happened. `notex` (the DEFAULT
767 // whenever `INCLUDE_STYLES` is off — see `require_package`, Perl
768 // `Package.pm` L2671-2672) gates the raw-search branch above, so in that
769 // case NO disk lookup was performed at all. Claiming "no raw file found
770 // on disk" there asserts a search that never ran, and the package is
771 // usually installed and findable: `\usepackage{xstring}` on arXiv
772 // 2607.21760 warned exactly that while `kpsewhich xstring.sty` resolved
773 // it fine, sending a reader hunting a file-resolution bug that does not
774 // exist. A diagnostic must not overclaim what it checked.
775 let why = if options.notex {
776 "no dispatcher entry, and raw TeX loading is off (enable with --includestyles)"
777 } else {
778 "no dispatcher entry, and no raw file found on disk"
779 };
780 Warn!(
781 "missing_file",
782 name,
783 s!("Can't find binding or file for '{filename}'. {why}.")
784 );
785 }
786 }
787
788 if options.handleoptions {
789 if is_binding || is_found_raw {
790 digest(T_CS!(s!("\\{name}.{as_type}-h@@k")))?;
791 }
792 // Always restore @currname/@currext and pop filename stack,
793 // even when no binding was found, to keep the stack balanced.
794 // Note: @popfilename uses \gdef to restore @currname/@currext from the stack,
795 // so it takes precedence. We also set them with def_macro as a fallback
796 // (matches Perl Package.pm lines 2635-2637).
797 if !prevname.is_empty() {
798 def_macro(
799 T_CS!("\\@currname"),
800 None,
801 Tokens!(ExplodeText!(prevname)),
802 None,
803 )?;
804 }
805 if !prevext.is_empty() {
806 def_macro(
807 T_CS!("\\@currext"),
808 None,
809 Tokens!(ExplodeText!(prevext)),
810 None,
811 )?;
812 }
813 // Perl-faithful: Package.pm:2637 —
814 // Digest(($pushpop ? T_CS('\@popfilename') : T_CS('\lx@popfilename')));
815 // Pair with the dispatched push above. Using `\@popfilename` (dump's
816 // expl3-wrapped) when both push/pop are defined; else `\lx@popfilename`
817 // (LaTeXML safe internal). The push site re-checks `\@pushfilename` and
818 // `\@popfilename` definedness independently (state may have changed
819 // mid-load); here we re-check too rather than threading a flag.
820 let pop_use_expl = lookup_definition(&T_CS!("\\@pushfilename"))?.is_some()
821 && lookup_definition(&T_CS!("\\@popfilename"))?.is_some();
822 if pop_use_expl {
823 digest(T_CS!("\\@popfilename"))?;
824 } else {
825 digest(T_CS!("\\lx@popfilename"))?;
826 }
827 // Verify @currname was correctly restored, and force-fix if not
828 let restored_name = if lookup_definition(&T_CS!("\\@currname"))?.is_some() {
829 do_expand(T_CS!("\\@currname"))?.to_string()
830 } else {
831 String::new()
832 };
833 if !prevname.is_empty() && restored_name != prevname {
834 // @popfilename may have popped a stale entry; force correct value
835 def_macro(
836 T_CS!("\\@currname"),
837 None,
838 Tokens!(ExplodeText!(prevname)),
839 Some(ExpandableOptions {
840 scope: Some(Scope::Global),
841 ..ExpandableOptions::default()
842 }),
843 )?;
844 }
845 if !prevext.is_empty() {
846 let restored_ext = if lookup_definition(&T_CS!("\\@currext"))?.is_some() {
847 do_expand(T_CS!("\\@currext"))?.to_string()
848 } else {
849 String::new()
850 };
851 if restored_ext != prevext {
852 def_macro(
853 T_CS!("\\@currext"),
854 None,
855 Tokens!(ExplodeText!(prevext)),
856 Some(ExpandableOptions {
857 scope: Some(Scope::Global),
858 ..ExpandableOptions::default()
859 }),
860 )?;
861 }
862 }
863 reset_options()?;
864 }
865 // No handleoptions=false cleanup needed: we never mutated
866 // \@currname/\@currext on that path (matching Perl).
867 Ok(())
868}
869
870/// loads a binding from the main binding dispatcher, if available+found.
871/// `Ok(Some(source))` on load — `source` is the on-disk path for a runtime
872/// `.rhai` file binding, `None` for a compiled-in one; `Ok(None)` = not loaded.
873pub fn load_binding(file: &str) -> Result<Option<BindingSource>> {
874 _load_binding(true, file, false)
875}
876/// loads a binding from an external binding dispatcher, if available+found
877pub fn load_external_binding(file: &str) -> Result<Option<BindingSource>> {
878 _load_binding(false, file, false)
879}
880// in the spirit of Perl's Package::loadLTXML
881fn _load_binding(internal: bool, request: &str, reloadable: bool) -> Result<Option<BindingSource>> {
882 // Perl loadLTXML L2311-2313: skip if already loaded, unless reloadable
883 // (e.g. `\inputencoding{cp1251}` re-invokes cp1251.def to re-register
884 // DeclareInputText mappings after `set_input_encoding` reset them).
885 // OXIDIZED_DESIGN #23: binding load gates ONLY on the binding-specific
886 // `_loaded` flag (set on success below). A prior raw load
887 // (`_raw_loaded`) does NOT preclude the binding from loading — they
888 // are independent paths. Mirrors Perl `loadLTXML` (Package.pm L2311).
889 let loaded_key = s!("{request}_loaded");
890 if !reloadable && lookup_bool(&loaded_key) {
891 // Already loaded; the source path is not retained across loads, and the
892 // announce site short-circuits on `already_handled` before reaching here,
893 // so reporting an unknown (`None`) source is correct.
894 return Ok(Some(None));
895 }
896
897 // Re-entrance guard for binding loads: track which bindings are
898 // currently mid-load on this thread, so that if a binding body
899 // transitively calls require_package(SAME_NAME) (e.g. via
900 // \citet → OmniBus closure → require_package(natbib) firing
901 // during natbib's own ProcessOptions chain), we short-circuit
902 // instead of re-entering the dispatcher and looping. Task #260.
903 thread_local! {
904 static IN_PROGRESS: std::cell::RefCell<rustc_hash::FxHashSet<String>> =
905 std::cell::RefCell::new(rustc_hash::FxHashSet::default());
906 }
907 let request_key = request.to_string();
908 let already_loading = IN_PROGRESS.with(|s| s.borrow().contains(&request_key));
909 if already_loading {
910 Warn!(
911 "recursion",
912 &request_key,
913 s!(
914 "Binding-load re-entrance for '{}' (transitive require_package \
915 during its own LoadDefinitions). Short-circuiting to break the \
916 loop; binding's pending side-effects (DefMacro, Let, etc.) \
917 will still complete in the outer frame.",
918 request_key
919 )
920 );
921 return Ok(Some(None));
922 }
923
924 // Normalize both dispatcher slots to one resolving closure so the shared
925 // unlock / in-progress guards wrap a single call. The main slot already
926 // reports a `BindingSource`; the extra (compiled) slot never has one, so its
927 // success maps to `None`.
928 type Resolver = Box<dyn Fn(&str) -> Option<Result<BindingSource>>>;
929 let taken_dispatcher: Option<Resolver> = if internal {
930 get_bindings_dispatch().map(|d| Box::new(move |r: &str| d(r)) as Resolver)
931 } else {
932 get_extra_bindings_dispatch()
933 .map(|d| Box::new(move |r: &str| d(r).map(|res| res.map(|()| None))) as Resolver)
934 };
935 match taken_dispatcher {
936 Some(ref dispatcher) => {
937 // Perl `Package.pm:loadLTXML L2318` wraps the binding-load body in
938 // `local $UNLOCKED = 1`, allowing bindings to override prior
939 // (locked) definitions. The guard auto-pops on drop.
940 let _unlock_guard = local_state_unlocked_guard(true);
941 // Mark in-progress for the duration of this dispatcher call.
942 IN_PROGRESS.with(|s| {
943 s.borrow_mut().insert(request_key.clone());
944 });
945 struct InProgressGuard(String);
946 impl Drop for InProgressGuard {
947 fn drop(&mut self) {
948 let key = self.0.clone();
949 IN_PROGRESS.with(|s| {
950 s.borrow_mut().remove(&key);
951 });
952 }
953 }
954 let _in_progress_guard = InProgressGuard(request_key);
955 let result_opt = dispatcher(request);
956 match result_opt {
957 Some(result) => {
958 // Here and only here we are certain we have binding support.
959 // Preemptively mark as loaded to avoid recursion.
960
961 // Mark binding as loaded (raw `<request>_raw_loaded` is tracked
962 // separately by load_tex_definitions). Per OXIDIZED_DESIGN #23.
963 assign_value(&loaded_key, true, Some(Scope::Global));
964 // `result` is `Result<BindingSource>`; wrap the success in `Some` to
965 // signal "a binding loaded" (carrying its source path, if any).
966 result.map(Some)
967 },
968 None => Ok(None),
969 }
970 },
971 None => Ok(None),
972 }
973}
974
975// Factor out handling and passing loading options from input_content,
976// to simplify main routine
977fn before_input_handle_options(
978 options: &mut InputDefinitionOptions,
979 prevname: &str,
980 prevext: &str,
981 name: &str,
982 as_type: &str,
983) -> Result<()> {
984 // Perl-faithful translation of Package.pm:2578-2591:
985 //
986 // my $pushpop = LookupDefinition(T_CS('\@pushfilename'))
987 // && LookupDefinition(T_CS('\@popfilename'));
988 // if ($pushpop) {
989 // Digest(Tokens(T_CS('\@pushfilename'),
990 // T_BEGIN, T_END, T_BEGIN, T_END, T_BEGIN, Explode($name), T_END));
991 // } else {
992 // Digest(T_CS('\lx@pushfilename'));
993 // }
994 //
995 // The 3 trailing brace-arg pairs `{}{}{name}` feed
996 // `\@expl@push@filename@aux@@` (which the dump's `\@pushfilename`
997 // body chains into) — that aux takes 3 args. Without them it reads
998 // 3 garbage tokens from the input stream, corrupting the
999 // `\g__hook_name_stack_seq` push. Subsequent `\@popfilename`
1000 // then sees an empty/corrupt seq, fires `\msg_error:nn{hooks}{extra-pop-label}`,
1001 // whose `\use:e` (=`\edef`) chain expands `\q_no_value` and triggers
1002 // recursion-detect. See docs/sandbox_failures_SYNC_STATUS.md
1003 // "\q_no_value cascade" for the full investigation.
1004 let push_defined = lookup_definition(&T_CS!("\\@pushfilename"))?.is_some();
1005 let pop_defined = lookup_definition(&T_CS!("\\@popfilename"))?.is_some();
1006 if push_defined && pop_defined {
1007 let mut pushtoks = vec![
1008 T_CS!("\\@pushfilename"),
1009 T_BEGIN!(),
1010 T_END!(),
1011 T_BEGIN!(),
1012 T_END!(),
1013 T_BEGIN!(),
1014 ];
1015 pushtoks.extend(Explode!(name));
1016 pushtoks.push(T_END!());
1017 digest(Tokens::new(pushtoks))?;
1018 } else {
1019 digest(T_CS!("\\lx@pushfilename"))?;
1020 }
1021
1022 // For \RequirePackageWithOptions, pass the options from the outer class/style to the inner one.
1023 if let Some(with_options_to_pass) = options.withoptions.take()
1024 && !prevname.is_empty()
1025 && has_value(&s!("opt@{}.{}", prevname, prevext))
1026 {
1027 // Only pass those class options that are declared by the package!
1028 let mut topass = Vec::new();
1029 with_vecdeque("@declaredoptions", |vdq_opt| {
1030 if let Some(declared_options) = vdq_opt {
1031 for op in with_options_to_pass.into_iter() {
1032 if declared_options.iter().any(|x| {
1033 if let Stored::String(val) = x {
1034 arena::with(*val, |str| str == op)
1035 } else {
1036 false
1037 }
1038 }) {
1039 topass.push(op)
1040 }
1041 }
1042 }
1043 });
1044 if !topass.is_empty() {
1045 pass_options(name, as_type, topass)?;
1046 }
1047 }
1048 // Use letter-catcode (`ExplodeText`) for `\@currext` / `\@currname` so
1049 // they match `\@pkgextension`-style build-time-tokenized macros under
1050 // `\ifx`. Without this the catcodes diverge — `\@pkgextension` from a
1051 // compile-time `DefMacro!("\\@pkgextension", "sty")` tokenizes "sty"
1052 // as letters (default LaTeX catcode 11), but the previous `Explode!`
1053 // used here produces OTHER catcode tokens, so kvoptions's
1054 // `\ifx\@currext\@pkgextension` always returned false — vendor
1055 // `\PackageError{kvoptions}{\ProcessLocalKeyvalOptions is intended
1056 // for packages only}` then fired on every package that uses kvoptions
1057 // (rerunfilecheck reaches this via the hyperref backend `.def` chain).
1058 // Witnesses: arXiv:cond-mat/9611206, math/9904040, math/9904041.
1059 def_macro(
1060 T_CS!("\\@currname"),
1061 None,
1062 Tokens!(ExplodeText!(name)),
1063 None,
1064 )?;
1065 def_macro(
1066 T_CS!("\\@currext"),
1067 None,
1068 Tokens!(ExplodeText!(as_type)),
1069 None,
1070 )?;
1071 // reset options (Note reset & pass were in opposite order in LoadClass ????)
1072 reset_options()?;
1073 pass_options(name, as_type, options.options.clone())?;
1074
1075 // Note which packages are pretending to be classes.
1076 if options.as_class {
1077 push_value("@masquerading@as@class", arena::pin(name))?;
1078 }
1079 let current_opt_val = with_vecdeque(&s!("opt@{}.{}", name, as_type), |vdq_opt| match vdq_opt {
1080 Some(vdq) => {
1081 let mut pieces = String::new();
1082 for x in vdq.iter() {
1083 if let Stored::String(val) = x {
1084 arena::with(*val, |str| pieces.push_str(str));
1085 }
1086 pieces.push(',');
1087 }
1088 pieces.pop();
1089 pieces
1090 },
1091 None => String::new(),
1092 });
1093 // Use letter-catcode (`ExplodeText`) for the stored option list, same
1094 // reason as `\@currname`/`\@currext` above: real LaTeX stores the
1095 // `\usepackage[...]` option tokens with alphabetic chars as LETTER
1096 // (catcode 11). kvoptions/keyval `\setkeys` then binds a `\DeclareString-
1097 // Option` value (e.g. `\axp@bibliography`) from these tokens, and packages
1098 // validate it with the catcode-SENSITIVE `ifthen` `\equal` (or `\ifx`).
1099 // The previous `Explode!` produced OTHER (catcode 12) letters, so
1100 // `\equal{\axp@bibliography}{common}` spuriously failed and apxproof
1101 // raised `unsupported option bibliography=common`. Witness:
1102 // docs/known_crashes — gdsm.tex (apxproof + biblatex).
1103 def_macro(
1104 T_CS!(s!("\\opt@{}.{}", name, as_type)),
1105 None,
1106 Tokens!(ExplodeText!(current_opt_val)),
1107 None,
1108 )?;
1109 Ok(())
1110}
1111
1112/// configuration for input of a TeX source (content files mostly)
1113#[derive(Debug, Default, Clone)]
1114pub struct InputOptions {
1115 pub noerror: bool,
1116 pub reloadable: bool,
1117 pub file_type: Option<String>,
1118}
1119
1120/// Input for cases when the file (or data)
1121/// is plain TeX material that is expected to contribute content
1122/// to the document (as opposed to pure definitions).
1123///
1124/// A Mouth is opened onto the file, and subsequent reading
1125/// and/or digestion will pull Tokens from that Mouth until it is
1126/// exhausted, or closed.
1127///
1128/// In some circumstances it may be useful to provide a string containing
1129/// the TeX material explicitly, rather than referencing a file.
1130/// In this case, the `literal` pseudo-protocal may be used.
1131pub fn input_content(request: &str, options: InputOptions) -> Result<()> {
1132 let filepath = find_file(request, None);
1133 match filepath {
1134 // TODO: type => $options{type}, noltxml => 1
1135 Some(path) => load_tex_content(&path, options),
1136 None => {
1137 // Perl Package.pm L2227-2233: `if (FindFile(...)) { loadTeXContent(...); }
1138 // elsif (!$options{noerror}) { Error('missing_file', $request, ..., ...); }`
1139 // Recoverable Error, NOT Fatal. Pre-fix, the Rust port emitted a
1140 // `fatal!(Package, MissingFile)` that terminated the conversion on any
1141 // missing-but-non-critical input — over-fatal-izing relative to Perl.
1142 if !options.noerror {
1143 Error!(
1144 "missing_file",
1145 request,
1146 format!("Can't find TeX file {request}")
1147 );
1148 }
1149 Ok(())
1150 },
1151 }
1152}
1153
1154/// This is essentially the `\input` equivalent
1155///
1156/// we are most likely expecting to get actual content,
1157/// (possibly with definitions included, as well)
1158/// but might actually be getting pure definitions,
1159/// (like a proper style file)
1160/// in which case we may really want to load a binding.
1161/// Note that generic style files (non-latex) often have a .tex extension.
1162pub fn input(request: &str, options: InputOptions) -> Result<()> {
1163 // unwrap if in quotes \input{"file name"} — Perl parity:
1164 // `$request =~ s/^("+)(.+)\g1$/$2/;` (single-pass strip of a matching
1165 // leading+trailing run of quotes). The previous `while` loop checked
1166 // the unchanged `request`, which spun forever on any quoted input
1167 // since the replacement only touches `clean_req`.
1168 let clean_req = QUOTE_WRAPPED.replace(request, "$1");
1169 // HEURISTIC! First check if equivalent style file, but only under very specific circumstances
1170 // if pathname_is_literaldata(request) {
1171 // let (dir, name, ftype) = pathname_split(request);
1172 // let file = name;
1173 // if !ftype.is_empty() {
1174 // file += format!(".{}",ftype);
1175 // }
1176 // let path;
1177 // // Firstly, check if we are going to OVERRIDE the requested raw .tex file
1178 // // with a latexml binding to a style file.
1179 // if ((dir.is_empty() && (ftype.is_empty() || (ftype == "tex")) // No SPECIFIC directory, but
1180 // a raw tex file. // AND, in preamble; SHOULD be style file, OR also if we can't find the
1181 // raw file. && (LookupValue!("inPreamble") || !FindFile(file))
1182 // && (path = FindFile(name, type => 'sty', notex => 1))) { // AND there IS such a style file
1183 // Info!("ignore", request, stomach.get_gullet(),
1184 // s!("Ignoring input of tex {}, using package {} instead", request, name));
1185 // RequirePackage!(name); // Then override, assuming we'll find name as a package file!
1186 // return;
1187 // }
1188 // }
1189 // // Next special case: If we were currently reading a "known" style or binding file,
1190 // // then this file, even if .tex, must also be definitions rather than content.!!(?)
1191 // Check for *.latexml source-level bindings first — these are always handled
1192 // as definitions regardless of INTERPRETING_DEFINITIONS state.
1193 // Mirrors Perl's automatic .latexml file loading mechanism.
1194 if clean_req.ends_with(".latexml") {
1195 return input_definitions(&clean_req, InputDefinitionOptions::default());
1196 }
1197 if lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS")) {
1198 // Split a binding extension off the request so input_definitions sees
1199 // (name, extension) — matches Perl Package.pm `FindFile` / `Input`
1200 // semantics. Without the split, `find_file_fallback` runs with
1201 // `ext_type=""` and reconstructs `"<base>."` (no extension), which
1202 // never matches a registered binding. Witness: hep-ph9911514 — the
1203 // raw-loaded `elsartwb.sty` issues `\input elsart12\@ptsize.sty` →
1204 // `\input{elsart12.sty}`; the version-strip fallback (elsart12 →
1205 // elsart) needs `ext_type="sty"` to reconstruct `"elsart.sty"` for
1206 // the binding lookup. Perl recovers `\ack` cleanly via this path; the
1207 // earlier Rust port dropped the extension and the fallback never
1208 // resolved.
1209 let has_dir = clean_req.contains('/') || clean_req.contains('\\');
1210 if !has_dir
1211 && let Some((stem, ext)) = clean_req.rsplit_once('.')
1212 && is_binding_extension(ext)
1213 {
1214 return input_definitions(stem, InputDefinitionOptions {
1215 extension: Some(Cow::Owned(ext.to_string())),
1216 ..InputDefinitionOptions::default()
1217 });
1218 }
1219 return input_definitions(&clean_req, InputDefinitionOptions::default());
1220 }
1221 // Perl Package.pm L2109-2113: FindFile_aux checks for `"$file.ltxml"` in
1222 // $ltxml_paths BEFORE consulting raw TeX paths. In Rust the bindings are
1223 // compile-time dispatch tables rather than on-disk .ltxml files, so the
1224 // equivalent check is: if a binding dispatcher responds to `<name>.tex`,
1225 // load it (matching `\input harvmac` → `harvmac.tex.ltxml` preference
1226 // over a local `harvmac.tex`). Skip when the request carries a directory
1227 // (explicit local path).
1228 let binding_loaded = {
1229 let has_dir = clean_req.contains('/') || clean_req.contains('\\');
1230 // Perl Package.pm:2109-2113 + 2255-2270: when `\input{name}` or
1231 // `\input{name.<ext>}` resolves to a known binding extension AND a
1232 // binding for `(name, ext)` is reachable, route to the binding
1233 // instead of the on-disk raw file. Without this, papers using
1234 // literal `\input{psfig.sty}` (common 1996-2005 idiom) fail because
1235 // TL2025 dropped the on-disk file even though Rust has the binding.
1236 //
1237 // Extensions handled dynamically via `is_binding_extension`: any
1238 // extension registered by `latexml_package` or `latexml_contrib`
1239 // (cls / sty / def / fontmap / ldf / ltx / lua / pool / tex /
1240 // code.tex / ...) is admitted, gating out `\input{foo.eps}`-style
1241 // content paths.
1242 //
1243 // For .tex / no-extension paths we still use `load_binding` (exact
1244 // dispatch lookup on `<name>.tex`) — a `<name>.tex` request is
1245 // semantically "include this content", so suffix-stripping fallback
1246 // (e.g. `mysetup.tex` → `setup.tex.ltxml`) would surprise more than
1247 // it helps.
1248 //
1249 // For .sty / .cls / .def / etc — the binding-extension cases — we
1250 // route through `input_definitions`, which gives us the full Step
1251 // 1 → Step 3 → Step 4 ladder including `find_file_fallback`'s
1252 // version-suffix strip. This is what makes `\input{psfig.sty}`
1253 // pick up `psfig_sty.rs` AND `\input{caption2.sty}` fall back to
1254 // `caption_sty.rs` exactly as Perl Package.pm:2266 does via
1255 // `RequirePackage($name)`.
1256 if !has_dir {
1257 let ext = clean_req.rsplit('.').next().unwrap_or("");
1258 let no_ext = ext == clean_req.as_ref();
1259 if no_ext || ext == "tex" {
1260 let tex_name = if ext == "tex" {
1261 clean_req.to_string()
1262 } else {
1263 s!("{}.tex", clean_req)
1264 };
1265 load_binding(&tex_name)?.is_some() || load_external_binding(&tex_name)?.is_some()
1266 } else if is_binding_extension(ext) {
1267 // Route through input_definitions for fallback-aware dispatch.
1268 // The `name` arg expects no extension, so split it off.
1269 let name = clean_req
1270 .strip_suffix(&format!(".{}", ext))
1271 .unwrap_or(&clean_req)
1272 .to_string();
1273 let result = input_definitions(&name, InputDefinitionOptions {
1274 extension: Some(Cow::Owned(ext.to_string())),
1275 noerror: true,
1276 reloadable: true,
1277 ..InputDefinitionOptions::default()
1278 });
1279 // input_definitions returns Err on not-found with noerror=true;
1280 // treat that as "binding not loaded, fall through to raw".
1281 result.is_ok()
1282 } else {
1283 false
1284 }
1285 } else {
1286 false
1287 }
1288 };
1289 if binding_loaded {
1290 Ok(())
1291 } else if let Some(path) = find_file(&clean_req, None) {
1292 // Found something plausible..
1293 // let ftype = if pathname_is_literaldata(path) { "tex" } else {
1294 // pathname_type(path)
1295 // };
1296
1297 // // Should we be doing anything about options in the next 2 cases?..... I kinda think not,
1298 // but? if (ftype == "rs") { // it's a LaTeXML binding.
1299 // load_latexml(request, path);
1300 // }
1301 // // Else some sort of "known" definitions type file, but not simply 'tex'
1302 // else if (ftype != "tex") && (pathname_is_raw(path)) {
1303 // load_tex_definitions(request, path);
1304 // } else {
1305 load_tex_content(&path, options)
1306 // }
1307 } else {
1308 // Perl heuristic: if the file has no directory, and is a .tex or no extension,
1309 // try loading it as definitions (which checks for binding dispatchers).
1310 // This handles cases like \input tcilatex where tcilatex.tex.ltxml exists.
1311 let has_dir = clean_req.contains('/') || clean_req.contains('\\');
1312 let ext = clean_req.rsplit('.').next().unwrap_or("");
1313 let is_tex_like = ext == clean_req.as_ref() || ext == "tex"; // no extension or .tex
1314 if !has_dir && is_tex_like {
1315 // Try loading as a .tex binding (e.g. tcilatex → tcilatex.tex)
1316 let tex_name = if ext == "tex" {
1317 clean_req.to_string()
1318 } else {
1319 s!("{}.tex", clean_req)
1320 };
1321 if load_binding(&tex_name)?.is_some() {
1322 return Ok(());
1323 }
1324 }
1325 // Couldn't find anything?
1326 note_status(LogStatus::Missing, Some(request));
1327 Error!(
1328 "missing_file",
1329 request,
1330 s!("Can't find TeX file {}", request)
1331 );
1332 Ok(())
1333 }
1334}
1335
1336fn load_tex_definitions(
1337 request: &str,
1338 pathname: &str,
1339 reloadable: bool,
1340 at_letter: bool,
1341 grandparent_in_expl3: bool,
1342) -> Result<()> {
1343 // Perl Package.pm L2334: $STATE->getStomach->leaveHorizontal_internal;
1344 // Defensive cleanup before reading definitions — if we're somehow in
1345 // horizontal mode while bound to vertical (e.g. after \par-less inline
1346 // text), repack and flip MODE in-place. No-op in the common case but
1347 // matches Perl's pre-load state hygiene.
1348 leave_horizontal_internal();
1349
1350 // Snapshot expl3-state at load entry. The cleanup hook below should
1351 // only restore catcodes if THIS load activated expl3; if the calling
1352 // context was already in expl3 mode (e.g. tasks.sty has run
1353 // `\ExplSyntaxOn` and is now `\file_input:n` ing a child file like
1354 // tasks.cfg), we must preserve the active state for the caller.
1355 // Without this guard, the nested cleanup would reset `_` and `:` to
1356 // OTHER/SUB inside the parent's processing, breaking everything past
1357 // the nested load (e.g. tasks.sty line 817's `\file_input_stop:`).
1358 // Witness for this exact failure: arXiv:2602.21210, 2604.21347,
1359 // 2604.22630, 2604.23234, 2604.22528 (tasks.sty + expl3 cluster,
1360 // Task #20).
1361 let entered_expl3 = lookup_catcode('_') == Some(Catcode::LETTER);
1362
1363 if !pathname::is_literaldata(pathname) {
1364 // We can't analyze literal data's pathnames!
1365 // let (dir, name, extension) = pathname::split(pathname);
1366
1367 // Don't load if we've already loaded it before.
1368 // Note that we'll still load it if we've already loaded only the ltxml version
1369 // since someone's presumably asking _explicitly_ for the raw TeX version.
1370 // It's probably even the ltxml version is asking for it!!
1371 // Of course, now it will be marked and wont get reloaded!
1372 // Per OXIDIZED_DESIGN #23: raw .sty/.cls/.def load tracks
1373 // `<request>_raw_loaded`, separate from the binding `<request>_loaded`.
1374 // This lets a binding .rs load the raw file of the same name without
1375 // the flags clobbering each other.
1376 if lookup_bool(&s!("{request}_raw_loaded")) && !reloadable && !pathname::is_reloadable(pathname)
1377 {
1378 return Ok(());
1379 }
1380 assign_value(&s!("{request}_raw_loaded"), true, Some(Scope::Global));
1381 }
1382
1383 // Note that we are reading definitions (and recursive input is assumed also definitions)
1384 let was_interpreting = lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS"));
1385 // And that if we're interpreting this TeX file of definitions,
1386 // we probably should interpret any TeX files IT loads.
1387 let was_including_styles = lookup_bool("INCLUDE_STYLES");
1388 assign_value_sym(crate::pin!("INTERPRETING_DEFINITIONS"), true, None);
1389 // If we're reading in these definitions, probaly will accept included ones?
1390 // (but not forbid ltxml ?)
1391 assign_value("INCLUDE_STYLES", true, None);
1392 // When set, this variable allows redefinitions of locked defns.
1393 // It is set in before/after methods to allow local rebinding of commands
1394 // but loading of sources & bindings is typically done in before/after methods of constructors!
1395 // This re-locks defns during reading of TeX packages.
1396 local_state_unlocked(false);
1397 let content_str = lookup_string(&s!("{pathname}_contents"));
1398 let content = if content_str.is_empty() {
1399 None
1400 } else {
1401 Some(content_str)
1402 };
1403 let pathname_mouth = Mouth::create(pathname, MouthOptions {
1404 fordefinitions: true,
1405 at_letter,
1406 notes: true,
1407 content,
1408 ..MouthOptions::default()
1409 })?;
1410
1411 gullet::reading_from_mouth(pathname_mouth, move || -> Result<()> {
1412 while let Some(token) = gullet::read_x_token(Some(false), false, None)? {
1413 if token != T_SPACE!() {
1414 invoke_token(&token)?;
1415 }
1416 }
1417 Ok(())
1418 })?;
1419
1420 // Expl3 scope-exit cleanup: if a raw .sty load activated expl3 catcodes
1421 // via `\ProvidesExplPackage` or explicit `\ExplSyntaxOn` and forgot to
1422 // pair it with `\ExplSyntaxOff` (e.g. lipsum.sty, which relies on an
1423 // `\AtEndOfPackage`-style hook the autoload chain doesn't register),
1424 // digest `\ExplSyntaxOff` now so the pending `\group_begin:` frame pops
1425 // and catcodes restore before the next package loads.
1426 //
1427 // Perl's `TeX.pool.ltxml` L44-47 acknowledges this as a known edge-
1428 // case of the `\ProvidesExplPackage` autoload pattern.
1429 //
1430 // Skip expl3 / xparse / l3keys2e / expl3-code — those legitimately
1431 // leave expl3 active for their callers.
1432 {
1433 let (_, base, _ext) = pathname::split(pathname);
1434 let is_expl3_core = matches!(
1435 base.as_str(),
1436 "expl3" | "xparse" | "l3keys2e" | "expl3-code"
1437 );
1438 // Use grandparent_in_expl3 (snapshotted before `\@pushfilename`)
1439 // rather than entered_expl3 (snapshotted after the push flipped `_`
1440 // to SUB). Without this, sub-loads inside an active expl3 frame
1441 // saw entered_expl3=false (because of the push's `\ExplSyntaxOff`)
1442 // and over-fired `\ExplSyntaxOff` at exit, which then leaks SUB
1443 // into the grandparent's continued reading once `\@popfilename`
1444 // pops the status stack and would otherwise restore `\ExplSyntaxOn`.
1445 // Witness: `\usepackage{xsavebox}` minimal repro (xsavebox →
1446 // sys_load_backend → l3backend-dvips.def); arXiv:2509.05997/.07893/
1447 // .02344, 2510.13206/.13942/.17317.
1448 if !is_expl3_core
1449 && !grandparent_in_expl3
1450 && lookup_catcode('_') == Some(Catcode::LETTER)
1451 && lookup_definition(&T_CS!("\\ExplSyntaxOff"))?.is_some()
1452 {
1453 let _ = invoke_token(&T_CS!("\\ExplSyntaxOff"));
1454 }
1455 let _ = entered_expl3; // kept for historical context
1456 }
1457
1458 assign_value_sym(
1459 crate::pin!("INTERPRETING_DEFINITIONS"),
1460 was_interpreting,
1461 None,
1462 );
1463 assign_value("INCLUDE_STYLES", was_including_styles, None);
1464 expire_state_unlocked();
1465
1466 // Perl Package.pm L2376: Let(T_CS('\ver@'.$request), T_CS('\fmtversion'), 'global');
1467 // Mark the raw .sty/.tex as loaded so LaTeX's `\@ifpackageloaded` and
1468 // `\RequirePackage` date-version guards work after a raw TeX load. Perl
1469 // unconditionally Lets here (in contrast to the LTXML loader at line 339,
1470 // which only Lets when undefined).
1471 let ver_cs = T_CS!(s!("\\ver@{}", request));
1472 let_i(&ver_cs, &T_CS!("\\fmtversion"), Some(Scope::Global));
1473
1474 Ok(())
1475}
1476
1477pub fn load_tex_content(path: &str, _options: InputOptions) -> Result<()> {
1478 // If there is a file-specific declaration file (name_tex.rs), load it first!
1479 // TODO: is this `.latexml` variation still relevant in the Rust port?
1480 let _has_binding = if !pathname::is_literaldata(path) {
1481 let (_dir, base, _ext) = pathname::split(path);
1482 load_external_binding(&base)?.is_some() || load_binding(&base)?.is_some()
1483 } else {
1484 false
1485 };
1486
1487 // Open a mouth for that TeX content
1488 let cached = lookup_string(&s!("{path}_contents"));
1489 let cached_opt = if cached.is_empty() {
1490 None
1491 } else {
1492 Some(cached)
1493 };
1494 gullet::open_mouth(
1495 Mouth::create(path, MouthOptions {
1496 notes: true,
1497 content: cached_opt,
1498 ..MouthOptions::default()
1499 })?,
1500 true,
1501 );
1502 Ok(())
1503}
1504
1505/// Pass the sequence of @options to the package $name (if $ext is 'sty'),
1506/// or class $name (if $ext is 'cls').
1507/// Perl Package.pm: PassOptions($name, $ext, @options)
1508/// Stores options to be processed when the package/class is loaded.
1509pub fn pass_options(name: &str, ext: &str, options: Vec<String>) -> Result<()> {
1510 let key = s!("opt@{}.{}", name, ext);
1511 for opt in options {
1512 push_value(&key, arena::pin(&opt))?;
1513 }
1514 Ok(())
1515}
1516
1517/// Perl Package.pm L2430-2465: ProcessOptions / ProcessOptions*
1518/// `inorder=false` (\ProcessOptions) — execute in declared order, default handler for undeclared
1519/// `inorder=true` (\ProcessOptions*) — execute in order passed, class options silently skipped
1520pub fn process_options(inorder: bool, keysets: &[&str]) -> Result<()> {
1521 let currname_token = T_CS!("\\@currname");
1522 let currext_token = T_CS!("\\@currext");
1523 let name = if lookup_definition(&currname_token)?.is_some() {
1524 do_expand(currname_token)?.to_string()
1525 } else {
1526 String::new()
1527 };
1528 let ext = if lookup_definition(&currext_token)?.is_some() {
1529 do_expand(currext_token)?.to_string()
1530 } else {
1531 String::new()
1532 };
1533 let declared_options: VecDeque<Stored> = lookup_vecdeque("@declaredoptions").unwrap_or_default();
1534 let opt_key = s!("opt@{}.{}", name, ext);
1535 let current_options = lookup_vecdeque(&opt_key).unwrap_or_default();
1536 let class_options = lookup_vecdeque("class_options").unwrap_or_default();
1537
1538 let collect_syms = |vdq: &VecDeque<Stored>| -> Vec<SymStr> {
1539 let mut list = Vec::new();
1540 for item in vdq.iter() {
1541 match item {
1542 Stored::String(s) => {
1543 list.push(*s);
1544 },
1545 Stored::Strings(ss) => {
1546 for s in ss.iter() {
1547 list.push(*s);
1548 }
1549 },
1550 _ => {},
1551 }
1552 }
1553 list
1554 };
1555 let cur_options_list = collect_syms(¤t_options);
1556 let cls_options_list = collect_syms(&class_options);
1557
1558 if inorder {
1559 // Perl L2447-2453: ProcessOptions* — execute in the order passed
1560 // Class options: try executeOption_internal only (no default fallback)
1561 for option in &cls_options_list {
1562 let _ = execute_option_internal(*option, keysets)?;
1563 }
1564 // Current options: try executeOption, then default handler
1565 for option in &cur_options_list {
1566 if !execute_option_internal(*option, keysets)? {
1567 execute_default_option_internal(*option)?;
1568 }
1569 }
1570 } else {
1571 // Perl L2454-2461: ProcessOptions — execute in declared order
1572 let mut cur_set: HashSet<SymStr> = cur_options_list.iter().copied().collect();
1573 let mut cls_set: HashSet<SymStr> = cls_options_list.iter().copied().collect();
1574
1575 for option in declared_options.iter() {
1576 match option {
1577 Stored::String(content) if cur_set.remove(content) || cls_set.remove(content) => {
1578 execute_option_internal(*content, keysets)?;
1579 },
1580 Stored::Strings(contents) => {
1581 for content in contents.iter() {
1582 if cur_set.remove(content) || cls_set.remove(content) {
1583 execute_option_internal(*content, keysets)?;
1584 }
1585 }
1586 },
1587 _ => {},
1588 }
1589 }
1590 // Only undeclared CURRENT options go to default handler (not class options).
1591 // Perl L2460-2461: "foreach my $option (@curroptions)" — class options excluded.
1592 // Iterate cur_options_list (Vec, ordered) instead of cur_set (HashSet,
1593 // unordered) so unknown options enter `@unusedoptionlist` in source
1594 // order. Otherwise `\documentstyle[a,b,c]` produces an arbitrary
1595 // dispatch order, which breaks paper-local option chains that depend
1596 // on left-to-right evaluation (e.g. `[aaspp4,tighten]` requires
1597 // aaspp4's bindings — \tightenlines — to be defined before tighten.sty
1598 // body fires; driver: astro-ph9707180).
1599 for option in &cur_options_list {
1600 if cur_set.contains(option) {
1601 execute_default_option_internal(*option)?;
1602 }
1603 }
1604 }
1605 // Now, undefine the handlers
1606 for option in declared_options.iter() {
1607 let_i(&T_CS!(s!("\\ds@{}", option)), &T_RELAX!(), None);
1608 }
1609 Ok(())
1610}
1611
1612fn execute_option_internal(option: SymStr, keysets: &[&str]) -> Result<bool> {
1613 if let Some((qname, value)) = keyval_option_qname(option, keysets) {
1614 // Perl Package.pm handles `key=value` package options before normal
1615 // `\ds@...` lookup when ProcessOptions was given keysets. It digests
1616 // `\KV@<keyset>@<key>{<value>}`. Rust DefKeyVal entries without code
1617 // are ordinary macros, so also store the value under the qname for
1618 // package bindings that apply keyvals after ProcessOptions.
1619 assign_value(
1620 &qname,
1621 Stored::String(arena::pin(value.trim())),
1622 Some(Scope::Global),
1623 );
1624 digest(Tokens!(
1625 T_CS!(s!("\\{qname}")),
1626 T_BEGIN!(),
1627 ExplodeText!(&value),
1628 T_END!()
1629 ))?;
1630 return Ok(true);
1631 }
1632
1633 let cs = T_CS!(arena::with(option, |opt| s!("\\ds@{opt}")));
1634 if lookup_definition(&cs)?.is_some() {
1635 // Perl Package.pm L2482: `DefMacroI('\CurrentOption', undef, $option)` —
1636 // tokenizes `$option` via Tokens(Explode($option)) so letters get
1637 // catcode LETTER and others OTHER. Babel's `\ifx\CurrentOption\bbl@tempa`
1638 // (where `\bbl@tempa{frenchb}` produces LETTER tokens) only matches when
1639 // our `\CurrentOption` body has the same catcodes — packing the whole
1640 // option string into one OTHER-catcode "string" token would make the
1641 // \ifx silently false. Use SymExplodeText! to split per-character.
1642 def_macro(
1643 T_CS!("\\CurrentOption"),
1644 None,
1645 Tokens!(SymExplodeText!(option)),
1646 None,
1647 )?;
1648
1649 let unused = match remove_vecdeque("@unusedoptionlist") {
1650 Some(list) => list
1651 .into_iter()
1652 .filter(|item| {
1653 if let Stored::String(content) = item {
1654 *content != option
1655 } else {
1656 false
1657 }
1658 })
1659 .collect(),
1660 None => VecDeque::new(),
1661 };
1662 assign_value("@unusedoptionlist", Stored::VecDequeStored(unused), None);
1663 digest(cs)?;
1664 Ok(true)
1665 } else {
1666 Ok(false)
1667 }
1668}
1669
1670fn keyval_option_qname(option: SymStr, keysets: &[&str]) -> Option<(String, String)> {
1671 if keysets.is_empty() {
1672 return None;
1673 }
1674 let (key, value) = arena::with(option, |opt| {
1675 opt
1676 .split_once('=')
1677 .map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
1678 })?;
1679 if key.is_empty() {
1680 return None;
1681 }
1682 for keyset in keysets {
1683 let qname = crate::keyval::keyval_qname("KV", keyset, &key);
1684 if crate::keyval::keyval_get(&qname, "type").is_some() {
1685 return Some((qname, value));
1686 }
1687 }
1688 None
1689}
1690
1691fn execute_default_option_internal(option: SymStr) -> Result<bool> {
1692 // Perl Package.pm L2494: `DefMacroI('\CurrentOption', undef, $option)`.
1693 // Same catcode-faithful tokenization as execute_option_internal.
1694 def_macro(
1695 T_CS!("\\CurrentOption"),
1696 None,
1697 Tokens!(SymExplodeText!(option)),
1698 None,
1699 )?;
1700 digest(T_CS!("\\default@ds"))?;
1701 Ok(true)
1702}
1703
1704fn reset_options() -> Result<()> {
1705 assign_value(
1706 "@declaredoptions",
1707 Stored::VecDequeStored(VecDeque::new()),
1708 None,
1709 );
1710 let opt_unused_cs = if do_expand(T_CS!("\\@currext"))?.eq_text("cls") {
1711 "\\OptionNotUsed"
1712 } else {
1713 "\\@unknownoptionerror"
1714 };
1715 let_i(&T_CS!("\\default@ds"), &T_CS!(opt_unused_cs), None);
1716 Ok(())
1717}
1718
1719/// Execute a list of options (Perl: ExecuteOptions).
1720/// Tries each option's \ds@{option} definition; logs unexpected ones.
1721pub fn execute_options(options: &[&str]) -> Result<()> {
1722 let mut unhandled = Vec::new();
1723 for option in options {
1724 let sym = arena::pin(*option);
1725 if !execute_option_internal(sym, &[])? {
1726 unhandled.push(*option);
1727 }
1728 }
1729 for option in &unhandled {
1730 Info!(
1731 "unexpected",
1732 *option,
1733 s!("Unexpected options passed to ExecuteOptions '{option}'")
1734 );
1735 }
1736 Ok(())
1737}
1738
1739pub struct RequireOptions {
1740 pub options: Vec<String>,
1741 pub withoptions: Option<Vec<String>>,
1742 pub extension: Option<Cow<'static, str>>,
1743 pub searchpaths_only: bool,
1744 pub as_class: bool,
1745 pub noltxml: Option<bool>,
1746 pub notex: Option<bool>,
1747 pub after: Tokens,
1748}
1749impl Default for RequireOptions {
1750 fn default() -> Self {
1751 RequireOptions {
1752 options: Vec::new(),
1753 withoptions: None,
1754 extension: None,
1755 notex: None,
1756 noltxml: None,
1757 as_class: false,
1758 searchpaths_only: false,
1759 after: Tokens!(),
1760 }
1761 }
1762}
1763
1764/// An opinionated binding for \RequirePackage.
1765///
1766/// This (and `FindFile`) needs to evolve a bit to support reading raw .sty (.def, etc) files from
1767/// the standard texmf directories. Maybe even use kpsewhich itself (INSTEAD of `pathname_find`
1768/// ???) Another potentially useful option might be that if we are reading a raw file,
1769/// perhaps it should just get digested immediately, since it shouldn't contribute any boxes.
1770pub fn require_package(name: &str, mut options: RequireOptions) -> Result<()> {
1771 // Perl Package.pm L2671-2672: notex defaults to true unless the user
1772 // explicitly set it, or INCLUDE_STYLES is true, or noltxml was passed
1773 // (a raw-only load explicitly requests raw TeX).
1774 if options.notex.is_none()
1775 && !lookup_bool("INCLUDE_STYLES")
1776 && !matches!(options.noltxml, Some(true))
1777 {
1778 options.notex = Some(true);
1779 }
1780 // Perl Package.pm L2674: top-level \RequirePackage can be limited to
1781 // local sources via searchpaths_only. Triggered by the `localrawstyles`
1782 // option to latexml.sty (sets `INCLUDE_STYLES => 'searchpaths'`).
1783 // Only applies when raw TeX is allowed (notex==false); otherwise the
1784 // gate is moot since find_file won't search on-disk anyway.
1785 if !options.searchpaths_only
1786 && !matches!(options.notex, Some(true))
1787 && lookup_string("INCLUDE_STYLES") == "searchpaths"
1788 {
1789 options.searchpaths_only = true;
1790 }
1791 if options.extension.is_none() {
1792 options.extension = Some("sty".into());
1793 }
1794 // OXIDIZED_DESIGN #65 (#311): a package loaded inside a bracket LaTeXML ITSELF
1795 // opened must still end up defined at the outermost level, so hoist the load's
1796 // meaning-delta past it. Real LaTeX has no such bracket — `\@fileswithoptions`
1797 // (latex.ltx L18700) refuses to load at `\currentgrouplevel > 0` — but we
1798 // execute a subfile preamble the real `standalone.sty` gobbles (divergence
1799 // #63), which splits the package in half: frame-local definitions, global
1800 // hooks. Witness: `pgfcoreexternal.code.tex` L152
1801 // `\newif\ifpgf@external@grabshipout` popped with the child while its L171-179
1802 // `\AtEndDocument` reaches the parent's `\end{document}`. `tex.rs::def_autoload`
1803 // already used this snapshot/hoist pair for the mirror-image autoload failure
1804 // (witness 1711.11576).
1805 //
1806 // `subfile:<depth>`, not a bare depth test: a group the AUTHOR wrote must keep real
1807 // LaTeX's verdict — `{\usepackage{amsthm}}` leaves `\theoremstyle` undefined in
1808 // pdflatex and Perl alike, and rescuing it would emit fewer errors than Perl on
1809 // an authoring mistake (guards
1810 // `06_cluster_regressions::author_written_group_around_usepackage_still_loses_the_package`,
1811 // and `100_stale_autoload_no_runaway` from a fresh process). The
1812 // two cases are indistinguishable by anything cheaper: both are a group opened
1813 // inside the current file, while `inPreamble` is true, at `\currentgrouplevel`
1814 // 1. Refuted alternatives in #65 — dropping the brackets, and `\globaldefs=1`.
1815 let pre_keys = if is_scope_active(subfile_scope_here()) {
1816 Some(snapshot_top_frame_meaning_keys())
1817 } else {
1818 None
1819 };
1820 let result = input_definitions(name, InputDefinitionOptions {
1821 extension: options.extension,
1822 handleoptions: true,
1823 // Pass classes options if we have NONE!
1824 withoptions: if options.options.is_empty() {
1825 Some(Vec::new())
1826 } else {
1827 None
1828 }, // fake boolean use, multi-type in latexml... refactor?
1829 options: options.options,
1830 as_class: options.as_class,
1831 noltxml: options.noltxml.unwrap_or(false),
1832 notex: options.notex.unwrap_or(false),
1833 searchpaths_only: options.searchpaths_only,
1834 after: options.after,
1835 ..InputDefinitionOptions::default()
1836 });
1837 if let Some(pre_keys) = pre_keys {
1838 hoist_top_frame_meaning_delta(&pre_keys);
1839 }
1840 // Perl Package.pm L2679 maybeRequireDependencies is invoked from
1841 // input_definitions's miss-handler. But that handler only runs when
1842 // the file was NOT found at all. For paper-bundled .sty files that
1843 // raw-load successfully without a .sty.ltxml binding, we still need
1844 // to scan transitive \RequirePackage so that bound deps fire too.
1845 // Mirrors the same fix for load_class (search "cls.ltxml_loaded").
1846 // Witness 2208.07400 (paper-bundled emnlp2022.sty has
1847 // \RequirePackage{caption} + others; without scan, \captionsetup
1848 // and similar are undefined).
1849 if !lookup_bool(&s!("{name}.sty.ltxml_loaded")) {
1850 maybe_require_dependencies(name, "sty");
1851 }
1852 result
1853}
1854
1855/// Perl: `RequirePackage($name, withoptions => 1)` — forward the current
1856/// package/class's options to the required child package. Reads
1857/// `\@currname` / `\@currext` to identify the caller, looks up its
1858/// `opt@<name>.<ext>` options, and passes them explicitly as the child's
1859/// options list. Mirrors `load_class_with_options` for the package path.
1860pub fn require_package_with_options(name: &str) -> Result<()> {
1861 let currname = if lookup_definition(&T_CS!("\\@currname"))?.is_some() {
1862 do_expand(T_CS!("\\@currname"))?.to_string()
1863 } else {
1864 String::new()
1865 };
1866 let currext = if lookup_definition(&T_CS!("\\@currext"))?.is_some() {
1867 do_expand(T_CS!("\\@currext"))?.to_string()
1868 } else {
1869 String::new()
1870 };
1871 let options: Vec<String> = if !currname.is_empty() {
1872 let key = s!("opt@{}.{}", currname, currext);
1873 lookup_vecdeque(&key)
1874 .unwrap_or_default()
1875 .iter()
1876 .filter_map(|item| match item {
1877 Stored::String(s) => Some(arena::to_string(*s)),
1878 _ => None,
1879 })
1880 .collect()
1881 } else {
1882 Vec::new()
1883 };
1884 require_package(name, RequireOptions {
1885 options,
1886 ..RequireOptions::default()
1887 })
1888}
1889
1890/// Perl Package.pm L2759-2796: maybeRequireDependencies
1891/// When a package/class file has no binding AND raw TeX loading is disabled,
1892/// scan the raw file for \RequirePackage/\usepackage/\LoadClass declarations
1893/// and load any dependencies that DO have bindings. This is a "best effort"
1894/// fallback that gives us the dependency chain without interpreting raw TeX.
1895// Strict translation of Perl `Package.pm:maybeRequireDependencies`
1896// (L2759-L2796). Scan a raw .sty/.cls file for transitive
1897// `\RequirePackage`, `\usepackage`, and (for classes) `\LoadClass`
1898// declarations and route them through `require_package` / `load_class`
1899// so the corresponding bindings get pulled in even when the original
1900// file has no .ltxml binding.
1901/// The `\addbibresource` names declared in a raw `.sty`/`.cls` body.
1902///
1903/// Split out of [`maybe_require_dependencies`] so the extraction is testable
1904/// without state: it is the whole of the BEYOND-PERL behaviour described there.
1905/// A comma-separated argument declares several resources at once, as biblatex
1906/// allows.
1907fn scan_bib_resources(code: &str) -> Vec<String> {
1908 use once_cell::sync::Lazy;
1909 use regex::Regex;
1910 static BIBRES_RE: Lazy<Regex> =
1911 Lazy::new(|| Regex::new(r"\\addbibresource\s*(?:\[[^\]]*\])?\s*\{([^\}]*)\}").unwrap());
1912 let mut out = Vec::new();
1913 for cap in BIBRES_RE.captures_iter(code) {
1914 for part in cap[1].split(',') {
1915 let name = part.trim();
1916 if !name.is_empty() && !out.iter().any(|o: &String| o == name) {
1917 out.push(name.to_string());
1918 }
1919 }
1920 }
1921 out
1922}
1923
1924#[cfg(test)]
1925mod bib_resource_scan_tests {
1926 use super::scan_bib_resources;
1927
1928 /// A journal class shipped with the paper declares the bibliography itself —
1929 /// `journaleducation.cls` (witness 2605.23724, 0 -> 35 entries),
1930 /// `cai26.cls` (2605.00270, 0 -> 25), `tau.cls` (2605.02720, 0 -> 31) — and
1931 /// the document then only writes `\printbibliography`. We never interpret
1932 /// that class, so this scan is the only place the declaration is visible.
1933 #[test]
1934 fn harvests_addbibresource_from_a_shipped_class() {
1935 assert_eq!(
1936 scan_bib_resources(r"\addbibresource{references.bib}"),
1937 vec!["references.bib"]
1938 );
1939 // A path, as journaleducation.cls writes it.
1940 assert_eq!(
1941 scan_bib_resources(r"\addbibresource{letters/refs.bib}"),
1942 vec!["letters/refs.bib"]
1943 );
1944 // biblatex's optional argument is skipped, not treated as the resource.
1945 assert_eq!(
1946 scan_bib_resources(r"\addbibresource[location=local]{tau.bib}"),
1947 vec!["tau.bib"]
1948 );
1949 // Several resources, and repeats collapse — naming a `.bib` twice would
1950 // make `\lx@bibliography` read it twice and double every entry.
1951 assert_eq!(
1952 scan_bib_resources("\\addbibresource{a.bib, b.bib}\n\\addbibresource{a.bib}"),
1953 vec!["a.bib", "b.bib"]
1954 );
1955 // Nothing to harvest is the common case and must stay empty.
1956 assert!(scan_bib_resources(r"\RequirePackage{biblatex}").is_empty());
1957 }
1958}
1959
1960fn maybe_require_dependencies(file: &str, ext_type: &str) {
1961 use once_cell::sync::Lazy;
1962 use regex::Regex;
1963
1964 // Rust-only re-entrancy guard. Perl avoids this case by other means
1965 // (the call-site of `maybeRequireDependencies` is the only entry).
1966 thread_local! { static SCANNING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; }
1967 if SCANNING.with(|s| s.get()) {
1968 return;
1969 }
1970 SCANNING.with(|s| s.set(true));
1971 struct ResetGuard;
1972 impl Drop for ResetGuard {
1973 fn drop(&mut self) { SCANNING.with(|s| s.set(false)); }
1974 }
1975 let _guard = ResetGuard;
1976
1977 // EXECUTED-SET GATE (Rust-only, more-robust-than-Perl). When this file was
1978 // actually RAW-LOADED, its `\usepackage`/`\RequirePackage` constructors ran
1979 // for every require the load REACHED — recording `<pkg>.usepackage_executed`.
1980 // A candidate the regex finds in the text but that is NOT in that set is one
1981 // whose `\usepackage` never executed — i.e. it sits inside a FALSE `\if…\fi`
1982 // the raw-load already skipped (e.g. `\ifpdf … \usepackage{hyperref} … \fi`
1983 // with `\ifpdf` false). Perl never dep-scans a raw-loaded file, so it never
1984 // anticipates such a package; mirror that by skipping it. The gate applies
1985 // ONLY when the file raw-loaded (key `<file>.<ext>_raw_loaded`): in the
1986 // miss-handler / `INCLUDE_STYLES=false` path no constructor ran, so the set
1987 // is empty and we must NOT filter (keep the anticipation). The flag is
1988 // cumulative+global, so a candidate that DID execute anywhere is kept — only
1989 // never-executed ones are dropped, which can never be a false skip.
1990 // Witnesses 1910.05586 (hyperref in false `\ifpdf` → cleveref "must be loaded
1991 // after hyperref"), 1804.09301 (xcolor in false `\ifacl@hyperref`).
1992 let raw_loaded = lookup_bool(&s!("{file}.{ext_type}_raw_loaded"));
1993
1994 // Perl L2776: `s/%[^\n]*\n//gs` — drop comment AND its trailing newline,
1995 // replacement is the empty string.
1996 static COMMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"%[^\n]*\n").unwrap());
1997 // `comment`-package block: `\begin{comment}…\end{comment}` is a verbatim-SKIP
1998 // environment, so a `\usepackage`/`\RequirePackage` inside it is NEVER loaded
1999 // by LaTeX. The dep-scan must not anticipate it. Same "more-robust than Perl"
2000 // rationale as the macro-def-body skip below. Witness 1901.05713: thesis.sty
2001 // has a commented-out `\usepackage{hyperref}` inside `\begin{comment}`, which
2002 // the scan otherwise loaded — tripping cleveref's "must be loaded after
2003 // hyperref" `\AtBeginDocument` order-check (hyperref appears loaded though the
2004 // author commented it out). `comment` doesn't nest, so a non-greedy match to
2005 // the first `\end{comment}` is correct.
2006 static COMMENT_ENV_RE: Lazy<Regex> =
2007 Lazy::new(|| Regex::new(r"(?s)\\begin\s*\{comment\}.*?\\end\s*\{comment\}").unwrap());
2008 // Perl L2777-2779 runs two separate substitutions, in this order:
2009 // first `\RequirePackage`, then `\usepackage`. Use two regexes so that
2010 // collected order matches Perl's call order to `$collect`.
2011 static REQ_RE: Lazy<Regex> =
2012 Lazy::new(|| Regex::new(r"\\RequirePackage\s*(?:\[([^\]]*)\])?\s*\{([^\}]*)\}").unwrap());
2013 static USE_RE: Lazy<Regex> =
2014 Lazy::new(|| Regex::new(r"\\usepackage\s*(?:\[([^\]]*)\])?\s*\{([^\}]*)\}").unwrap());
2015 static CLS_RE: Lazy<Regex> =
2016 Lazy::new(|| Regex::new(r"\\LoadClass\s*(?:\[([^\]]*)\])?\s*\{([^\}]*)\}").unwrap());
2017 // Matches a `\newcommand`/`\def`-family DEFINITION HEADER ending exactly at a
2018 // `{` — i.e. the brace that follows opens the macro BODY. Used to detect a
2019 // `\usepackage` that lives in a deferred macro body (loads only when the
2020 // macro is later expanded) vs one inside a load-time conditional.
2021 static DEF_BODY_HEADER_RE: Lazy<Regex> = Lazy::new(|| {
2022 Regex::new(
2023 r"(?s)(?:\\(?:re|provide)?newcommand|\\DeclareRobustCommand)\*?\s*(?:\{\s*\\[A-Za-z@]+\s*\}|\\[A-Za-z@]+)\s*(?:\[[^\]]*\]\s*)*$|\\[egx]?def\s*\\[A-Za-z@]+[^{}]*$",
2024 )
2025 .unwrap()
2026 });
2027 // The `\def`-family sub-case of DEF_BODY_HEADER_RE, capturing the defined
2028 // macro NAME (without backslash). A `\def\<m>{… \RequirePackage{P} …}` body
2029 // is only truly deferred if `\<m>` is never invoked; the AMS-class idiom
2030 // `\def\@tempa{\RequirePackage{amsmath}}…\@tempa` invokes it immediately and
2031 // so DOES load P (witness ijnam.cls → amsmath → `{aligned}`, 1911.03415).
2032 static DEF_NAME_RE: Lazy<Regex> =
2033 Lazy::new(|| Regex::new(r"(?s)\\[egx]?def\s*\\([A-Za-z@]+)[^{}]*$").unwrap());
2034 // A require inside a `\DeclareOption{<opt>}{…}` (or `\DeclareOption*{…}`)
2035 // code-arm is CONDITIONAL on the option being selected (resolved later by
2036 // `\ProcessOptions`), so the dep-scan must not eagerly load it. Matches when a
2037 // group's preceding window ends at `…\DeclareOption{<opt>}` / `…\DeclareOption*`
2038 // — i.e. the `{` it precedes opens the option's code arm. (Does NOT match
2039 // `\DeclareOptionX`/`\ExecuteOptions`/`\ProcessOptions`.)
2040 static DECLARE_OPTION_RE: Lazy<Regex> =
2041 Lazy::new(|| Regex::new(r"(?s)\\DeclareOption\*?\s*(?:\{[^{}]*\})?\s*$").unwrap());
2042
2043 // Perl L2761: `FindFile($file, type => $type, noltxml => 1)`. `$file`
2044 // is BARE — `FindFile` glues on `.$type` itself per L2073-2076.
2045 let raw_path = find_file(
2046 file,
2047 Some(FindFileOptions {
2048 ext_type: Some(Cow::Owned(ext_type.to_string())),
2049 forbid_ltxml: true, // Perl `noltxml => 1`
2050 ..FindFileOptions::default()
2051 }),
2052 );
2053 let Some(path) = raw_path else { return };
2054
2055 // Perl L2762-2766: slurp file. Check filecontents-cache first for the
2056 // inline-cls/sty case (e.g. `\begin{filecontents}{alggeom.cls}`), then
2057 // fall through to disk. Without the cache check, papers that bundle
2058 // their .cls inline via filecontents miss the dep-scan and downstream
2059 // CSes that the (now-cached) cls would have hand-loaded stay
2060 // undefined. Witness: arXiv:2604.09738.
2061 let cached = lookup_string(&s!("{}_contents", path));
2062 let code = if !cached.is_empty() {
2063 cached
2064 } else {
2065 // Use read (bytes) + lossy UTF-8 conversion so non-UTF-8 cls/sty
2066 // files (ISO-8859 with vendor copyright headers, e.g. cpc-hepnp.cls
2067 // with Chinese comments) still get scanned. read_to_string strict
2068 // UTF-8 validation would error out, leaving \RequirePackage{fancyhdr}
2069 // and friends silently undiscovered. Witness 2203.16500.
2070 match std::fs::read(&path) {
2071 Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
2072 Err(_) => {
2073 Warn!(
2074 "I/O",
2075 "read",
2076 s!("Couldn't open {} to scan dependencies, $!", path)
2077 );
2078 return;
2079 },
2080 }
2081 };
2082
2083 // Perl L2776: strip comments (replacement empty).
2084 let code = COMMENT_RE.replace_all(&code, "");
2085 // Strip `\begin{comment}…\end{comment}` blocks (see COMMENT_ENV_RE above).
2086 let code = COMMENT_ENV_RE.replace_all(&code, "");
2087
2088 // DIVERGENCE FROM PERL (deliberate, more-robust): do NOT dep-load a
2089 // `\usepackage` / `\RequirePackage` that sits in a DEFERRED macro-definition
2090 // body (a `\newcommand` / `\def`-family body) — it loads only if that macro
2091 // is later expanded, which the dep-scan must not eagerly force. Requires
2092 // inside LOAD-TIME conditionals (`\IfFileExists{X.sty}{\usepackage{X}}`,
2093 // `\@ifundefined{..}{..}`, `\if…\usepackage…\fi`) DO execute during raw-load
2094 // and MUST still be picked up. (Perl never force-loads the deferred ones: for
2095 // a normally raw-loaded file it doesn't dep-scan at all, and the bundled deps
2096 // it wants in the binding-bypass case run at load.) Witnesses:
2097 // - 1506.06200 — categorytheory.sty `\newcommand{\usediagrams}{\usepackage {diagrams}}` (never
2098 // invoked) must be SKIPPED (else the `diagrams` stub's `locked` `\begin{diagram}` shadows the
2099 // paper's tikz `{diagram}`).
2100 // - 1703.03673 — iau.cls `\IfFileExists{amssymb.sty}{…\usepackage{amssymb}…}` must be KEPT
2101 // (else `\bigstar` is undefined). An earlier brace-DEPTH filter wrongly skipped this
2102 // conditional; the def-body check is precise.
2103 // A `\usepackage` is deferred iff ANY enclosing `{…}` group is opened directly
2104 // by a `\newcommand`/`\def` definition header.
2105 // Is `\<name>` INVOKED (not merely defined) somewhere in the file? A bare
2106 // control-sequence occurrence not directly preceded by a `\…def`/`\let`
2107 // introducer counts as an invocation. Used to tell an executed deferred-load
2108 // idiom (`\def\@tempa{…\RequirePackage{P}…}\@tempa`) from a never-called one.
2109 let is_invoked = |name: &str| -> bool {
2110 let pat = s!("\\{name}");
2111 let mut from = 0usize;
2112 while let Some(rel) = code[from..].find(&pat) {
2113 let at = from + rel;
2114 from = at + pat.len();
2115 // Full control sequence: next char must not extend the name.
2116 let after_ok = code[at + pat.len()..]
2117 .chars()
2118 .next()
2119 .is_none_or(|c| !c.is_ascii_alphabetic() && c != '@');
2120 if !after_ok {
2121 continue;
2122 }
2123 // Not a (re)definition: the chars just before `\name` aren't `…def`/`…let`.
2124 let before = code[at.saturating_sub(8)..at].trim_end();
2125 if before.ends_with("def") || before.ends_with("let") {
2126 continue;
2127 }
2128 return true;
2129 }
2130 false
2131 };
2132 let in_macro_def_body = |start: usize| -> bool {
2133 let bytes = code.as_bytes();
2134 let mut stack: Vec<usize> = Vec::new();
2135 let mut i = 0usize;
2136 while i < start {
2137 match bytes[i] {
2138 b'\\' => {
2139 i += 2;
2140 continue;
2141 },
2142 b'{' => stack.push(i),
2143 b'}' => {
2144 stack.pop();
2145 },
2146 _ => {},
2147 }
2148 i += 1;
2149 }
2150 stack.iter().any(|&ob| {
2151 let lo = ob.saturating_sub(400);
2152 let window = code.get(lo..ob).unwrap_or(&code[..ob]);
2153 // A require inside a `\DeclareOption` code-arm is conditional (only the
2154 // option-selected arm runs, via `\ProcessOptions`) → defer it, even when
2155 // the arm `\def`s an INVOKED macro: aa/myaa.cls have
2156 // \DeclareOption{ascii}{\def\aa@inputenc{\RequirePackage[ascii]{inputenc}}}
2157 // …\DeclareOption{utf8}{…[utf8]…} … \aa@inputenc
2158 // where the single `\aa@inputenc` call would otherwise un-defer the FIRST
2159 // (ascii) arm via the `is_invoked` rule below and wrongly force
2160 // inputenc[ascii] (→ UTF-8 `ç` rejected). Perl defers all such (loads no
2161 // inputenc; the actual `utf8` default is the right answer and `ç` passes
2162 // through either way). Witness 1504.05963 (Perl 0 / Rust 1, now parity).
2163 if DECLARE_OPTION_RE.is_match(window) {
2164 return true;
2165 }
2166 // A `\def\<m>{…}` body only defers if `\<m>` is never invoked; an invoked
2167 // scratch macro (`\@tempa`) runs its body at load, so its require loads.
2168 if let Some(caps) = DEF_NAME_RE.captures(window) {
2169 return !is_invoked(&caps[1]);
2170 }
2171 // `\newcommand`/`\DeclareRobustCommand` user-command bodies stay deferred
2172 // (witness 1506.06200 `\newcommand{\usediagrams}{\usepackage{diagrams}}`).
2173 DEF_BODY_HEADER_RE.is_match(window)
2174 })
2175 };
2176 let top_level = |cap: ®ex::Captures| -> bool {
2177 cap
2178 .get(0)
2179 .map(|m| !in_macro_def_body(m.start()))
2180 .unwrap_or(true)
2181 };
2182
2183 // NOTE — a former Rust-only "conflicting option sets" heuristic was REMOVED
2184 // here (was: drop a package `\RequirePackage`'d / `\usepackage`'d with two or
2185 // more DIFFERENT option sets, as the signature of a deferred require inside a
2186 // `\def`/`\DeclareOption` body, e.g. aa.cls's
2187 // `\DeclareOption{ascii}{\def\aa@inputenc{\RequirePackage[ascii]{inputenc}}}`
2188 // …`{utf8}{…[utf8]…}`). It over-fired on a package required in BOTH arms of a
2189 // load-time `\if…\else…\fi` with different options — mutually exclusive, so
2190 // exactly one branch loads and the package MUST be kept. Witness: rist.cls's
2191 // `\ifpdf \RequirePackage[pdftex,…]{hyperref} \else \RequirePackage[dvipdfm,…]
2192 // {hyperref} \fi` (1912.00781: hyperref dropped → `\url` undefined). The
2193 // heuristic was also provably REDUNDANT: it only ever saw TOP-LEVEL requires
2194 // (the captures feeding it were `top_level`-filtered), but aa.cls's inputenc
2195 // lives in a `\def`/`\DeclareOption` BODY — already dropped by the def-body
2196 // `top_level` skip below, so it never entered the conflicting set at all. Perl
2197 // has no such gate (L2767-2774 is a plain dedup); the def-body `top_level` skip
2198 // plus the executed-set gate cover the legitimate cases faithfully.
2199 static OPT_SPLIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*,\s*").unwrap());
2200
2201 // Perl L2767-2774: shared `%dups` map, $collect closure splits on
2202 // `\s*,\s*` and only enrolls a package once, AND only if its
2203 // `.sty.ltxml_loaded` flag is unset.
2204 let mut packages: Vec<(String, Option<String>)> = Vec::new();
2205 let mut dups: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
2206 let mut collect = |pkg_csv: &str, raw_options: Option<&str>| {
2207 for p in OPT_SPLIT.split(pkg_csv) {
2208 if p.is_empty() {
2209 continue;
2210 }
2211 // Executed-set gate (see top of fn): when this file raw-loaded, drop a
2212 // candidate whose `\usepackage` never executed anywhere — it was inside a
2213 // false conditional the raw-load skipped.
2214 if raw_loaded && !lookup_bool(&s!("{p}.usepackage_executed")) {
2215 continue;
2216 }
2217 // Perl L2773: `!$dups{$p} && !LookupValue($p . '.sty.ltxml_loaded')`
2218 if !dups.contains(p) && !lookup_bool(&s!("{p}.sty.ltxml_loaded")) {
2219 packages.push((p.to_string(), raw_options.map(|s| s.to_string())));
2220 dups.insert(p.to_string());
2221 }
2222 }
2223 };
2224
2225 // Perl L2777: `\RequirePackage` first.
2226 for cap in REQ_RE.captures_iter(&code) {
2227 if !top_level(&cap) {
2228 continue;
2229 }
2230 collect(&cap[2], cap.get(1).map(|m| m.as_str()));
2231 }
2232 // Perl L2778-2779: `\usepackage` second.
2233 for cap in USE_RE.captures_iter(&code) {
2234 if !top_level(&cap) {
2235 continue;
2236 }
2237 collect(&cap[2], cap.get(1).map(|m| m.as_str()));
2238 }
2239
2240 // Perl L2767/L2781-2782: `@classes` is class-only, NO dup-check.
2241 let mut classes: Vec<(String, Option<String>)> = Vec::new();
2242 if ext_type == "cls" {
2243 for cap in CLS_RE.captures_iter(&code) {
2244 if !top_level(&cap) {
2245 continue;
2246 }
2247 let class = cap[2].to_string();
2248 if !class.is_empty() {
2249 classes.push((class, cap.get(1).map(|m| m.as_str().to_string())));
2250 }
2251 }
2252 }
2253
2254 // BEYOND PERL: harvest `\addbibresource` too.
2255 //
2256 // A journal class shipped with the paper routinely declares the bibliography
2257 // itself — `journaleducation.cls` has `\addbibresource{letters/refs.bib}`,
2258 // `cai26.cls` has `references.bib`, `tau.cls` has `tau.bib` — and the
2259 // document then only writes `\printbibliography`. We do not interpret that
2260 // class (no binding, OmniBus fallback), so the resource was never registered
2261 // and `\printbibliography` had nothing to print: no `MakeBibliography` line
2262 // at all and an empty References section, even though the `.bib` ships with
2263 // the paper. This scanner is already reading the file for its dependencies,
2264 // so it is the one place that can see the declaration.
2265 //
2266 // Registering the name is enough — `\biblatex@printbibliography` pops the
2267 // list and routes it through `\lx@bibliography`, which resolves the file (and
2268 // deduplicates, so a document that ALSO declares the same resource does not
2269 // get a doubled bibliography). Papers that never load biblatex simply leave
2270 // the value unread. Audit family F4(c); witnesses 2605.23724, 2605.00270,
2271 // 2605.02720.
2272 for res in scan_bib_resources(&code) {
2273 let _ = push_value("biblatex_resources", Stored::String(arena::pin(res)));
2274 }
2275
2276 // Perl L2784-2785: Info iff EITHER list is non-empty; message lists
2277 // class names then package names, separated by ',' (no space).
2278 if !classes.is_empty() || !packages.is_empty() {
2279 let names: Vec<&str> = classes
2280 .iter()
2281 .map(|(n, _)| n.as_str())
2282 .chain(packages.iter().map(|(n, _)| n.as_str()))
2283 .collect();
2284 Info!(
2285 "dependencies",
2286 "dependencies",
2287 s!("Loading dependencies for {}: {}", path, names.join(","))
2288 );
2289 }
2290
2291 // Perl L2786-2789: foreach class — gate by `FindFile($class, type=>'cls',
2292 // notex=>1)`, then `LoadClass(..., options=>[split ...])`.
2293 for (class, raw_opts) in classes {
2294 if find_file(
2295 &class,
2296 Some(FindFileOptions {
2297 ext_type: Some(Cow::Borrowed("cls")),
2298 notex: true, // Perl `notex => 1`
2299 ..FindFileOptions::default()
2300 }),
2301 )
2302 .is_some()
2303 {
2304 let opts: Vec<String> = raw_opts
2305 .as_deref()
2306 .map(|s| OPT_SPLIT.split(s).map(|x| x.to_string()).collect())
2307 .unwrap_or_default();
2308 let _ = load_class(&class, opts, Tokens::default());
2309 }
2310 }
2311
2312 // Perl L2790-2793: foreach package — gate by `FindFile($pkg, type=>'sty',
2313 // notex=>1)`, then `RequirePackage(..., options=>[split ...])`.
2314 for (pkg, raw_opts) in packages {
2315 if find_file(
2316 &pkg,
2317 Some(FindFileOptions {
2318 ext_type: Some(Cow::Borrowed("sty")),
2319 notex: true, // Perl `notex => 1`
2320 ..FindFileOptions::default()
2321 }),
2322 )
2323 .is_some()
2324 {
2325 let opts: Vec<String> = raw_opts
2326 .as_deref()
2327 .map(|s| OPT_SPLIT.split(s).map(|x| x.to_string()).collect())
2328 .unwrap_or_default();
2329 let _ = require_package(&pkg, RequireOptions {
2330 options: opts,
2331 ..RequireOptions::default()
2332 });
2333 }
2334 }
2335}
2336
2337/// Attach a resource (CSS, JavaScript, …) to the document being built.
2338///
2339/// Port of Perl `Package.pm:RequireResource` L3139-3158. A resource needs
2340/// either a pathname or inline content, and a mime-type: an absent type is
2341/// inferred from the pathname's extension (case-sensitively, as Perl's
2342/// `$resource_types{$ext}` lookup is), and a resource still lacking one after
2343/// that is skipped with a warning rather than emitted untyped.
2344///
2345/// The resource is queued as *pending* and folded into the document later,
2346/// covering Perl's `$LaTeXML::DOCUMENT ? addResource : PushValue(PENDING_RESOURCES)`
2347/// split — bindings routinely call this from a preamble, before any document
2348/// exists.
2349pub fn require_resource(mut resource: Resource) {
2350 if resource.name.is_empty() && resource.content.is_empty() {
2351 Warn!(
2352 "expected",
2353 "resource",
2354 "Resource must have a resource pathname or content; skipping"
2355 );
2356 return;
2357 }
2358 if resource.mimetype.is_empty() && !resource.name.is_empty() {
2359 // Perl Package.pm L3129: `my $ext = pathname_type($resource);` — no
2360 // case-folding; `$resource_types{$ext}` is a case-sensitive lookup.
2361 let ext = pathname::extension(&resource.name);
2362 resource.mimetype = resource_type(&ext);
2363 }
2364 if resource.mimetype.is_empty() {
2365 Warn!(
2366 "expected",
2367 "mime-type",
2368 "Resource must have a mime-type; skipping"
2369 );
2370 return;
2371 }
2372
2373 // If we've got a document, go ahead & put the resource in.
2374 // if (document.is_some()) {
2375 // document.as_mut().unwrap().add_resource(resource, resource);
2376 // } else {
2377 push_pending_resource(resource);
2378 // }
2379}
2380
2381/// Perl: `LoadClass($name, withoptions => 1)` — load a class passing the
2382/// caller's class options through to the child. Reads `class_options` from
2383/// state (populated by the outer `\documentclass` invocation) and forwards
2384/// those as the child's options list, matching Perl Package.pm LoadClass's
2385/// `withoptions` branch.
2386pub fn load_class_with_options(name: &str, after: Tokens) -> Result<()> {
2387 let class_opts = lookup_vecdeque("class_options").unwrap_or_default();
2388 let options: Vec<String> = class_opts
2389 .iter()
2390 .filter_map(|item| match item {
2391 Stored::String(s) => Some(arena::to_string(*s)),
2392 _ => None,
2393 })
2394 .collect();
2395 load_class(name, options, after)
2396}
2397
2398/// Load a document class — the `\documentclass` / `\LoadClass` implementation,
2399/// port of Perl `Package.pm:LoadClass` L2702-2730.
2400///
2401/// Resolution runs [`input_definitions`] for `<name>.cls`, then Perl's
2402/// three-step fallback when that yields no binding: the longest known
2403/// `.cls.ltxml` binding whose name PREFIXES the requested class (so an
2404/// author-renamed `IEEEtranTCOM.cls` still gets the `IEEEtran` binding), and
2405/// failing that `OmniBus`, the generic class supplying frontmatter, counter and
2406/// theorem definitions. Whether the raw `.cls` may be read at all is the
2407/// `INCLUDE_CLASSES` value's call (`searchpaths` restricts it to local
2408/// sources); by default it may not, because a raw load that "succeeds" would
2409/// suppress the OmniBus fallback while providing none of its bindings.
2410///
2411/// See [`load_class_with_options`] for Perl's `withoptions => 1` variant. The
2412/// per-branch reasoning, and the arXiv witnesses behind it, are in the body
2413/// comments.
2414pub fn load_class(name: &str, options: Vec<String>, after: Tokens) -> Result<()> {
2415 // Perl Package.pm LoadClass: $options{notex}=1 unless LookupValue('INCLUDE_CLASSES').
2416 // Defaults to NOT loading raw .cls. Only .cls.ltxml bindings are considered;
2417 // if the binding is missing, fall through to OmniBus (below). Allowing raw
2418 // .cls to "succeed" the load prevents the OmniBus fallback that provides
2419 // generic frontmatter / counter / theorem bindings.
2420 //
2421 // PERL-FAITHFUL (2026-05-30): a directory prefix does NOT force a raw .cls
2422 // load. Perl resolves a path-prefixed class to its basename BINDING when one
2423 // exists, else OmniBus — it loads `IEEEtran.cls.ltxml` for
2424 // `\documentclass{misc/ieeetran}` and falls to OmniBus for
2425 // `\documentclass{JINST-Sample-files/JINST}`. The basename→binding match is
2426 // handled in the `alternate` search below (it strips the path). Forcing a raw
2427 // load for any path-prefixed name (the old `&& !has_path_prefix` exception)
2428 // broke bundled classes whose raw load is semantically incomplete — JINST's
2429 // begin-document `\author`/`\abstract` checks fire and `\abstract@cs` is left
2430 // undefined, where Perl (OmniBus) is clean. Witness 1504.01965; the
2431 // misc/ieeetran case (2105.02087) now matches Perl via the basename binding.
2432 let notex_default = !lookup_bool("INCLUDE_CLASSES");
2433 // Perl Package.pm L2690: LoadClass can be limited to local SEARCHPATHS when
2434 // `localrawclasses` option sets `INCLUDE_CLASSES => 'searchpaths'`.
2435 let searchpaths_only = !notex_default && lookup_string("INCLUDE_CLASSES") == "searchpaths";
2436
2437 let result = input_definitions(name, InputDefinitionOptions {
2438 extension: Some(Cow::Borrowed("cls")),
2439 options: options.clone(),
2440 after: after.clone(),
2441 notex: notex_default,
2442 searchpaths_only,
2443 handleoptions: true,
2444 noerror: true,
2445 ..InputDefinitionOptions::default()
2446 });
2447 // Perl Package.pm L2700-2716: if no direct binding, try a prefix-match fallback.
2448 // Scan all known cls bindings (longest-first), pick the first whose name is a
2449 // prefix of the requested class. This catches author-renamed classes like
2450 // mysvjour3.cls → ProvidesClass{svjour3} → binding: svjour3
2451 // mn2ebis.cls → starts with "mn2e" → binding: mn2e
2452 // IEEEtranTCOM.cls → starts with "IEEEtran" → binding: IEEEtran
2453 // Fall through to OmniBus only when nothing matches.
2454 let will_fallback = (result.is_err()
2455 || (!lookup_bool(&format!("{name}.cls_loaded"))
2456 && !lookup_bool(&format!("{name}.cls_raw_loaded"))))
2457 && name != "OmniBus"
2458 && name != "article"
2459 && !lookup_bool("OmniBus.cls_loaded")
2460 && !lookup_bool("OmniBus.cls_raw_loaded");
2461
2462 // Perl Package.pm L2679 (LoadClass branch): scan the raw .cls for
2463 // \usepackage/\RequirePackage/\LoadClass dependencies when no .cls.ltxml
2464 // binding was found. This matters for unknown classes that nonetheless
2465 // pull in well-known packages (e.g. ijms-preprint.cls loads amsmath);
2466 // without it, downstream code like `\eqref{foo_bar}` sees `\eqref` as
2467 // undefined and the `_` characters then reach the stomach as subscript
2468 // catcodes, triggering runaway error recovery (arxiv 1003.0934 OOM).
2469 // Skip deps-scan only when a real `.cls.ltxml` binding has been
2470 // loaded — that binding is responsible for its own
2471 // `\RequirePackage` calls. The `cls_loaded` flag is set even for
2472 // a successful raw .cls load (no binding), so we MUST check the
2473 // binding-specific `cls.ltxml_loaded` flag instead. Without
2474 // this, paper-bundled .cls files (e.g. myclass.cls bundling
2475 // caption + many others, witness 2202.11535) raw-load
2476 // successfully but their `\RequirePackage` calls do NOT trigger
2477 // our binding loaders, leaving \captionsetup / \href / \affil
2478 // undefined.
2479 //
2480 // PERL-FAITHFUL ORDER: when we will fall through to an alternate
2481 // class binding (OmniBus or a prefix-match), DEFER the deps-scan
2482 // until AFTER the alternate is loaded. Otherwise the deps-scan
2483 // pulls natbib (et al.) ahead of OmniBus, and OmniBus's later
2484 // `Let('\lx@OmniBus@saved@bibitem', '\bibitem')` +
2485 // `DefMacro('\bibitem', ...)` clobbers natbib's `\lx@nat@bibitem`
2486 // binding — infinite-loop chain on
2487 // `\bibitem[\protect\citeauthoryear{...}{...}{...}]{key}`.
2488 // Witness: 1001.1919, 1001.5004, 0809.4358 (statsoc.cls),
2489 // 0904.3132, 0912.1617 (ectj.cls), 0904.3938 (compositio.cls),
2490 // 0908.3882 (third-input timeout), 0911.1590 (\tag\textsc cascade).
2491 // Perl's load order in this branch: warn missing-binding → load
2492 // alternate (OmniBus) → deps-scan pulls natbib LAST → natbib's Let
2493 // overrides OmniBus correctly.
2494 if !lookup_bool(&s!("{name}.cls.ltxml_loaded")) && !will_fallback {
2495 maybe_require_dependencies(name, "cls");
2496 }
2497 if will_fallback {
2498 note_status(LogStatus::Missing, Some(&format!("{name}.cls")));
2499
2500 // Perl: @classes = sort { -(length($a) <=> length($b)) } available_cls_names
2501 // my ($alternate) = grep { $class =~ /^\Q$_\E/ } @classes;
2502 // Flatten across ALL registered binding crates (latexml_package +
2503 // latexml_contrib + any future extensions) so contrib classes like
2504 // `memoir`, `siamltex`, `scrbook` are eligible alternates too.
2505 let alternate = {
2506 let mut sorted: Vec<&str> = get_class_binding_names()
2507 .into_iter()
2508 .filter(|n| *n != "OmniBus" && *n != name)
2509 .collect();
2510 sorted.sort_by_key(|n| std::cmp::Reverse(n.len()));
2511 // Strict-case prefix first (Perl-faithful: `$class =~ /^\Q$_\E/`), then
2512 // a case-insensitive fallback for binding entries that differ from the
2513 // class name ONLY in capitalization (e.g. `WileyNJDv5` class vs a
2514 // `wileyNJDv5` binding entry, witness 2406.08163). The ci fallback uses
2515 // FULL equality, NOT prefix: a ci-PREFIX match wrongly fired
2516 // `AAAI-Std` → `aa` (the 2-char A&A astronomy binding) because
2517 // `"aaai-std".starts_with("aa")` — but Perl's case-SENSITIVE `/^aa/`
2518 // against `AAAI-Std` finds nothing, so Perl falls back to OmniBus
2519 // (which defines `\address`). Full ci-equality keeps the
2520 // capitalization-only Wiley case while matching Perl's no-match →
2521 // OmniBus for AAAI-Std. Witness 2008.08548.
2522 sorted
2523 .iter()
2524 .copied()
2525 .find(|candidate| name.starts_with(candidate))
2526 .or_else(|| {
2527 sorted
2528 .iter()
2529 .copied()
2530 .find(|candidate| name.eq_ignore_ascii_case(candidate))
2531 })
2532 .or_else(|| {
2533 // Path-prefixed class (`misc/ieeetran`, `JINST-Sample-files/JINST`):
2534 // strip the directory and match the basename against a binding, so
2535 // `misc/ieeetran` → IEEEtran (Perl loads IEEEtran.cls.ltxml for it)
2536 // while `JINST-Sample-files/JINST` → no basename binding → OmniBus.
2537 // Case-insensitive FULL equality only (not prefix) — mirrors the
2538 // capitalization-only ci fallback above and avoids a basename like
2539 // `AAAI-Std` wrongly prefix-matching the 2-char `aa` binding.
2540 // Witnesses 1504.01965 (JINST→OmniBus), 2105.02087 (misc/ieeetran).
2541 let basename = name.rsplit(['/', '\\']).next().unwrap_or(name);
2542 if basename != name {
2543 sorted
2544 .iter()
2545 .copied()
2546 .find(|candidate| basename.eq_ignore_ascii_case(candidate))
2547 } else {
2548 None
2549 }
2550 })
2551 };
2552
2553 let target = alternate.unwrap_or("OmniBus");
2554 Warn!(
2555 "missing_file",
2556 name,
2557 format!("Can't find binding for class {name} (using {target})"),
2558 "Anticipate undefined macros or environments"
2559 );
2560 let loaded = input_definitions(target, InputDefinitionOptions {
2561 extension: Some(Cow::Borrowed("cls")),
2562 options,
2563 after,
2564 notex: true,
2565 handleoptions: true,
2566 noerror: true,
2567 ..InputDefinitionOptions::default()
2568 });
2569 // Perl Package.pm L2715: after loading the alternate class binding, scan
2570 // the raw class file for \usepackage/\RequirePackage/\LoadClass — the
2571 // alternate rarely covers all dependencies the renamed class adds.
2572 // Run for BOTH a real prefix-match alternate AND the pure-OmniBus
2573 // fallback (the deps were deferred above so their require_package
2574 // calls fire AFTER OmniBus is in place).
2575 maybe_require_dependencies(name, "cls");
2576 return loaded;
2577 }
2578 result
2579}
2580
2581/// configuration for searching for a file in the local filesystem
2582#[derive(Default)]
2583pub struct FindFileOptions {
2584 // TODO: this is no longer used in find_file, rather a level earlier
2585 pub forbid_ltxml: bool,
2586 pub notex: bool,
2587 pub ext_type: Option<Cow<'static, str>>,
2588 pub search_paths_only: bool,
2589}
2590
2591/// search for a file as prescribed by a `FindFileOptions` configuration
2592pub fn find_file(file: &str, options: Option<FindFileOptions>) -> Option<String> {
2593 let options = options.unwrap_or_default();
2594 if pathname::is_literaldata(file) {
2595 // If literal protocol return immediately (unless notex!)
2596 if options.notex {
2597 None
2598 } else {
2599 // TODO: Consider returning a Cow<str> instead to optimize
2600 Some(file.to_string())
2601 }
2602 } else if pathname::is_literaldata(file) || pathname::is_url(file) {
2603 // If a known special protocol return immediately
2604 Some(file.to_string())
2605 } else if let Some(ref ext) = options.ext_type {
2606 // Otherwise, it's some kind of "real" file, and we might have to search for it
2607 // Specific type requested? Search for it.
2608 // Add the extension, if it isn't already there. Perl tests with the DOT
2609 // (`/\.\Q$type\E$/`): a bare `\bibliography{mybib}` must retry
2610 // `mybib.bib` — the dotless ends_with("bib") matched the basename itself
2611 // and searched the literal `mybib`, silently disabling the .bib fallback
2612 // (PR_READINESS must-fix 6).
2613 let dotted = s!(".{}", ext);
2614 let aux_file = if file.ends_with(&dotted) {
2615 file.to_string()
2616 } else {
2617 s!("{}.{}", file, ext)
2618 };
2619 find_file_aux(&aux_file, &options)
2620 } else if file.ends_with(".tex") {
2621 // If no type given, we MAY expect .tex, or maybe NOT!!
2622 // No requested type, then .tex; Of course, it may already have it!
2623 find_file_aux(file, &options)
2624 } else {
2625 match find_file_aux(&s!("{}.tex", file), &options) {
2626 None => find_file_aux(file, &options),
2627 Some(f) => Some(f),
2628 }
2629 }
2630}
2631
2632/// Perl Package.pm L2141-2210: FindFile_fallback
2633/// Pure-check variant of [`find_file_fallback`]: strips the same
2634/// suffix/prefix patterns and reports whether the fallback name has a
2635/// registered binding, but DOES NOT eagerly invoke the binding's
2636/// `load_definitions`. Use this from pre-flight existence checks (e.g.
2637/// `class_cls_via_fallback` in tex_job.rs) where firing the binding's
2638/// body has side effects (\LoadClass, \RequirePackage) that contaminate
2639/// the subsequent real load. Witness: astro-ph0002213 — the `\psfig`
2640/// cluster fix (mn1 → mn fallback was eagerly running mn.cls's
2641/// `\LoadClass{article}` before the `\documentstyle[epsfig]{mn1}`
2642/// option-pass-through machinery had a chance to enqueue `epsfig` into
2643/// `opt@article.cls`).
2644pub fn find_file_fallback_exists(name: &str, ext_type: &str) -> bool {
2645 use regex::Regex;
2646
2647 use crate::state::binding_exists;
2648 // Mirror find_file_fallback's regex set exactly.
2649 let suffix_rx = match Regex::new(
2650 r"(?i)[._-](arx|arxiv|conference|workshop|tmp|alternate|preprint|fixed|[vV]?[-_.\d]+|old|new|final|clean|mine|priv|rev|mod|modified|edited|custom|altered|rtx)$",
2651 ) {
2652 Ok(rx) => rx,
2653 Err(_) => return false,
2654 };
2655 let glued_rx = match Regex::new(r"(?i)([vV]?[-_.\d]+|arxiv)$") {
2656 Ok(rx) => rx,
2657 Err(_) => return false,
2658 };
2659 let prefix_rx = match Regex::new(r"(?i)^((?:rw|my|preprint)[-_.]?)") {
2660 Ok(rx) => rx,
2661 Err(_) => return false,
2662 };
2663 let basename = pathname::file_name(name);
2664 let mut base = if basename.is_empty() {
2665 name.to_string()
2666 } else {
2667 basename
2668 };
2669 let mut changed = base != name;
2670 loop {
2671 if let Some(m) = suffix_rx.find(&base) {
2672 base = base[..m.start()].to_string();
2673 changed = true;
2674 continue;
2675 }
2676 if let Some(m) = glued_rx.find(&base) {
2677 base = base[..m.start()].to_string();
2678 changed = true;
2679 continue;
2680 }
2681 if let Some(m) = prefix_rx.find(&base) {
2682 base = base[m.end()..].to_string();
2683 changed = true;
2684 continue;
2685 }
2686 break;
2687 }
2688 if !changed || base.is_empty() || base == name {
2689 return false;
2690 }
2691 binding_exists(&base, ext_type)
2692}
2693
2694/// Kind of fallback that matched, returned by [`find_file_fallback`].
2695///
2696/// `Versioned` — suffix/prefix stripping changed the basename (Perl
2697/// FindFile_fallback's core function). Drivers: 1206.0536 (mysvjour3
2698/// → svjour3), astro-ph0005021 (./aaspp4 → ./aaspp).
2699///
2700/// `BasenameOnly` — the *only* change was directory-prefix removal,
2701/// matching the binding registry by leaf name. This is a Rust-specific
2702/// extension on top of Perl's FindFile_fallback that exists because
2703/// our contrib-binding registry is keyed by basename. Drivers:
2704/// 2105.02087 (misc/ieeetran → IEEEtran binding); 2405.18387
2705/// (assets/equations → equations binding).
2706///
2707/// Both kinds win unconditionally over local raw `.sty`/`.cls` files
2708/// at the call site (see `input_definitions` Step 3): the `.rs`
2709/// bindings are hand-tuned for the conversion, so a binding match
2710/// always supersedes a co-located vendored copy. The variant is
2711/// preserved for diagnostics and potential future policy tweaks.
2712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2713pub enum FallbackKind {
2714 Versioned,
2715 BasenameOnly,
2716}
2717
2718/// Strip version/arxiv suffixes from package names to find existing bindings.
2719/// Returns the fallback filename (with extension) and which kind of fallback fired.
2720pub fn find_file_fallback(name: &str, ext_type: &str) -> Option<(String, FallbackKind)> {
2721 use regex::Regex;
2722 // Suffixes with separator (Perl @find_fallback_suffixes)
2723 let suffix_rx = Regex::new(
2724 r"(?i)[._-](arx|arxiv|conference|workshop|tmp|alternate|preprint|fixed|[vV]?[-_.\d]+|old|new|final|clean|mine|priv|rev|mod|modified|edited|custom|altered|rtx)$"
2725 ).ok()?;
2726 // Glued suffixes without separator
2727 let glued_rx = Regex::new(r"(?i)([vV]?[-_.\d]+|arxiv)$").ok()?;
2728 // Prefixes. Perl Package.pm L2182: `^((?:rw|my|preprint)[-_.]?)` —
2729 // separator is OPTIONAL, so `mysvjour3` strips to `svjour3` (not just
2730 // `mysvjour`). Caught on arxiv 1206.0536 (\documentclass{mysvjour3}).
2731 let prefix_rx = Regex::new(r"(?i)^((?:rw|my|preprint)[-_.]?)").ok()?;
2732
2733 // Strip a leading directory path (Perl Package.pm L2167-2170: FindFile_fallback
2734 // calls `pathname_name($name)` first, so e.g. `\documentclass{./sty/IEEEtran}`
2735 // routes the basename `IEEEtran` through the binding-name registry. Without
2736 // this, `IEEEtran.cls.ltxml` is missed because `./sty/IEEEtran.cls.ltxml`
2737 // never matches the @ltxml_paths registry. Driver paper: arXiv:1308.6663.
2738 let basename = pathname::file_name(name);
2739 let mut base = if basename.is_empty() {
2740 name.to_string()
2741 } else {
2742 basename
2743 };
2744 let dir_stripped = base != name;
2745 let mut suffix_stripped = false;
2746 // Iteratively strip suffixes, then glued, then prefixes.
2747 //
2748 // Perl's FindFile_fallback (Package.pm:2174) version-strips the FULL `$file`
2749 // *with its directory prefix intact*, so the prefix regex `^(rw|my|preprint)`
2750 // — anchored at the very START of the string — never matches a name that
2751 // begins with a directory (e.g. `sty/myunits` starts with `sty/`, not `my`).
2752 // We strip the directory FIRST (to allow a basename-exact binding match like
2753 // `misc/ieeetran` → IEEEtran), so we must NOT then apply the `^`-anchored
2754 // prefix strip to the basename, or `sty/myunits` wrongly becomes `units` and
2755 // loads the stock units.sty instead of the paper-local myunits.sty (which
2756 // defines `\T`/`\fC`/`\Cm` via its `\defUnit` mechanism). Witness 1702.05093.
2757 // The suffix/glued strips ARE `$`-anchored, so they still match the tail of a
2758 // dir-prefixed name in Perl (`./aaspp4` → `./aaspp`); keep applying them.
2759 loop {
2760 if let Some(m) = suffix_rx.find(&base) {
2761 base = base[..m.start()].to_string();
2762 suffix_stripped = true;
2763 continue;
2764 }
2765 if let Some(m) = glued_rx.find(&base) {
2766 base = base[..m.start()].to_string();
2767 suffix_stripped = true;
2768 continue;
2769 }
2770 if !dir_stripped && let Some(m) = prefix_rx.find(&base) {
2771 base = base[m.end()..].to_string();
2772 suffix_stripped = true;
2773 continue;
2774 }
2775 break;
2776 }
2777
2778 if !suffix_stripped && !dir_stripped {
2779 return None;
2780 }
2781 if base.is_empty() || base == name {
2782 return None;
2783 }
2784
2785 let kind = if suffix_stripped {
2786 FallbackKind::Versioned
2787 } else {
2788 FallbackKind::BasenameOnly
2789 };
2790
2791 let fallback_filename = format!("{base}.{ext_type}");
2792 // Check if fallback binding exists
2793 if matches!(load_binding(&fallback_filename), Ok(Some(_))) {
2794 // Binding exists but was loaded by the check — it's OK, the caller will mark loaded
2795 Some((fallback_filename, kind))
2796 } else if matches!(load_external_binding(&fallback_filename), Ok(Some(_))) {
2797 Some((fallback_filename, kind))
2798 } else {
2799 None
2800 }
2801}
2802
2803fn find_file_aux(file: &str, options: &FindFileOptions) -> Option<String> {
2804 // If cached, return simple path (it's a key into the cache)
2805 let cached = lookup_string(&s!("{}_contents", file));
2806 if !cached.is_empty() {
2807 return Some(file.to_string());
2808 }
2809 if pathname::is_absolute(file) {
2810 // Perl Package.pm L2089-2093:
2811 // if pathname_is_absolute($file) {
2812 // if (!$options{noltxml}) {
2813 // return $file . '.ltxml' if -f ($file . '.ltxml'); }
2814 // return $file if -f $file;
2815 // return; }
2816 // No `<file>.ltxml` lookup (Perl checked one here): a `.ltxml` is a Perl
2817 // LaTeXML binding latexml-oxide can never read. Binding availability is
2818 // decided by the DISPATCHER (compiled + Rhai) in the relative-path branch's
2819 // `notex` fast-path, not by a file on disk.
2820 if Path::new(file).exists() {
2821 Some(file.to_string())
2822 } else {
2823 None
2824 }
2825 } else if pathname::is_nasty(file) {
2826 // If it is a nasty filename, we won't touch it.
2827 // we DO NOT want to pass this to kpathse or such!
2828 None
2829 } else {
2830 // Note that the strategy is complicated by the fact that
2831 // (1) we prefer .ltxml bindings, if present
2832 // (2) those MAY be present in kpsewhich's DB (although our searchpaths take precedence!)
2833 // (3) BUT we want to avoid kpsewhich if we can, since it's slower
2834 // (4) depending on switches we may EXCLUDE .ltxml OR raw tex OR allow both.
2835 let paths: Vec<String> = get_search_paths();
2836 // let _urlbase = state!().lookup_value("URLBASE");
2837 // let _nopaths = lookup_bool("REMOTE_REQUEST");
2838 // let _ltxml_paths: Vec<String> = if nopaths { vec![] } else { paths.clone() };
2839
2840 // Rust equivalent of Perl's ".ltxml" check: if the binding dispatcher
2841 // has an entry for this file, consider it "found". This is how Perl's
2842 // FindFile discovers pgfsys-latexml.def.ltxml etc.
2843 //
2844 // Two registry kinds are consulted (in order of cost):
2845 // 1. The per-call `{file}_binding_available` runtime flag, which packages can set to
2846 // pre-announce their availability (used by pgf_sty for `pgfsys-latexml.def`).
2847 // 2. The compile-time class registry (latexml_package's `BINDINGS`) surfaced via
2848 // `state::get_class_binding_names()`. Without this, `find_file("revtex4-1.cls",
2849 // notex=true)` returned None for compiled-in bindings — so AIAA.cls's
2850 // `\LoadClass{revtex4-1}` was silently skipped, breaking the eager natbib transitive load
2851 // (1709.05096 / AIAA → 60s wall-clock SIGABRT in the autoload- trapped-by-abstract loop).
2852 // Binding-marker fast paths. ONLY fire when caller has requested
2853 // `notex=true` (i.e. caller wants binding-only search, not a real
2854 // disk path). Without this gate, raw `\openin` /`\IfFileExists`
2855 // calls (notex=false) get a literal binding name back as if it were
2856 // a path — `Mouth::open_file` then fails / produces an empty mouth
2857 // and `\ifeof` returns true, masking the file as missing. Mirrors
2858 // Perl `pathname_find`: only `noltxml=>0,notex=>1` returns binding
2859 // names; the disk-search variant only resolves real files.
2860 // Triggered by 2026-04-26 t1enc.def-cascade investigation: raw
2861 // fonttext.ltx's `\input {t1enc.def}` opens via raw `\openin` /
2862 // `\IfFileExists`; without this gate find_file returned literal
2863 // "t1enc.def" → empty mouth → kernel's `\@missingfileerror` → 1M
2864 // TooManyErrors during latex.ltx dump-build.
2865 if !options.forbid_ltxml && options.notex {
2866 if lookup_bool(&s!("{file}_binding_available")) {
2867 return Some(file.to_string());
2868 }
2869 // Check the compile-time binding registries from latexml_package and
2870 // latexml_contrib for ANY (name, ext) pair that matches `file` (split
2871 // on the FIRST `.`, mirroring `dispatch()`'s split rule so multi-dot
2872 // names like `pgfmath.code.tex` resolve as `("pgfmath", "code.tex")`).
2873 if let Some((base, ext)) = file.split_once('.') {
2874 // Perl pathname_find L383-389: strict-case first, then case-insensitive
2875 // fallback (mirrors the dispatcher's lookup). Without this, requests
2876 // like `find_file("jhep.cls", notex=true)` would miss `("JHEP","cls")`
2877 // entries that derive from Perl's `JHEP.cls.ltxml` filename.
2878 let exact = get_binding_names()
2879 .iter()
2880 .any(|slice| slice.iter().any(|(n, e)| *n == base && *e == ext));
2881 let nocase = exact
2882 || get_binding_names().iter().any(|slice| {
2883 slice
2884 .iter()
2885 .any(|(n, e)| n.eq_ignore_ascii_case(base) && e.eq_ignore_ascii_case(ext))
2886 });
2887 if nocase {
2888 return Some(file.to_string());
2889 }
2890 }
2891 } else if !options.forbid_ltxml {
2892 // Narrow notex=false (disk-search) fallback: ONLY honor explicit
2893 // `<file>_binding_available` runtime flags, NOT the broad compile-time
2894 // registry. Mirrors Perl `\openin` calling default-args FindFile —
2895 // see `TeX_FileIO.pool.ltxml:50-64` "we SHOULD find an .ltxml version!"
2896 //
2897 // Use case: pgf.sty's pgfsys.code.tex `\pgfutil@InputIfFileExists{\pgfsysdriver}`
2898 // → `\pgfutil@IfFileExists{pgfsys-latexml.def}` → `\openin` with notex=false.
2899 // The openin impl in tex_file_io.rs creates an empty Mouth on
2900 // Mouth::create failure, so \ifeof=false → pgf inputs the driver.
2901 // pgf_sty.rs sets `pgfsys-latexml.def_binding_available=true` to
2902 // enable this; without the flag we don't fake-find arbitrary binding
2903 // names (which would re-introduce the t1enc.def `\@missingfileerror`
2904 // cascade documented above the notex=true branch).
2905 if lookup_bool(&s!("{file}_binding_available")) {
2906 return Some(file.to_string());
2907 }
2908 }
2909 // Perl L2123-2125: `elsif !notex && !interpreting && pathname_find($file,
2910 // paths=>$paths)` — search local paths for raw TeX.
2911 // (Rust does not yet honour `INTERPRETING_DEFINITIONS` — minor TODO,
2912 // acknowledged in the audit.)
2913 if !options.notex
2914 && let Some(path) = pathname::find(file, PathnameFindOptions {
2915 paths: Some(paths),
2916 ..PathnameFindOptions::default()
2917 })
2918 {
2919 return Some(path);
2920 }
2921 // Perl L2131-2136: build kpsewhich candidate list:
2922 // @candidates = ( "$file.ltxml" if !noltxml && !nopaths,
2923 // $file if !notex );
2924 // if (!searchpaths_only) && pathname_kpsewhich(@candidates) → -f $result
2925 // Perl gates the kpsewhich call only on `!searchpaths_only`; `notex`
2926 // and `noltxml` instead control which candidate names are tried.
2927 if options.search_paths_only {
2928 return None;
2929 }
2930 // NB: we do NOT add a `<file>.ltxml` candidate. That was Perl's logic —
2931 // a `.ltxml` is a Perl LaTeXML binding, and latexml-oxide can never read
2932 // one (as TeX or otherwise). The Rust equivalent of "is there a binding for
2933 // this name?" is the BINDING DISPATCHER (the `get_binding_names()` /
2934 // `{file}_binding_available` fast-path above, gated on `notex`), not a
2935 // `.ltxml` on disk. Otherwise kpsewhich returns e.g. `stex.sty.ltxml` (it
2936 // ships in TeX Live) ahead of the raw `stex.sty`, and the raw-loader
2937 // tokenizes the Perl source as TeX (`$out =~ s/^\s+//;` → "Script ^…",
2938 // `\DefMacroI` undefined, …). A missing binding falls through to the raw
2939 // `.sty`, matching pdflatex.
2940 let mut candidates: Vec<String> = Vec::new();
2941 if !options.notex {
2942 candidates.push(file.to_string());
2943 }
2944 if candidates.is_empty() {
2945 return None;
2946 }
2947 let refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
2948 match pathname::kpsewhich(&refs) {
2949 // Perl L2136: `(-f $result ? $result : undef)` — re-confirm existence.
2950 Some(p) if Path::new(&p).exists() => Some(p),
2951 _ => None,
2952 }
2953 }
2954}
2955
2956//======================================================================
2957// Declaring and Adjusting the Document Model.
2958//======================================================================
2959
2960/// Declare document-model properties for one element tag — Perl's `Tag`
2961/// (`Package.pm` L2015-2030).
2962///
2963/// Merges `properties` into the tag's existing entry rather than replacing it,
2964/// which is what lets a class, a package and the engine each contribute to the
2965/// same tag. How a property merges depends on the property: `auto_open` /
2966/// `auto_close` overwrite when given, while the accumulating properties
2967/// (`afterOpen`, `afterClose`, …) prepend or append per
2968/// [`TagOptionName::is_prepend`]/[`TagOptionName::is_append`] — Perl's
2969/// `$tag_prepend_options` / `$tag_append_options` tables, whose order decides
2970/// whether a late binding's hook runs before or after the engine's.
2971pub fn install_tag(tag: &str, mut properties: TagOptions) {
2972 let tag_ticket = arena::pin(tag);
2973 with_tag_property_mut(tag_ticket, |options| {
2974 if properties.auto_open.is_some() {
2975 options.auto_open = properties.auto_open;
2976 }
2977 if properties.auto_close.is_some() {
2978 options.auto_close = properties.auto_close;
2979 }
2980 for name in &TagOptionName::all() {
2981 if name.is_prepend() {
2982 options.prepend(name, properties.remove(name));
2983 } else if name.is_append() {
2984 options.append(name, properties.remove(name));
2985 } else {
2986 // we'll handle the regular ones out of the loop
2987 }
2988 }
2989 });
2990}
2991
2992/// Selects the RelaxNG schema defining the XML output language
2993pub fn select_relaxng_schema(schema: &str, namespaces: Option<HashMap<String, String>>) {
2994 // What verb here? Set, Choose,...
2995 model::set_relaxng_schema(schema);
2996 if let Some(namespaces) = namespaces {
2997 for (prefix, value) in namespaces {
2998 model::register_document_namespace(&prefix, Some(&value));
2999 }
3000 }
3001}
3002
3003/// Merge font attributes into the current font, **locally** — the change
3004/// reverts with the enclosing TeX group.
3005///
3006/// Port of Perl `Package.pm:MergeFont` L441-444
3007/// (`AssignValue(font => LookupValue('font')->merge(@kv), 'local')`). Only the
3008/// attributes `font` actually specifies are taken; the rest are inherited from
3009/// the font in force. See [`merge_font_ref`] to merge without moving the font.
3010pub fn merge_font(font: Font) {
3011 let new_font = lookup_font().unwrap().merge_ref(&font);
3012 assign_font(Rc::new(new_font), Some(Scope::Local));
3013}
3014
3015/// Like `merge_font` but borrows the font. Saves a clone when the caller
3016/// has a shared reference (e.g. via Rc) to the font being merged.
3017pub fn merge_font_ref(font: &Font) {
3018 let new_font = lookup_font().unwrap().merge_ref(font);
3019 assign_font(Rc::new(new_font), Some(Scope::Local));
3020}
3021
3022/// Define a named color (Perl: DefColor).
3023/// Stores as color_{name} and also defines \\color@{name} macro.
3024pub fn def_color(
3025 name: &str,
3026 color: &crate::common::color::Color,
3027 scope: Option<Scope>,
3028) -> Result<()> {
3029 use crate::common::color;
3030 // Check ifglobalcolors — Perl: $scope='global' if lookupDefinition(\ifglobalcolors) &&
3031 // IfCondition(\ifglobalcolors) Guard with lookup first: xcolor may not be loaded (e.g.
3032 // colordvi-only documents).
3033 //
3034 // ALSO force global when `color_force_global` is set: color_sty.rs's lazy
3035 // dvipsnam.def loader (recovering a dropped `\usepackage[dvipsnames]{color}`
3036 // option, e.g. when hyperref preloaded `color` first) fires from WITHIN a
3037 // grouped digestion (`\textcolor{Blue}{…}` / a listings keywordstyle), so the
3038 // 68 dvips colors would otherwise be defined in that local group and revert
3039 // before the next `\color{…}` — making only the FIRST color resolve. Perl
3040 // loads dvipsnam at the preamble (top level), so its colors persist; the flag
3041 // reproduces that global effect. Witness 1705.06183.
3042 let effective_scope = if (lookup_definition(&T_CS!("\\ifglobalcolors"))?.is_some()
3043 && if_condition(&T_CS!("\\ifglobalcolors"))? == Some(true))
3044 || lookup_bool("color_force_global")
3045 {
3046 Some(Scope::Global)
3047 } else {
3048 scope
3049 };
3050 // Store in state as "model c1 c2 ..."
3051 let stored = color.to_stored();
3052 assign_value(
3053 &s!("color_{name}"),
3054 Stored::String(arena::pin(stored)),
3055 effective_scope,
3056 );
3057 // Define \\color@{name} macro for reversion
3058 // Perl: DefMacroI('\\color@'.$name, undef,
3059 // '\relax\relax{model spec}{model}{spec_commas}')
3060 let model = color.model();
3061 let comps = color.components();
3062 let spec_parts: Vec<String> = comps.iter().map(|c| color::format_component(*c)).collect();
3063 let spec_space = spec_parts.join(" ");
3064 let spec_comma = spec_parts.join(",");
3065 let model_spec = s!("\\relax\\relax{{{model} {spec_space}}}{{{model}}}{{{spec_comma}}}");
3066 def_macro(
3067 T_CS!(s!("\\\\color@{name}")),
3068 None,
3069 crate::mouth::tokenize_internal(TeXString::assembled(model_spec)),
3070 Some(ExpandableOptions {
3071 scope: effective_scope,
3072 ..Default::default()
3073 }),
3074 )?;
3075 Ok(())
3076}
3077
3078/// Define a derived color model (Perl: DefColorModel).
3079/// Stores conversion functions for a derived color model.
3080pub fn def_color_model(model: &str, coremodel: &str) {
3081 assign_value(
3082 &s!("derived_color_model_{model}"),
3083 Stored::String(arena::pin(coremodel)),
3084 Some(Scope::Global),
3085 );
3086}
3087
3088/// Digest tokens in text mode, whatever mode the caller is in.
3089///
3090/// Port of Perl `Package.pm:DigestText` L405-411: brackets the digestion in
3091/// `beginMode`/`endMode` so that material which must come out as text — a
3092/// title, a caption, an alt string — does so even when it is being read from
3093/// inside math. Compare [`digest_literal`], which additionally forces an ASCII
3094/// encoding and leaves the mode's other side-effects out.
3095pub fn digest_text(stuff: Tokens) -> Result<Digested> {
3096 begin_mode("text")?;
3097 let value = digest(stuff);
3098 end_mode("text")?;
3099 value
3100}
3101
3102pub fn digest_literal<T: Into<Tokens>>(stuff: T) -> Result<Digested> {
3103 let stuff: Tokens = stuff.into();
3104 // Perhaps should do StartSemiverbatim, but is it safe to push a frame? (we might cover over
3105 // valid changes of state!)
3106 begin_mode("text")?;
3107
3108 // Fall back to the global text default if no font is currently assigned —
3109 // avoids a panic at this hot path (called from e.g. RefStepID / label
3110 // digestion) when the state's "font" slot hasn't been initialised. Matches
3111 // the same fallback `assign_value("font", Font::text_default(), …)` that
3112 // stomach::init uses at startup.
3113 let font = lookup_font().unwrap_or_else(|| Rc::new(Font::text_default()));
3114 assign_font(
3115 Rc::new(font.merge(fontmap!(encoding => "ASCII"))),
3116 Some(Scope::Local),
3117 ); // try to stay as ASCII as possible
3118
3119 let value = digest(stuff);
3120 assign_font(font, None);
3121 end_mode("text")?;
3122 value
3123}
3124
3125pub fn digest_if(token: Token) -> Result<Option<Digested>> {
3126 if lookup_definition(&token)?.is_some() {
3127 match digest(Tokens!(token)) {
3128 Ok(t) => Ok(Some(t)),
3129 Err(e) => Err(e),
3130 }
3131 } else {
3132 Ok(None)
3133 }
3134}
3135
3136/// Test a conditional `\ifXXX` and return its boolean result (Perl: IfCondition).
3137/// Looks up the conditional's test closure and invokes it.
3138pub fn if_condition(if_token: &Token) -> Result<Option<bool>> {
3139 use crate::definition::conditional::ConditionalType;
3140 if let Some(defn) = lookup_definition(if_token)?
3141 && defn.get_conditional_type() == Some(ConditionalType::If)
3142 && let Some(test) = defn.get_test()
3143 {
3144 // Read arguments for the conditional test
3145 let args = match defn.get_parameters() {
3146 Some(params) => params.read_arguments(Some(defn.as_ref()))?,
3147 None => Vec::new(),
3148 };
3149 return Ok(Some(test(args)?));
3150 }
3151 if x_equals(if_token, &T_CS!("\\iftrue")) {
3152 return Ok(Some(true));
3153 }
3154 if x_equals(if_token, &T_CS!("\\iffalse")) {
3155 return Ok(Some(false));
3156 }
3157 Error!(
3158 "expected",
3159 "conditional",
3160 s!("Expected a conditional, got '{}'", if_token.stringify())
3161 );
3162 Ok(None)
3163}
3164
3165/// Set the boolean value of a `\newif`-type conditional (Perl: SetCondition).
3166/// This is only for simple conditionals taking no arguments.
3167pub fn set_condition(if_token: &Token, value: bool, scope: Option<Scope>) {
3168 if let Ok(Some(defn)) = lookup_definition(if_token)
3169 && defn.get_parameters().is_none()
3170 {
3171 let target = if value {
3172 T_CS!("\\iftrue")
3173 } else {
3174 T_CS!("\\iffalse")
3175 };
3176 let_i(if_token, &target, scope);
3177 return;
3178 }
3179 emit_error(
3180 "expected",
3181 "newif",
3182 &format!(
3183 "Expected a conditional defined by \\newif, got '{}'",
3184 if_token.stringify()
3185 ),
3186 );
3187}
3188
3189/// Creates a single `Tokens` representing a TeX invocation of the lead `token` over a list of
3190/// arguments.
3191///
3192/// Note: currently this is near the `Mouth` representation of data, and deals purely with tokens.
3193/// A more generic version of this method may be able to support `ArgWrap` for the argument list.
3194/// Indeed, the return type may also be lifted to a new generic ArgWrap::Invocation, if there was
3195/// benefit.
3196pub fn build_invocation<T: Into<Token>>(token: T, args: Vec<Option<Tokens>>) -> Result<Tokens> {
3197 let token: Token = token.into();
3198 build_invocation_token(token, args)
3199}
3200
3201/// String entry point for `Invocation` (Perl: `Invocation($token,@args)` with a string
3202/// first argument). The string is tokenized via `TokenizeInternal`; if it yields a single
3203/// token, this reduces to `build_invocation` on that token. Otherwise the tokens are
3204/// treated as an "anonymous macro" containing parameter markers like `#1`, and the
3205/// arguments are substituted in.
3206pub fn build_invocation_str(
3207 spec: impl Into<TeXString>,
3208 args: Vec<Option<Tokens>>,
3209) -> Result<Tokens> {
3210 let spec = spec.into();
3211 let tokens = crate::mouth::tokenize_internal(spec.clone());
3212 let mut list = tokens.unlist();
3213 if list.len() > 1 {
3214 // Treat as anonymous macro
3215 let cow_args: Vec<Option<Cow<Tokens>>> = args.into_iter().map(|a| a.map(Cow::Owned)).collect();
3216 return Ok(
3217 Tokens::new(list)
3218 .pack_parameters()?
3219 .substitute_parameters(&cow_args),
3220 );
3221 }
3222 match list.pop() {
3223 Some(cs) => build_invocation_token(cs, args), // reduce to single token.
3224 None => {
3225 Error!(
3226 "unexpected",
3227 "invocation",
3228 s!("Can't invoke empty token spec '{}'", spec)
3229 );
3230 Ok(Tokens::default())
3231 },
3232 }
3233}
3234
3235fn build_invocation_token(token: Token, args: Vec<Option<Tokens>>) -> Result<Tokens> {
3236 // Note: token may have been \let to another defn!
3237 match lookup_definition(&token)? {
3238 Some(defn) => {
3239 let mut invoked_tokens = vec![token];
3240 if let Some(params) = defn.get_parameters() {
3241 invoked_tokens.extend(params.revert_arguments(args)?);
3242 }
3243 Ok(Tokens::new(invoked_tokens))
3244 },
3245 _ => {
3246 let message = s!("Can't invoke {:?}; it is undefined", token.stringify());
3247 token.with_cs_name(|csname| {
3248 Error!("undefined", csname, message);
3249 Ok(())
3250 })?;
3251 let mut invoked_tokens = vec![token];
3252 // DefConstructor!(token, convert_latex_args(args.len(), 0),
3253 // sub { LaTeXML::Core::Stomach::makeError($_[0], 'undefined', token); });
3254 let wrapped_args: Vec<Token> = args
3255 .into_iter()
3256 .flat_map(|arg_opt| {
3257 let mut wrapped = vec![T_BEGIN!()];
3258 if let Some(arg) = arg_opt {
3259 wrapped.extend(arg.unlist());
3260 }
3261 wrapped.push(T_END!());
3262 wrapped
3263 })
3264 .collect();
3265 invoked_tokens.extend(wrapped_args);
3266 Ok(Tokens::new(invoked_tokens))
3267 },
3268 }
3269}
3270
3271/// Convert a LaTeX-style argument spec to our Package form.
3272/// Ie. given $nargs and $optional, being the two optional arguments to
3273/// something like \newcommand, convert it to the form we use
3274pub fn convert_latex_args(
3275 mut nargs: usize,
3276 optional: Option<Tokens>,
3277) -> Result<Option<Parameters>> {
3278 let mut params = Vec::new();
3279 if let Some(tks) = optional {
3280 params.push(
3281 Parameter {
3282 name: pin!("Optional"),
3283 spec: arena::pin(s!("[Default:{}]", tks.clone().untex())),
3284 extra: vec![tks],
3285 ..Parameter::default()
3286 }
3287 .init()?,
3288 );
3289 // Perl `convertLaTeXArgs` does `$nargs-- if $optional`; with `$nargs==0`
3290 // that just yields an empty `1..$nargs` range. Saturate so the malformed
3291 // `\newcommand\foo[0][d]{}` (0 args yet an optional default — outside
3292 // LaTeX's 0..9 domain) can't underflow `usize` to ~2^64 and spin the loop
3293 // below. Mirrors the sibling `convert_twoopt_args`.
3294 nargs = nargs.saturating_sub(1);
3295 }
3296
3297 for _ in 1..=nargs {
3298 params.push(
3299 Parameter {
3300 name: pin!("Plain"),
3301 spec: pin!("{}"),
3302 ..Parameter::default()
3303 }
3304 .init()?,
3305 );
3306 }
3307 if params.is_empty() {
3308 Ok(None)
3309 } else {
3310 Ok(Some(Parameters::new(params)))
3311 }
3312}
3313
3314/// Two-optional variant of `convert_latex_args` — mirrors Perl's
3315/// `convert2optArgs` helper in twoopt.sty.ltxml. `\newcommandtwoopt{\cs}[n][d1][d2]{…}`
3316/// builds a signature of `[Default d1][Default d2]{…}…` where the remaining
3317/// `n - 2` args are plain required.
3318pub fn convert_twoopt_args(
3319 mut nargs: usize,
3320 opt1: Option<Tokens>,
3321 opt2: Option<Tokens>,
3322) -> Result<Option<Parameters>> {
3323 let mut params = Vec::new();
3324 if let Some(tks) = opt1 {
3325 params.push(
3326 Parameter {
3327 name: pin!("Optional"),
3328 spec: arena::pin(s!("[Default:{}]", tks.clone().untex())),
3329 extra: vec![tks],
3330 ..Parameter::default()
3331 }
3332 .init()?,
3333 );
3334 nargs = nargs.saturating_sub(1);
3335 }
3336 if let Some(tks) = opt2 {
3337 params.push(
3338 Parameter {
3339 name: pin!("Optional"),
3340 spec: arena::pin(s!("[Default:{}]", tks.clone().untex())),
3341 extra: vec![tks],
3342 ..Parameter::default()
3343 }
3344 .init()?,
3345 );
3346 nargs = nargs.saturating_sub(1);
3347 }
3348 for _ in 1..=nargs {
3349 params.push(
3350 Parameter {
3351 name: pin!("Plain"),
3352 spec: pin!("{}"),
3353 ..Parameter::default()
3354 }
3355 .init()?,
3356 );
3357 }
3358 if params.is_empty() {
3359 Ok(None)
3360 } else {
3361 Ok(Some(Parameters::new(params)))
3362 }
3363}
3364
3365/// Decode a codepoint using the fontmap for a given font and/or encoding (Perl: FontDecode).
3366/// Returns the decoded glyph (if any) and the possibly-adjusted font.
3367pub fn font_decode(
3368 code: i32,
3369 encoding_opt: Option<&str>,
3370 font_opt: Option<Rc<Font>>,
3371) -> (Option<char>, Option<Rc<Font>>) {
3372 if code < 0 {
3373 return (None, font_opt);
3374 }
3375 let font = font_opt.unwrap_or_else(|| lookup_font().unwrap());
3376 let encoding = match encoding_opt {
3377 Some(enc) => enc.to_string(),
3378 None => font
3379 .get_encoding()
3380 .map_or_else(|| "OT1".to_string(), |c| c.to_string()),
3381 };
3382 let map = load_font_map(&encoding);
3383 // Check for family-specific map. Use with_value to avoid cloning the
3384 // Stored envelope when the From<&Stored>→Option<Fontmap> impl only
3385 // needs the enum variant discriminant + an Rc bump.
3386 let (effective_map, _effective_enc) = if let Some(family) = font.get_family() {
3387 let fam_key = s!("{}_{}_fontmap", encoding, family);
3388 let fam_map: Option<Fontmap> = with_value(&fam_key, |v| v.and_then(|s| s.into()));
3389 if let Some(fm) = fam_map {
3390 (Some(fm), s!("{}_{}", encoding, family))
3391 } else {
3392 (map, encoding)
3393 }
3394 } else {
3395 (map, encoding)
3396 };
3397 let glyph = effective_map
3398 .as_ref()
3399 .and_then(|m| m.get(code as usize).copied().flatten());
3400 // In-math alphanumeric mathstyle handling is done in decodeMathChar instead
3401 (glyph, Some(font))
3402}
3403
3404/// Decode a string using the fontmap for a given encoding (Perl: FontDecodeString).
3405/// If `implicit` is true, codepoints missing from the map decode to themselves.
3406pub fn font_decode_string(string: &str, encoding_opt: Option<&str>, implicit: bool) -> String {
3407 let font = lookup_font().unwrap();
3408 let encoding = match encoding_opt {
3409 Some(enc) => enc.to_string(),
3410 None => font
3411 .get_encoding()
3412 .map_or_else(|| "OT1".to_string(), |c| c.to_string()),
3413 };
3414 let map = load_font_map(&encoding);
3415 // Check for family-specific map — same with_value motivation as above.
3416 let effective_map = if let Some(family) = font.get_family() {
3417 let fam_key = s!("{}_{}_fontmap", encoding, family);
3418 let fam_map: Option<Fontmap> = with_value(&fam_key, |v| v.and_then(|s| s.into()));
3419 fam_map.or(map)
3420 } else {
3421 map
3422 };
3423 let input_enc = lookup_string("INPUT_ENCODING");
3424 let map_max: usize = if input_enc == "utf8" { 128 } else { 256 };
3425 // Also limit for short font maps
3426 let map_max = if let Some(ref m) = effective_map {
3427 if m.len() < map_max { m.len() } else { map_max }
3428 } else {
3429 map_max
3430 };
3431
3432 let mut result = String::new();
3433 for ch in string.chars() {
3434 let code = ch as usize;
3435 if implicit {
3436 if let Some(ref m) = effective_map {
3437 if code < map_max {
3438 if let Some(Some(glyph)) = m.get(code) {
3439 result.push(*glyph);
3440 }
3441 } else {
3442 result.push(ch);
3443 }
3444 } else {
3445 result.push(ch);
3446 }
3447 } else if let Some(ref m) = effective_map
3448 && let Some(Some(glyph)) = m.get(code)
3449 {
3450 result.push(*glyph);
3451 }
3452 }
3453 result
3454}
3455
3456pub fn load_font_map(encoding: &str) -> Option<Fontmap> {
3457 let _ = preload_font_map(encoding); // infallible in practice; swallow Result
3458 // with_value avoids the Stored::clone; the Fontmap extraction is a cheap
3459 // Rc bump on the inner slice regardless.
3460 with_value(&s!("{encoding}_fontmap"), |v| v.and_then(|s| s.into()))
3461}
3462pub fn preload_font_map(encoding: &str) -> Result<()> {
3463 // This check is done as a "preload" step for mutability reasons.
3464 let key = s!("{encoding}_fontmap");
3465 if has_value(&key) {
3466 return Ok(());
3467 }
3468 let fail_key = s!("{encoding}_fontmap_failed_to_load");
3469 let failed_flag = lookup_bool(&fail_key);
3470 if !failed_flag {
3471 assign_value(&fail_key, true, None); // Stop recursion?
3472 let _ = input_definitions(&encoding.to_lowercase(), InputDefinitionOptions {
3473 extension: Some(Cow::Borrowed("fontmap")),
3474 noerror: true,
3475 ..InputDefinitionOptions::default()
3476 });
3477 if has_value(&s!("{encoding}_fontmap")) {
3478 // Got map?
3479 assign_value(&fail_key, false, None);
3480 } else {
3481 assign_value(&fail_key, true, Some(Scope::Global));
3482 }
3483 }
3484 Ok(())
3485}