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