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}