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