Skip to main content

latexml_post/
xslt.rs

1//! XSLT transformation processor.
2//!
3//! Port of `LaTeXML::Post::XSLT`.
4//! Applies an XSLT stylesheet to transform the document (e.g., LaTeXML XML → HTML5).
5//! Handles CSS/JS/icon resource copying.
6
7use std::{
8  cell::RefCell,
9  collections::HashSet,
10  fs,
11  path::{Path, PathBuf},
12  sync::LazyLock,
13};
14
15use libxml::tree::Node;
16use regex::Regex;
17use rustc_hash::FxHashMap as HashMap;
18
19use crate::{
20  document::{PostDocument, PostDocumentOptions},
21  processor::{PostError, ProcessResult, Processor},
22};
23
24/// Set libxslt's global template-recursion cap to Perl's value (1000) exactly
25/// once per process. Mirrors `XML::LibXSLT->max_depth(1000)` in
26/// `LaTeXML::Post::XSLT`. Prevents deeply-recursive stylesheet templates from
27/// exhausting the C call stack (SIGSEGV) or RAM on pathological documents,
28/// aborting the transform gracefully like Perl instead.
29fn set_xslt_max_depth() {
30  static SET_MAX_DEPTH: std::sync::Once = std::sync::Once::new();
31  SET_MAX_DEPTH.call_once(|| {
32    // SAFETY: `xsltMaxDepth` is libxslt's process-global recursion cap
33    // (a plain C `int`). The libxslt crate exposes no safe setter. `Once`
34    // guarantees a single writer; libxslt only ever READS this value (when
35    // creating each transform context), so there is no data race with
36    // concurrent transforms.
37    //
38    // PORTABILITY: resolved via `dlsym` rather than the crate's
39    // `libxslt::bindings::xsltMaxDepth` extern static. Those pregenerated
40    // (bindgen-on-Linux) bindings pin the raw ELF symbol name with
41    // `#[link_name = "\u{1}xsltMaxDepth"]`, which fails to LINK on Mach-O
42    // where the C symbol is `_xsltMaxDepth` (macOS probe 2026-06-07 — the
43    // sole undefined symbol in the whole workspace link; see
44    // docs/PORTABILITY_MACOS_PROBE_2026-06-07.md). `dlsym` applies the
45    // platform's own C-symbol decoration, so it works on ELF and Mach-O
46    // alike. If the symbol is ever absent (NULL), we skip the write:
47    // libxslt's built-in default cap of 3000 still bounds recursion.
48    #[cfg(unix)]
49    unsafe {
50      let sym = libc::dlsym(libc::RTLD_DEFAULT, c"xsltMaxDepth".as_ptr());
51      if !sym.is_null() {
52        *(sym as *mut std::os::raw::c_int) = 1000;
53      }
54    }
55    // Windows (MSVC): no dlsym/RTLD_DEFAULT, and `libc` is a cfg(unix)-only
56    // dependency of this crate — but none of that machinery is needed. The
57    // vcpkg-static libxslt is linked into this very image, and x64 COFF C
58    // symbols carry no decoration, so a direct extern declaration links
59    // (GetProcAddress would NOT work here: it only sees DLL exports, not
60    // statically linked globals). See WINDOWS_COMPATIBILITY_PLAN Phase 2.3.
61    #[cfg(windows)]
62    unsafe {
63      #[allow(non_upper_case_globals)]
64      unsafe extern "C" {
65        static mut xsltMaxDepth: std::os::raw::c_int;
66      }
67      xsltMaxDepth = 1000;
68    }
69    // Any other platform: skip the write; libxslt's built-in default cap
70    // of 3000 still bounds recursion, just above Perl's 1000.
71  });
72}
73
74/// Windows twin of the unix dlsym read-back below: the write must land in
75/// the linked-in libxslt's global. Reads the same extern static the setter
76/// writes — both resolve to the one `xsltMaxDepth` in the image.
77#[cfg(all(test, windows))]
78mod max_depth_tests {
79  #[test]
80  fn extern_static_sets_perl_parity_cap() {
81    super::set_xslt_max_depth();
82    // SAFETY: single-threaded read of the process-global int after the
83    // Once-guarded write; the extern declaration matches libxslt's C type.
84    let val = unsafe {
85      #[allow(non_upper_case_globals)]
86      unsafe extern "C" {
87        static xsltMaxDepth: std::os::raw::c_int;
88      }
89      xsltMaxDepth
90    };
91    assert_eq!(val, 1000);
92  }
93}
94
95#[cfg(all(test, unix))]
96mod max_depth_tests {
97  /// The dlsym write must actually land: after `set_xslt_max_depth`,
98  /// reading the global back through the same runtime resolution path
99  /// must yield Perl's value (1000). Guards both the symbol lookup
100  /// (platform decoration) and the write.
101  #[test]
102  fn dlsym_sets_perl_parity_cap() {
103    super::set_xslt_max_depth();
104    // SAFETY: `dlsym(RTLD_DEFAULT, "xsltMaxDepth")` returns the address of
105    // libxslt's process-global `int` recursion cap, valid for the lifetime of
106    // the loaded libxslt (linked into this test binary). We assert non-null
107    // before dereferencing, and the `*const c_int` cast matches the symbol's C
108    // type; the read is on a single thread (`set_xslt_max_depth` already ran).
109    let val = unsafe {
110      let sym = libc::dlsym(libc::RTLD_DEFAULT, c"xsltMaxDepth".as_ptr());
111      assert!(!sym.is_null(), "xsltMaxDepth not resolvable via dlsym");
112      *(sym as *const std::os::raw::c_int)
113    };
114    assert_eq!(val, 1000);
115  }
116}
117
118/// Resource type information.
119struct ResourceInfo {
120  extension: &'static str,
121  subdir:    &'static str,
122}
123
124const RESOURCE_CSS: ResourceInfo = ResourceInfo {
125  extension: "css",
126  subdir:    "resources/CSS",
127};
128const RESOURCE_JS: ResourceInfo = ResourceInfo {
129  extension: "js",
130  subdir:    "resources/javascript",
131};
132
133/// XSLT post-processor: applies a stylesheet transformation.
134///
135/// Port of `LaTeXML::Post::XSLT`.
136pub struct XSLT {
137  name:               String,
138  /// Path to the XSLT stylesheet.
139  stylesheet_path:    Option<String>,
140  /// Parameters to pass to the XSLT stylesheet.
141  parameters:         HashMap<String, String>,
142  /// Whether to remove resource requests (CSS/JS not copied).
143  no_resources:       bool,
144  /// Resource directory for copied resources.
145  resource_directory: Option<String>,
146  /// Search paths for finding resources.
147  searchpaths:        Vec<String>,
148}
149
150impl XSLT {
151  pub fn new(
152    stylesheet: &str,
153    parameters: HashMap<String, String>,
154    no_resources: bool,
155    resource_directory: Option<String>,
156    searchpaths: Vec<String>,
157  ) -> Result<Self, PostError> {
158    if stylesheet.is_empty() {
159      // Perl XSLT.pm:36 — Error('expected', 'stylesheet', undef,
160      //   "No stylesheet specified!")
161      Error!("expected", "stylesheet", "No stylesheet specified!");
162      return Err(PostError::Processing(
163        "No stylesheet specified!".to_string(),
164      ));
165    }
166
167    // Find the stylesheet file
168    let stylesheet_path = match find_stylesheet(stylesheet, &searchpaths) {
169      Ok(p) => p,
170      Err(e) => {
171        // Perl XSLT.pm:42 — Error('missing-file', $stylesheet, undef,
172        //   "No stylesheet '$stylesheet' found!")
173        Error!(
174          "missing-file",
175          stylesheet,
176          "No stylesheet '{}' found!",
177          stylesheet
178        );
179        return Err(e);
180      },
181    };
182
183    Ok(XSLT {
184      name: format!("XSLT[using {}]", stylesheet),
185      stylesheet_path: Some(stylesheet_path),
186      parameters,
187      no_resources,
188      resource_directory,
189      searchpaths,
190    })
191  }
192
193  /// Copy a resource file and return the path relative to the destination.
194  ///
195  /// Port of `XSLT::copyResource`.
196  fn copy_resource(&self, doc: &PostDocument, src: &str, resource_type: Option<&str>) -> String {
197    // If it's a URL, return as-is
198    if src.starts_with("http://") || src.starts_with("https://") || src.starts_with("//") {
199      return src.to_string();
200    }
201
202    let info = match resource_type {
203      Some("text/css") => Some(&RESOURCE_CSS),
204      Some("text/javascript") => Some(&RESOURCE_JS),
205      _ => None,
206    };
207
208    // Try to find the file
209    let search_paths: Vec<&str> = doc
210      .get_search_paths()
211      .iter()
212      .chain(self.searchpaths.iter())
213      .map(String::as_str)
214      .collect();
215
216    let basename = Path::new(src)
217      .file_name()
218      .and_then(|f| f.to_str())
219      .unwrap_or(src);
220
221    // Determine destination once — same logic regardless of whether
222    // the resource ends up on disk or comes from the embedded table.
223    let dest = if let Some(ref rd) = self.resource_directory {
224      if let Some(site_dir) = doc.get_site_directory() {
225        format!("{}/{}/{}", site_dir, rd, basename)
226      } else {
227        format!("{}/{}", rd, basename)
228      }
229    } else if let Some(dest_dir) = doc.get_destination_directory() {
230      format!("{}/{}", dest_dir, basename)
231    } else {
232      basename.to_string()
233    };
234    let ensure_parent = |dest: &str| {
235      if let Some(parent) = Path::new(dest).parent() {
236        let _ = fs::create_dir_all(parent);
237      }
238    };
239
240    match find_resource_file(src, info, &search_paths) {
241      Some(path) => {
242        if same_file(&path, &dest) {
243          // The only search-path match IS the destination file itself — the dest
244          // dir is on the search path and a stale/empty resource from an earlier
245          // (possibly failed) run is shadowing the bundled copy (#312). A plain
246          // `fs::copy` here copies the file onto itself and TRUNCATES it to empty.
247          // Rewrite the embedded canonical bytes when we have them, repairing the
248          // stale/empty leftover; otherwise it is genuinely already in place.
249          if let Some(bytes) = embedded_resources::lookup(basename) {
250            ensure_parent(&dest);
251            if let Err(e) = fs::write(&dest, bytes) {
252              Warn!(
253                "I/O",
254                dest,
255                "Couldn't rewrite embedded {} to {}: {}",
256                basename,
257                dest,
258                e
259              );
260            }
261          }
262        } else {
263          ensure_parent(&dest);
264          if let Err(e) = fs::copy(&path, &dest) {
265            Warn!("I/O", dest, "Couldn't copy {} to {}: {}", path, dest, e);
266          }
267        }
268      },
269      None => {
270        // Not on disk — try the embedded table. CSS/JS assets are
271        // baked into the binary at build time; we materialize them
272        // straight to the destination, no temp dir round-trip.
273        if let Some(bytes) = embedded_resources::lookup(basename) {
274          ensure_parent(&dest);
275          if let Err(e) = fs::write(&dest, bytes) {
276            Warn!(
277              "I/O",
278              dest,
279              "Couldn't write embedded resource {} to {}: {}",
280              basename,
281              dest,
282              e
283            );
284          }
285        } else {
286          Warn!(
287            "missing_file",
288            src,
289            "Couldn't find resource file {} in paths {:?}",
290            src,
291            search_paths
292          );
293          return src.to_string();
294        }
295      },
296    }
297
298    // Return path relative to destination directory.
299    if let Some(dest_dir) = doc.get_destination_directory() {
300      relative_path(&dest, dest_dir)
301    } else {
302      dest
303    }
304  }
305
306  /// Copy each resource named in a `"a|b|c"` quoted pipe-list stylesheet
307  /// parameter (`CSS`, `JAVASCRIPT`, `ICON`) to the destination so the
308  /// `<link>`/`<script>` the stylesheet emits actually resolve.
309  ///
310  /// Port of `XSLT::process` L71-78 (the param-resource copy, distinct from the
311  /// embedded `<ltx:resource>` handling): every `--css` / `--javascript` /
312  /// icon entry is searched on the path (then the embedded table), copied, and
313  /// a missing entry warns `missing_file`. This runs **regardless of
314  /// `no_resources`** — `--nodefaultresources` only governs embedded
315  /// `<ltx:resource>` nodes, not CLI-specified resources (Perl L62-78: the CSS/
316  /// JAVASCRIPT/ICON copies sit *outside* the `noresources` guard).
317  ///
318  /// The copy targets the **site directory** (the root the relativized links in
319  /// [`relativize_resource_params`] point at, via `../` for split sub-pages),
320  /// falling back to the destination directory when no site dir is set. Link
321  /// relativization itself stays in `relativize_resource_params`; this method
322  /// only performs the copy, so split/`--splitat` link paths are unchanged.
323  fn copy_param_resources(&self, doc: &PostDocument, value: &str, info: Option<&ResourceInfo>) {
324    let dest_root = match doc
325      .get_site_directory()
326      .or_else(|| doc.get_destination_directory())
327    {
328      Some(d) => d.to_string(),
329      None => return,
330    };
331    let search_paths: Vec<&str> = doc
332      .get_search_paths()
333      .iter()
334      .chain(self.searchpaths.iter())
335      .map(String::as_str)
336      .collect();
337    // Only CSS recurses into its `@import`-ed sub-resources (below).
338    let is_css = info.is_some_and(|i| i.extension == "css");
339    // Cycle/dedup guard shared across every entry's @import graph.
340    let mut seen: HashSet<PathBuf> = HashSet::new();
341    for entry in value.trim_matches('"').split('|') {
342      let entry = entry.trim();
343      // Skip empties and URLs (nothing to copy — same guard as copy_resource).
344      if entry.is_empty()
345        || entry.starts_with("http://")
346        || entry.starts_with("https://")
347        || entry.starts_with("//")
348      {
349        continue;
350      }
351      let basename = Path::new(entry)
352        .file_name()
353        .and_then(|f| f.to_str())
354        .unwrap_or(entry);
355      let dest = format!("{}/{}", dest_root, basename);
356      let ensure_parent = || {
357        if let Some(parent) = Path::new(&dest).parent() {
358          let _ = fs::create_dir_all(parent);
359        }
360      };
361      match find_resource_file(entry, info, &search_paths) {
362        Some(path) => {
363          if same_file(&path, &dest) {
364            // Only search-path match IS the destination — avoid the self-copy
365            // truncate; rewrite the embedded canonical bytes if bundled (#312).
366            if let Some(bytes) = embedded_resources::lookup(basename) {
367              ensure_parent();
368              if let Err(e) = fs::write(&dest, bytes) {
369                Warn!(
370                  "I/O",
371                  dest,
372                  "Couldn't rewrite embedded {} to {}: {}",
373                  basename,
374                  dest,
375                  e
376                );
377              }
378            }
379          } else {
380            ensure_parent();
381            if let Err(e) = fs::copy(&path, &dest) {
382              Warn!("I/O", dest, "Couldn't copy {} to {}: {}", path, dest, e);
383            }
384          }
385          // Beyond Perl: follow the copied CSS's local @import chain so split
386          // stylesheets (e.g. ar5iv.css -> ./ar5iv/*.css layer files) bring
387          // their sub-files along. Gated to CSS; no-op for JS/icon.
388          if is_css {
389            copy_css_imports(Path::new(&path), Path::new(&dest), &mut seen);
390          }
391        },
392        None => {
393          // Not on the path — try the binary's embedded resource table
394          // (same fallback as copy_resource, so bundled CSS/JS still land).
395          if let Some(bytes) = embedded_resources::lookup(basename) {
396            ensure_parent();
397            if let Err(e) = fs::write(&dest, bytes) {
398              Warn!(
399                "I/O",
400                dest,
401                "Couldn't write embedded resource {} to {}: {}",
402                basename,
403                dest,
404                e
405              );
406            }
407          } else {
408            Warn!(
409              "missing_file",
410              entry,
411              "Couldn't find resource file {} in paths {:?}",
412              entry,
413              search_paths
414            );
415          }
416        },
417      }
418    }
419  }
420
421  /// Build a per-doc parameter map with `CSS`, `JAVASCRIPT`, and `ICON`
422  /// relativized so each split sub-page references the resource at the
423  /// correct relative path.
424  ///
425  /// The raw values are constructed as `"foo.css|bar.css"` (quoted,
426  /// pipe-separated basenames) by the binary's `run_post_processing`.
427  /// They are interpreted as paths relative to the site root, so
428  /// sub-pages need `../foo.css` etc.
429  fn relativize_resource_params(&self, doc: &PostDocument) -> HashMap<String, String> {
430    let mut out = self.parameters.clone();
431    let (Some(site), Some(dest)) = (doc.get_site_directory(), doc.get_destination_directory())
432    else {
433      return out;
434    };
435    let prefix = match relative_dir_prefix(site, dest) {
436      Some(p) => p,
437      None => return out,
438    };
439    if prefix.is_empty() {
440      return out;
441    }
442    for key in ["CSS", "JAVASCRIPT", "ICON"] {
443      if let Some(value) = out.get(key).cloned() {
444        out.insert(
445          key.to_string(),
446          relativize_quoted_pipe_list(&value, &prefix),
447        );
448      }
449    }
450    out
451  }
452}
453
454/// Walk-up prefix from `dest_dir` to `site_dir`. Returns `Some("")` when
455/// they're identical, `Some("../")` when `dest_dir` is one level deeper,
456/// `Some("../../")` two levels, etc. Returns `None` if `dest_dir` is not
457/// inside `site_dir`.
458fn relative_dir_prefix(site_dir: &str, dest_dir: &str) -> Option<String> {
459  let site = Path::new(site_dir);
460  let dest = Path::new(dest_dir);
461  let rel = dest.strip_prefix(site).ok()?;
462  let depth = rel.components().count();
463  Some("../".repeat(depth))
464}
465
466/// Apply `prefix` to every basename in a `"a|b|c"` quoted pipe-list, but
467/// only when the entry doesn't already look absolute or scheme-prefixed.
468fn relativize_quoted_pipe_list(value: &str, prefix: &str) -> String {
469  let inner = value.trim_matches('"');
470  let parts: Vec<String> = inner
471    .split('|')
472    .map(|p| {
473      let p = p.trim();
474      if p.is_empty()
475        || p.starts_with('/')
476        || p.starts_with("./")
477        || p.starts_with("../")
478        || p.contains("://")
479      {
480        p.to_string()
481      } else {
482        format!("{}{}", prefix, p)
483      }
484    })
485    .collect();
486  format!("\"{}\"", parts.join("|"))
487}
488
489/// Extract the targets of CSS `@import` rules from stylesheet source.
490///
491/// Block comments are stripped first (so a commented-out `@import` is ignored),
492/// then the common forms are matched: `@import "x.css";`, `@import url("x");`,
493/// `@import url('x');`, and bare `@import url(x);`, each with an optional
494/// trailing `layer(...)` / media / `supports(...)` tail (only the URL token is
495/// captured). One target per `@import` (CSS allows a single URL per rule).
496fn parse_css_imports(css: &str) -> Vec<String> {
497  static COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)/\*.*?\*/").unwrap());
498  static IMPORT: LazyLock<Regex> =
499    LazyLock::new(|| Regex::new(r#"(?i)@import\s+(?:url\(\s*)?["']?([^"')\s;]+)"#).unwrap());
500  let stripped = COMMENT.replace_all(css, "");
501  IMPORT
502    .captures_iter(&stripped)
503    .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
504    .collect()
505}
506
507/// True iff both paths exist and canonicalize to the **same file** on disk.
508/// Used to avoid `fs::copy`ing a file onto itself — which truncates it to empty
509/// — when a resource's only search-path match is the destination file itself
510/// (the dest dir is on the search path; #312). A plain string `!=` on the two
511/// path spellings (relative vs absolute, `./` prefixes) misses this.
512fn same_file(a: impl AsRef<Path>, b: impl AsRef<Path>) -> bool {
513  match (fs::canonicalize(a), fs::canonicalize(b)) {
514    (Ok(ca), Ok(cb)) => ca == cb,
515    _ => false,
516  }
517}
518
519/// After a CSS file is copied from `src` to `dest`, recursively copy the LOCAL
520/// resources it `@import`s, preserving each import's relative subpath so the
521/// cascade still resolves at the destination.
522///
523/// BEYOND PERL: `copyResource` copies only the named file. ar5iv-style
524/// stylesheets split themselves across `@import url("./layer/part.css")`
525/// sub-files; without following those, the copied top-level CSS renders
526/// unstyled. Each target is resolved relative to the importing file — for both
527/// the on-disk source and the destination, so the same relative path is
528/// recreated under the destination — copied, and (if itself a `.css`) recursed
529/// into. Remote (`http(s)://`, `//`, `data:`, any `scheme://`) and absolute
530/// (`/…`) targets are left untouched. `seen` (keyed on the absolute source
531/// path) guards against import cycles and redundant copies.
532fn copy_css_imports(src: &Path, dest: &Path, seen: &mut HashSet<PathBuf>) {
533  let content = match fs::read_to_string(src) {
534    Ok(c) => c,
535    Err(_) => return,
536  };
537  let (Some(src_dir), Some(dest_dir)) = (src.parent(), dest.parent()) else {
538    return;
539  };
540  for target in parse_css_imports(&content) {
541    let t = target.trim();
542    // Skip remote / non-copyable targets — only local relative paths are ours.
543    if t.is_empty()
544      || t.starts_with('/')
545      || t.starts_with("//")
546      || t.starts_with("data:")
547      || t.contains("://")
548    {
549      continue;
550    }
551    let import_src = src_dir.join(t);
552    let import_dest = dest_dir.join(t);
553    // Skip if already handled (shared import or cycle).
554    if !seen.insert(import_src.clone()) {
555      continue;
556    }
557    if !import_src.is_file() {
558      Warn!(
559        "missing_file",
560        t,
561        "Couldn't find @import target {} referenced by {}",
562        t,
563        src.display()
564      );
565      continue;
566    }
567    if !same_file(&import_src, &import_dest) {
568      if let Some(parent) = import_dest.parent() {
569        let _ = fs::create_dir_all(parent);
570      }
571      if let Err(e) = fs::copy(&import_src, &import_dest) {
572        Warn!(
573          "I/O",
574          t,
575          "Couldn't copy @import {} to {}: {}",
576          import_src.display(),
577          import_dest.display(),
578          e
579        );
580        continue;
581      }
582    }
583    // Recurse into nested CSS imports only (not imported fonts/images).
584    if import_src
585      .extension()
586      .and_then(|e| e.to_str())
587      .is_some_and(|e| e.eq_ignore_ascii_case("css"))
588    {
589      copy_css_imports(&import_src, &import_dest, seen);
590    }
591  }
592}
593
594impl Processor for XSLT {
595  fn get_name(&self) -> &str { &self.name }
596
597  fn process(&mut self, doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult {
598    let stylesheet_path = match &self.stylesheet_path {
599      Some(p) => p.clone(),
600      None => return Ok(vec![doc]),
601    };
602
603    Info!(
604      "xslt",
605      "stylesheet",
606      "Applying XSLT stylesheet: {}",
607      stylesheet_path
608    );
609
610    // Handle resource elements first (before transformation removes them)
611    let resource_nodes = doc.findnodes("//ltx:resource[@src]");
612    if self.no_resources {
613      // Perl L64-65: remove resource nodes so XSLT won't generate CSS/JS links
614      for mut node in resource_nodes {
615        node.unlink_node();
616      }
617    } else {
618      for node in &resource_nodes {
619        if let Some(src) = node.get_attribute("src") {
620          let resource_type = node.get_attribute("type");
621          let _path = self.copy_resource(&doc, &src, resource_type.as_deref());
622        }
623      }
624    }
625
626    // Copy CLI-specified --css/--javascript/icon resources to the destination
627    // and warn on any that can't be found. Port of XSLT::process L71-78 — the
628    // param-resource copy that the binary's `--css`/`--javascript` flow needs
629    // (those flags only set the CSS/JAVASCRIPT stylesheet params; without this
630    // the link is emitted but the file is never searched on --path or copied).
631    // Deliberately OUTSIDE the `no_resources` guard above: --nodefaultresources
632    // suppresses only the bundled defaults' <ltx:resource> nodes, not these.
633    if let Some(css) = self.parameters.get("CSS") {
634      self.copy_param_resources(&doc, css, Some(&RESOURCE_CSS));
635    }
636    if let Some(js) = self.parameters.get("JAVASCRIPT") {
637      self.copy_param_resources(&doc, js, Some(&RESOURCE_JS));
638    }
639    if let Some(icon) = self.parameters.get("ICON") {
640      self.copy_param_resources(&doc, icon, None);
641    }
642
643    // Serialize the entire libxslt-touching critical section process-wide.
644    // libxslt/libxml2 keep NON-thread-safe process-global state — the input-
645    // callback + EXSLT registries, the generic error context, and the
646    // namespace-internalisation / dictionary caches that `xsltApplyStylesheet
647    // User` and stylesheet parsing mutate (the hidden mutation this file's
648    // per-thread-cache note already anticipates). Two conversion threads
649    // transforming concurrently DEADLOCK on that state: witnessed as the
650    // `52_source_map` XSLT tests hanging forever on a futex under
651    // `cargo test --tests` (all threads `futex_do_wait`, 0% CPU). The thread-
652    // local stylesheet cache below removes cross-thread *cache* sharing but not
653    // this shared C-library state, so a process-global lock is required for
654    // correctness. Cost: NONE in production — the CLI and the cortex fleet run
655    // one conversion per process (single thread), so this is never contended;
656    // only the multi-threaded test harness (or a hypothetical in-process pool)
657    // ever serializes here. Poison-tolerant: a transform that panicked while
658    // holding the lock didn't corrupt anything we read, so recover the guard.
659    static XSLT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
660    let _xslt_guard = XSLT_LOCK
661      .lock()
662      .unwrap_or_else(|poisoned| poisoned.into_inner());
663
664    // Register EXSLT extension functions (str:tokenize, math:*, etc.)
665    // used by LaTeXML stylesheets. Safe-wrapped upstream in
666    // rust-libxslt — `register_exslt()` is Once-guarded.
667    libxslt::register_exslt();
668
669    // Faithful port of Perl `XML::LibXSLT->max_depth(1000)`
670    // (LaTeXML::Post::XSLT.pm L48): cap libxslt's template-recursion depth.
671    // libxslt's compiled-in default is 3000; lowering to Perl's 1000 makes a
672    // runaway / deeply-recursive stylesheet apply ABORT gracefully (matching
673    // Perl) instead of growing the C call stack until SIGSEGV/OOM on
674    // pathological input. `xsltMaxDepth` is a process-global libxslt static
675    // read when each transform context is created, so setting it once is
676    // sufficient. See docs/performance/STABILITY_WITNESSES.md (Cluster A, hypothesis 3).
677    set_xslt_max_depth();
678
679    // Hand the source tree to libxslt WITHOUT a deep copy. `transform()`
680    // takes its `Document` by value (the moved handle's Drop would free the
681    // tree), so earlier code `dup()`'d (xmlCopyDoc — a full deep copy) to keep
682    // this PostDocument's own tree alive. On a large-math document the DOM is
683    // multi-GB, and that deep copy TRANSIENTLY DOUBLES peak RSS during the
684    // transform — the dominant driver of post-processing OOM on the canvas
685    // sweep (docs/performance/STABILITY_WITNESSES.md, Cluster A / hypothesis 1).
686    //
687    // `libxml::Document` is `Rc<RefCell<_Document>>` and the underlying
688    // `xmlDoc` is freed only when the LAST handle drops, so an Rc `clone()`
689    // (a refcount bump, no copy) is the right tool: libxslt reads the shared
690    // tree to build a SEPARATE result tree (`xsltApplyStylesheetUser` does not
691    // free its source), the moved clone's Drop just decrements the count, and
692    // `doc`'s own handle (dropped at function end) performs the single real
693    // free. We never read `doc`'s tree again after this point — only its
694    // string metadata below — so libxslt mutating the shared source while
695    // applying is harmless. This mirrors Perl, which passes
696    // `$doc->getDocument` straight to `transform` with no pre-copy
697    // (LaTeXML::Post::XSLT.pm L79).
698    let transform_doc = doc.get_document().clone();
699
700    // Build parameters, relativizing path-valued ones (CSS, JAVASCRIPT,
701    // ICON) for the current doc's destination. The crate-level params
702    // hold basenames in site-relative form; split sub-pages live in a
703    // subdirectory and need `../foo.css` etc. to resolve correctly.
704    let per_doc_params = self.relativize_resource_params(&doc);
705    let params: Vec<(&str, &str)> = per_doc_params
706      .iter()
707      .map(|(k, v)| (k.as_str(), v.as_str()))
708      .collect();
709
710    // Apply the transformation. The parsed `Stylesheet` lives in a
711    // per-thread cache (`with_cached_stylesheet`) — `libxslt::parser::
712    // parse_file` runs once per (thread, stylesheet path) instead of
713    // once per conversion. The cache is thread-local, not shared, so
714    // we don't lean on libxslt's undocumented thread-safety (mirroring
715    // the caution that resolved KWARC/rust-libxslt issue #6).
716    let result_doc = with_cached_stylesheet(&stylesheet_path, |stylesheet| {
717      stylesheet
718        .transform(transform_doc, params)
719        .map_err(|e| PostError::Processing(format!("XSLT transformation failed: {}", e)))
720    })?;
721    // Transform done — the libxslt-global critical section is over. Release the
722    // lock BEFORE wrapping the result: `result_doc` is the transform's fresh,
723    // unshared output tree, so building the `PostDocument` around it touches no
724    // shared libxslt/libxml2 state. Narrowing the hold keeps a hypothetical
725    // in-process pool serialized only over the actual transform, not the cheap
726    // wrapping. (No effect on the one-conversion-per-process production path,
727    // where the lock is never contended anyway.)
728    drop(_xslt_guard);
729
730    // XSLT returns a libxml `Document` directly — wrap it into a
731    // PostDocument without the serialize → reparse roundtrip the
732    // earlier code did. Saves ~10-30 ms on a typical mid-size paper
733    // (XML serialize + libxml2 reparse of ~100-500 KB markup).
734    if result_doc.get_root_element().is_none() {
735      return Err(PostError::Processing(
736        "XSLT produced empty output".to_string(),
737      ));
738    }
739
740    let result_doc = PostDocument::new(result_doc, PostDocumentOptions {
741      destination: doc.destination.clone(),
742      destination_directory: doc.destination_directory.clone(),
743      site_directory: doc.site_directory.clone(),
744      source: doc.source.clone(),
745      source_directory: doc.source_directory.clone(),
746      searchpaths: Some(doc.searchpaths.clone()),
747      ..PostDocumentOptions::default()
748    });
749
750    Ok(vec![result_doc])
751  }
752}
753
754// ======================================================================
755// Per-thread cache of parsed stylesheets.
756//
757// `libxslt::parser::parse_file` reads the .xsl from disk and compiles
758// it. For LaTeXML-html5.xsl that's ~5–10 ms including its xsl:imports.
759// On a single CLI run that's once per process — fine. On a daemon-mode
760// `cortex_worker` chewing through 10 000 papers from a thread pool of
761// 8 workers, naive code re-parses once per paper. With this cache,
762// each worker thread parses each unique stylesheet path *once* and
763// reuses the compiled artefact for the rest of its lifetime.
764//
765// ## Why thread-local (and not process-wide + Arc/Mutex)?
766//
767// libxslt is not documented as thread-safe. `xsltApplyStylesheetUser`
768// is not audited to be read-only on the stylesheet — it may write
769// back into namespace-internalisation caches, error context fields,
770// or other internal state. This is the same kind of hidden mutation
771// that issue KWARC/rust-libxslt#6 punctured for the input `Document`
772// (libxslt silently mutates docs during whitespace stripping). A
773// process-wide cache shared across worker threads via `Arc` would
774// either need a `Mutex` (serialising transforms — defeats the
775// throughput benefit) or rely on libxslt's undocumented thread-safety
776// (the same bet that #6 retired).
777//
778// Thread-local keeps the safety story simple: each thread owns its
779// own `Stylesheet` for its lifetime, no cross-thread sharing, and the
780// `&mut Stylesheet` requirement is satisfied by `RefCell::borrow_mut`.
781// Worst case: 8 worker threads × 1 parse per unique stylesheet =
782// 8 parses per process, instead of N parses per N papers.
783
784fn cache_key(path: &str) -> String {
785  // Canonicalise so `./resources/XSLT/foo.xsl` and
786  // `/abs/.../resources/XSLT/foo.xsl` hit the same entry. Falls back
787  // to the raw path on canonicalisation failure (the file might not
788  // exist yet — let parse_file emit its own error in that case).
789  fs::canonicalize(path)
790    .map(|p| p.to_string_lossy().into_owned())
791    .unwrap_or_else(|_| path.to_string())
792}
793
794thread_local! {
795  static STYLESHEET_CACHE: RefCell<HashMap<String, libxslt::stylesheet::Stylesheet>> =
796    RefCell::new(HashMap::default());
797}
798
799/// Borrow a `&mut Stylesheet` from the per-thread cache, parsing on
800/// miss. The closure runs while the cache is mutably borrowed, so
801/// nested calls (which the LaTeXML pipeline never makes) would
802/// `RefCell::borrow_mut`-panic — a deliberate single-borrow contract.
803fn with_cached_stylesheet<F, R>(path: &str, f: F) -> Result<R, PostError>
804where F: FnOnce(&mut libxslt::stylesheet::Stylesheet) -> Result<R, PostError> {
805  let key = cache_key(path);
806  // A user `--stylesheet` (parsed from disk, below) may `xsl:import` the engine
807  // by `urn:x-LaTeXML:XSLT:` — install the embedded-XSLT input callback for
808  // every parse, not just the embed:/// fallback path (issue #292). Idempotent.
809  embedded_xslt::install_callback_once();
810  STYLESHEET_CACHE.with(|cache| {
811    let mut map = cache.borrow_mut();
812    if !map.contains_key(&key) {
813      let parsed = if let Some(name) = path.strip_prefix(embedded_xslt::URL_PREFIX) {
814        // `embed:///<name>` sentinel from `find_stylesheet`. Parse the
815        // root stylesheet from the embedded byte table; libxslt's
816        // `xsl:import` machinery will then re-enter our libxml2 input
817        // callback for every referenced URL.
818        let bytes = embedded_xslt::lookup(name).ok_or_else(|| {
819          PostError::Processing(format!("Embedded XSLT stylesheet {} not found", name))
820        })?;
821        libxslt::parser::parse_bytes(bytes.to_vec(), path)
822          .map_err(|e| PostError::Processing(format!("Failed to parse embedded XSLT: {}", e)))?
823      } else {
824        libxslt::parser::parse_file(path)
825          .map_err(|e| PostError::Processing(format!("Failed to parse XSLT stylesheet: {}", e)))?
826      };
827      map.insert(key.clone(), parsed);
828    }
829    let entry = map
830      .get_mut(&key)
831      .expect("cache entry just inserted is missing");
832    f(entry)
833  })
834}
835
836// ======================================================================
837// Embedded XSLT stylesheets — bundled at compile time for portable binary.
838// When the resources/XSLT/ directory is not available on disk, these are
839// extracted to a temp directory so libxslt can resolve xsl:import chains.
840
841mod embedded_xslt {
842  pub const FILES: &[(&str, &str)] = &[
843    (
844      "LaTeXML-html5.xsl",
845      include_str!("../resources/XSLT/LaTeXML-html5.xsl"),
846    ),
847    (
848      "LaTeXML-all-xhtml.xsl",
849      include_str!("../resources/XSLT/LaTeXML-all-xhtml.xsl"),
850    ),
851    (
852      "LaTeXML-bib-xhtml.xsl",
853      include_str!("../resources/XSLT/LaTeXML-bib-xhtml.xsl"),
854    ),
855    (
856      "LaTeXML-block-xhtml.xsl",
857      include_str!("../resources/XSLT/LaTeXML-block-xhtml.xsl"),
858    ),
859    (
860      "LaTeXML-common.xsl",
861      include_str!("../resources/XSLT/LaTeXML-common.xsl"),
862    ),
863    (
864      "LaTeXML-epub3.xsl",
865      include_str!("../resources/XSLT/LaTeXML-epub3.xsl"),
866    ),
867    (
868      "LaTeXML-html4.xsl",
869      include_str!("../resources/XSLT/LaTeXML-html4.xsl"),
870    ),
871    (
872      "LaTeXML-inline-xhtml.xsl",
873      include_str!("../resources/XSLT/LaTeXML-inline-xhtml.xsl"),
874    ),
875    (
876      "LaTeXML-jats.xsl",
877      include_str!("../resources/XSLT/LaTeXML-jats.xsl"),
878    ),
879    (
880      "LaTeXML-math-xhtml.xsl",
881      include_str!("../resources/XSLT/LaTeXML-math-xhtml.xsl"),
882    ),
883    (
884      "LaTeXML-meta-xhtml.xsl",
885      include_str!("../resources/XSLT/LaTeXML-meta-xhtml.xsl"),
886    ),
887    (
888      "LaTeXML-misc-xhtml.xsl",
889      include_str!("../resources/XSLT/LaTeXML-misc-xhtml.xsl"),
890    ),
891    (
892      "LaTeXML-para-xhtml.xsl",
893      include_str!("../resources/XSLT/LaTeXML-para-xhtml.xsl"),
894    ),
895    (
896      "LaTeXML-picture-xhtml.xsl",
897      include_str!("../resources/XSLT/LaTeXML-picture-xhtml.xsl"),
898    ),
899    (
900      "LaTeXML-structure-xhtml.xsl",
901      include_str!("../resources/XSLT/LaTeXML-structure-xhtml.xsl"),
902    ),
903    (
904      "LaTeXML-tabular-xhtml.xsl",
905      include_str!("../resources/XSLT/LaTeXML-tabular-xhtml.xsl"),
906    ),
907    (
908      "LaTeXML-tei.xsl",
909      include_str!("../resources/XSLT/LaTeXML-tei.xsl"),
910    ),
911    (
912      "LaTeXML-webpage-xhtml.xsl",
913      include_str!("../resources/XSLT/LaTeXML-webpage-xhtml.xsl"),
914    ),
915    (
916      "LaTeXML-xhtml5.xsl",
917      include_str!("../resources/XSLT/LaTeXML-xhtml5.xsl"),
918    ),
919    (
920      "LaTeXML-xhtml.xsl",
921      include_str!("../resources/XSLT/LaTeXML-xhtml.xsl"),
922    ),
923  ];
924
925  use std::sync::OnceLock;
926
927  /// URL scheme through which our embedded stylesheets are served to
928  /// libxslt. Any URL starting with this prefix is intercepted by the
929  /// input callback we install in [`install_callback_once`] and
930  /// resolved against the [`FILES`] table.
931  pub const URL_PREFIX: &str = "embed:///";
932
933  /// Look up the embedded XSLT bytes by basename, or `None` if the
934  /// stylesheet is not bundled.
935  pub fn lookup(name: &str) -> Option<&'static [u8]> {
936    FILES
937      .iter()
938      .find_map(|(n, c)| (*n == name).then_some(c.as_bytes()))
939  }
940
941  /// Map any URL libxml2 hands us to an embedded XSLT file, or `None` if we
942  /// don't serve it. We key purely on the **final path segment**, because a
943  /// user `--stylesheet` imports the engine by the LaTeXML-canonical
944  /// `urn:x-LaTeXML:XSLT:X` scheme (Perl resolves it via an XML catalog), and
945  /// libxml2 versions then resolve that root's *relative* child imports
946  /// differently against the opaque `urn:` base: Linux composes
947  /// `urn:LaTeXML-all-xhtml.xsl`, macOS drops to a bare `LaTeXML-all-xhtml.xsl`
948  /// (CI witness, issue #292). Keying on the basename resolves `embed:///X`,
949  /// `urn:x-LaTeXML:XSLT:X`, `urn:X`, and bare `X` uniformly. Only our
950  /// distinctive `LaTeXML-*.xsl` names are in [`FILES`], so a user's own
951  /// relative import (a different basename) still loads from disk.
952  pub fn resolve(url: &str) -> Option<&'static [u8]> {
953    let name = url.rsplit(['/', ':']).next().unwrap_or(url);
954    lookup(name)
955  }
956
957  /// Install the libxml2 input callback that serves `embed:///`
958  /// URLs from [`FILES`]. Called once per process; subsequent calls
959  /// are no-ops. The callback fires whenever libxml2 itself opens a
960  /// URL — including `xsl:import` / `xsl:include` resolution from
961  /// inside `libxslt::parser::parse_bytes`. Result: every stylesheet
962  /// (root + imports) is loaded from the binary's own `.rodata`
963  /// section, no disk extraction required.
964  pub fn install_callback_once() {
965    static INSTALLED: OnceLock<()> = OnceLock::new();
966    INSTALLED.get_or_init(|| {
967      libxml::io::register_input_callback(
968        // Serve any URL whose basename is one of our embedded engine files —
969        // `embed:///X`, a user stylesheet's `urn:x-LaTeXML:XSLT:X`, and the
970        // relative child imports libxml2 composes from it (`urn:X` on Linux, a
971        // bare `X` on macOS). A non-engine basename resolves to None, so the
972        // callback declines it and libxml2 loads it from disk as before (#292).
973        |url| resolve(url).is_some(),
974        |url| resolve(url).map(|s| s.to_vec()),
975      );
976    });
977  }
978}
979
980// ======================================================================
981// Embedded CSS / JavaScript resources — bundled at compile time so a
982// single-binary distribution can serve them without an accompanying
983// `resources/` tree on disk.
984//
985// Unlike XSLT (which libxslt needs as files on disk to resolve
986// `xsl:import` chains), CSS and JS are pure leaf assets — the
987// post-processor's job is to put a copy next to the output HTML so
988// `<link rel="stylesheet">` resolves. We can write the embedded
989// bytes straight to the destination directory, skipping the
990// extract-to-temp-then-copy round-trip entirely.
991
992mod embedded_resources {
993  pub const CSS_FILES: &[(&str, &str)] = &[
994    (
995      "LaTeXML-blue.css",
996      include_str!("../resources/CSS/LaTeXML-blue.css"),
997    ),
998    (
999      "LaTeXML-marginpar.css",
1000      include_str!("../resources/CSS/LaTeXML-marginpar.css"),
1001    ),
1002    (
1003      "LaTeXML-navbar-left.css",
1004      include_str!("../resources/CSS/LaTeXML-navbar-left.css"),
1005    ),
1006    (
1007      "LaTeXML-navbar-right.css",
1008      include_str!("../resources/CSS/LaTeXML-navbar-right.css"),
1009    ),
1010    ("LaTeXML.css", include_str!("../resources/CSS/LaTeXML.css")),
1011    (
1012      "ltx-amsart.css",
1013      include_str!("../resources/CSS/ltx-amsart.css"),
1014    ),
1015    ("ltx-apj.css", include_str!("../resources/CSS/ltx-apj.css")),
1016    (
1017      "ltx-article.css",
1018      include_str!("../resources/CSS/ltx-article.css"),
1019    ),
1020    (
1021      "ltx-book.css",
1022      include_str!("../resources/CSS/ltx-book.css"),
1023    ),
1024    (
1025      "ltx-listings.css",
1026      include_str!("../resources/CSS/ltx-listings.css"),
1027    ),
1028    (
1029      "ltx-report.css",
1030      include_str!("../resources/CSS/ltx-report.css"),
1031    ),
1032    (
1033      "ltx-svjour.css",
1034      include_str!("../resources/CSS/ltx-svjour.css"),
1035    ),
1036    (
1037      "ltx-ulem.css",
1038      include_str!("../resources/CSS/ltx-ulem.css"),
1039    ),
1040    (
1041      "relaxng-schema-rustdoc-theme.css",
1042      include_str!("../resources/CSS/relaxng-schema-rustdoc-theme.css"),
1043    ),
1044  ];
1045
1046  pub const JS_FILES: &[(&str, &str)] = &[
1047    (
1048      "LaTeXML-maybeMathjax.js",
1049      include_str!("../resources/javascript/LaTeXML-maybeMathjax.js"),
1050    ),
1051    (
1052      "relaxng-schema-rustdoc-theme.js",
1053      include_str!("../resources/javascript/relaxng-schema-rustdoc-theme.js"),
1054    ),
1055  ];
1056
1057  /// Return the embedded bytes for `basename` if it's one of the
1058  /// bundled CSS/JS assets, or `None` otherwise. Callers write the
1059  /// returned slice straight to the destination directory — no temp
1060  /// dir, no intermediate copy.
1061  pub fn lookup(basename: &str) -> Option<&'static [u8]> {
1062    CSS_FILES
1063      .iter()
1064      .chain(JS_FILES.iter())
1065      .find_map(|(n, c)| (*n == basename).then_some(c.as_bytes()))
1066  }
1067}
1068
1069// ======================================================================
1070// File search helpers
1071
1072fn find_stylesheet(stylesheet: &str, searchpaths: &[String]) -> Result<String, PostError> {
1073  // 1. Check if the stylesheet exists as an absolute/relative path
1074  if Path::new(stylesheet).is_file() {
1075    return Ok(stylesheet.to_string());
1076  }
1077  // 2. Check each search path
1078  for sp in searchpaths {
1079    let p = format!("{}/{}", sp, stylesheet);
1080    if Path::new(&p).is_file() {
1081      return Ok(p);
1082    }
1083  }
1084  // 3. Fallback: serve from the embedded table via the libxml2 input callback. We return an
1085  //    `embed:///<basename>` URL sentinel that `with_cached_stylesheet` routes through
1086  //    `libxslt::parser:: parse_bytes`; subsequent `xsl:import` references inside that stylesheet
1087  //    compose against this base URI and re-enter our callback, so the whole chain stays in memory.
1088  let filename = Path::new(stylesheet)
1089    .file_name()
1090    .and_then(|f| f.to_str())
1091    .unwrap_or(stylesheet);
1092  if embedded_xslt::lookup(filename).is_some() {
1093    embedded_xslt::install_callback_once();
1094    return Ok(format!("{}{}", embedded_xslt::URL_PREFIX, filename));
1095  }
1096  Err(PostError::Processing(format!(
1097    "No stylesheet '{}' found!",
1098    stylesheet
1099  )))
1100}
1101
1102/// Disk-only lookup for a CSS/JS/icon resource — searches the literal
1103/// path, then `info.subdir`-prefixed variants, then each `search_paths`
1104/// entry. Embedded (compile-time-bundled) assets are handled by the
1105/// caller via `embedded_resources::lookup`; this function deliberately
1106/// does NOT check the embed, so on-disk overrides always win and the
1107/// "couldn't find" branch can fall through to the embed cleanly.
1108fn find_resource_file(
1109  src: &str,
1110  info: Option<&ResourceInfo>,
1111  search_paths: &[&str],
1112) -> Option<String> {
1113  let name = Path::new(src).file_name()?.to_str()?;
1114  let mut candidates = vec![src.to_string()];
1115  if let Some(info) = info {
1116    candidates.push(format!("{}/{}", info.subdir, name));
1117    candidates.push(format!("{}/{}", info.subdir, src));
1118  }
1119  for candidate in &candidates {
1120    if Path::new(candidate).is_file() {
1121      return Some(candidate.clone());
1122    }
1123    for sp in search_paths {
1124      let p = format!("{}/{}", sp, candidate);
1125      if Path::new(&p).is_file() {
1126        return Some(p);
1127      }
1128    }
1129  }
1130  None
1131}
1132
1133fn relative_path(target: &str, base: &str) -> String {
1134  let target_path = Path::new(target);
1135  let base_path = Path::new(base);
1136  if let Ok(rel) = target_path.strip_prefix(base_path) {
1137    rel.to_string_lossy().to_string()
1138  } else {
1139    target.to_string()
1140  }
1141}
1142
1143#[cfg(test)]
1144mod witnessed_css_delta {
1145  //! Guards the intentional non-vanilla rules in the bundled default
1146  //! `LaTeXML.css`: a tcolorbox/minipage foreignobject top-alignment rule
1147  //! (arXiv **2605.02240**) and the display-equation vertical margin
1148  //! (issue **#473**). The rest of the stylesheet is a straight adoption
1149  //! of upstream vanilla (#312), which we do NOT assert here — the Perl
1150  //! `LaTeXML/` reference tree is not shipped in the crate, so there is no
1151  //! ground truth to diff against, and re-sync fidelity is a human/`cp`
1152  //! responsibility. These tests exist only so that a future "re-vanilla"
1153  //! sweep can't silently drop a witnessed local delta.
1154  use super::embedded_resources;
1155
1156  fn bundled_latexml_css() -> &'static str {
1157    std::str::from_utf8(embedded_resources::lookup("LaTeXML.css").expect("LaTeXML.css is bundled"))
1158      .expect("LaTeXML.css is valid UTF-8")
1159  }
1160
1161  #[test]
1162  fn witnessed_minipage_delta_stays_present() {
1163    let css = bundled_latexml_css();
1164    assert!(
1165      css.contains("2605.02240")
1166        && css.contains(
1167          ".ltx_foreignobject_container:has( > .ltx_foreignobject_content > .ltx_minipage)"
1168        ),
1169      "the witnessed minipage top-alignment delta (2605.02240) is missing from LaTeXML.css",
1170    );
1171  }
1172
1173  #[test]
1174  fn equation_display_margin_delta_stays_present() {
1175    let css = bundled_latexml_css();
1176    assert!(
1177      css.contains("#473")
1178        && css.contains(".ltx_eqn_table, .ltx_eqn_div { margin-top:1em; margin-bottom:1em; }"),
1179      "the display-equation vertical-margin delta (issue #473) is missing from LaTeXML.css: \
1180       adjacent display equations would render touching, unlike TeX's \\abovedisplayskip",
1181    );
1182  }
1183
1184  #[test]
1185  fn verbatim_whitespace_delta_stays_present() {
1186    let css = bundled_latexml_css();
1187    assert!(
1188      css.contains("#431")
1189        && css.contains(".ltx_verbatim { white-space:pre; }")
1190        && css.contains(".ltx_text.ltx_verbatim.ltx_inline-block"),
1191      "the verbatim whitespace/line-block delta (issue #431) is missing from LaTeXML.css: \
1192       plain verbatim would collapse to one line under vanilla's nowrap, and fancyvrb \
1193       source lines would reflow side-by-side with their indentation collapsed",
1194    );
1195  }
1196
1197  #[test]
1198  fn constrained_equation_overflow_delta_stays_present() {
1199    let css = bundled_latexml_css();
1200    assert!(
1201      css.contains("#533")
1202        && css.contains(".ltx_inline-block .ltx_eqn_table")
1203        && css.contains(".ltx_td .ltx_eqn_table")
1204        && css.contains("display:block; overflow-x:auto; max-width:100%;"),
1205      "the constrained-equation containment delta (issue #533) is missing from LaTeXML.css: \
1206       display math inside a p{{}} cell / parbox / minipage / table cell would escape its \
1207       box and scatter across the page instead of scrolling within the cell",
1208    );
1209  }
1210
1211  #[test]
1212  fn framed_verbatim_responsive_delta_stays_present() {
1213    let css = bundled_latexml_css();
1214    assert!(
1215      css.contains("#525")
1216        && css.contains(
1217          ".ltx_framed_verbatim { max-width:100%; overflow-x:auto; box-sizing:border-box; }"
1218        ),
1219      "the framed-verbatim responsive-containment delta (issue #525) is missing from \
1220       LaTeXML.css: a fancyvrb frame=single box spans the print \\linewidth and would push \
1221       its border off-screen and scroll the whole page on a phone",
1222    );
1223  }
1224
1225  #[test]
1226  fn title_pubnote_content_stays_collapsed() {
1227    // arXiv/html_feedback#6888 (+ #6886): a title footnote (`\thanks`/`\titlenote`,
1228    // role=note/thanks) stays in the `<h1>` as a `.ltx_pubnotes` block. The bundled
1229    // CSS must render ONLY the dagger MARK and keep the footnote CONTENT hidden by
1230    // default, so the title text is the sole rendered language — the reporter saw the
1231    // footnote text rendered inline in the title (an older deployed build). Guard both
1232    // halves so a re-vanilla / cleanup sweep cannot silently drop the collapse.
1233    let css = bundled_latexml_css();
1234    assert!(
1235      css.contains(".ltx_pubnotes:before") && css.contains(r#"content:"\002020""#),
1236      "the title-footnote MARK rule (.ltx_pubnotes:before dagger) is missing from \
1237       LaTeXML.css (arXiv/html_feedback#6888)",
1238    );
1239    assert!(
1240      css.contains(".ltx_pubnotes .ltx_pubnotes_content")
1241        && css.contains("visibility:hidden; opacity:0"),
1242      "the title-footnote CONTENT-hidden rule (.ltx_pubnotes .ltx_pubnotes_content \
1243       {{ visibility:hidden }}) is missing from LaTeXML.css: the `\\thanks` text would \
1244       render inline in the title instead of collapsing to the dagger \
1245       (arXiv/html_feedback#6888)",
1246    );
1247  }
1248}