Skip to main content

latexml/
multidoc.rs

1//! In-memory join of multiple core-XML documents into one.
2//!
3//! An arXiv submission may ship several top-level `.tex` files — a main paper
4//! plus Supplementary-Material documents (see [`crate::main_tex::find_top_level_texs`]).
5//! Each is converted **independently** (its own `\documentclass`, its own core
6//! XML), then this module stitches them into a single core-XML document that the
7//! normal post-processing pipeline renders as one output: the main first, each
8//! supplement appended as a top-level appendix `<section>` titled by the
9//! supplement's own `\title`.
10//!
11//! This is the **non-streaming** join — it holds the parsed supplements in
12//! memory — which suits the overwhelmingly common case where a main+supplement
13//! pair is small. Very large submissions that need streaming are a separate
14//! concern (a post-pass "join" over separate core-XML files, tracked
15//! separately); this path deliberately keeps the whole pipeline downstream of a
16//! single `<document>` unchanged.
17//!
18//! **Id de-confliction.** Both documents number from `S1`, share the label
19//! namespace (`LABEL:sec:intro`), etc. Every supplement's id/ref/label space is
20//! rewritten with a per-source prefix (`as1_`, `as2_`, …) before splicing, so
21//! intra-supplement `\ref`s still resolve and never collide with the main. A
22//! supplement that cross-`\ref`s *into the main* will not resolve — faithful to
23//! arXiv, whose separately-compiled PDFs cannot cross-reference either.
24
25use latexml_core::common::xml::XML_NS;
26use latexml_post::document::{PostDocument, PostDocumentOptions};
27use libxml::tree::{Node, NodeType};
28
29/// Frontmatter elements dropped when a supplement becomes an appendix section
30/// (its `<title>` is promoted to the section heading; its own author/abstract
31/// block does not belong mid-document).
32const DROP_FRONTMATTER: &[&str] = &[
33  "resource",
34  "creator",
35  "date",
36  "abstract",
37  "keywords",
38  "classification",
39];
40
41/// Join a `main` core-XML string with zero or more `supplements`, returning the
42/// combined core XML. With no supplements the main is returned verbatim.
43pub fn join_core_documents(main: &str, supplements: &[String]) -> Result<String, String> {
44  if supplements.is_empty() {
45    return Ok(main.to_string());
46  }
47  let mut appendices = String::new();
48  for (i, supp) in supplements.iter().enumerate() {
49    appendices.push_str(&build_appendix(supp, i + 1)?);
50  }
51  splice_before_document_close(main, &appendices)
52}
53
54/// Parse one supplement, prefix its id/ref/label space, and render it as a
55/// top-level appendix `<section>` string (heading = the supplement's `<title>`).
56fn build_appendix(supp_xml: &str, idx: usize) -> Result<String, String> {
57  let doc = PostDocument::new_from_string(supp_xml, PostDocumentOptions::default())
58    .map_err(|e| format!("supplement {idx} parse failed: {e}"))?;
59  let root = doc
60    .get_document_element()
61    .ok_or_else(|| format!("supplement {idx} has no root <document>"))?;
62  let prefix = format!("as{idx}_");
63  prefix_id_space(&root, &prefix);
64
65  let mut title_xml = String::new();
66  let mut body = String::new();
67  for child in root.get_child_nodes() {
68    if child.get_type() != Some(NodeType::ElementNode) {
69      continue;
70    }
71    let name = child.get_name();
72    if name == "title" && title_xml.is_empty() {
73      title_xml = doc.node_to_string(&child);
74    } else if !DROP_FRONTMATTER.contains(&name.as_str()) {
75      body.push_str(&doc.node_to_string(&child));
76    }
77  }
78  if title_xml.is_empty() {
79    title_xml = "<title>Supplementary Material</title>".to_string();
80  }
81  // `class="ltx_appendix"` is the thin presentation hook for XSLT/CSS. `xml:id`
82  // is already namespaced by the reserved `xml:` prefix on re-parse; the section
83  // inherits the main document's default namespace at the splice point.
84  Ok(format!(
85    "<section class=\"ltx_appendix\" inlist=\"toc\" xml:id=\"as{idx}\">{title_xml}{body}</section>"
86  ))
87}
88
89/// Rewrite every id, reference and label under `node` with `prefix`, so a
90/// supplement's id space cannot collide with the main's. `inlist` (TOC list
91/// membership, e.g. `"toc"`) is intentionally left untouched so the appendix
92/// still joins the combined table of contents.
93fn prefix_id_space(node: &Node, prefix: &str) {
94  if node.get_type() == Some(NodeType::ElementNode) {
95    let mut n = node.clone();
96    // `xml:id` is namespaced (reserved xml: prefix) — read NS-aware, write bare.
97    if let Some(v) = n.get_attribute_ns("id", XML_NS) {
98      n.set_attribute("xml:id", &format!("{prefix}{v}")).ok();
99    }
100    for attr in ["idref", "fragid"] {
101      if let Some(v) = n.get_attribute(attr) {
102        n.set_attribute(attr, &format!("{prefix}{v}")).ok();
103      }
104    }
105    for attr in ["labels", "labelref"] {
106      if let Some(v) = n.get_attribute(attr) {
107        let rewritten = v
108          .split_whitespace()
109          .map(|tok| prefix_label(tok, prefix))
110          .collect::<Vec<_>>()
111          .join(" ");
112        n.set_attribute(attr, &rewritten).ok();
113      }
114    }
115    if let Some(v) = n.get_attribute("href")
116      && let Some(frag) = v.strip_prefix('#')
117    {
118      n.set_attribute("href", &format!("#{prefix}{frag}")).ok();
119    }
120  }
121  for child in node.get_child_nodes() {
122    prefix_id_space(&child, prefix);
123  }
124}
125
126/// Prefix a single label token, preserving the `LABEL:` sentinel.
127fn prefix_label(tok: &str, prefix: &str) -> String {
128  match tok.strip_prefix("LABEL:") {
129    Some(rest) => format!("LABEL:{prefix}{rest}"),
130    None => format!("{prefix}{tok}"),
131  }
132}
133
134/// Insert `appendices` immediately before the main document's closing
135/// `</document>` tag. The core-XML root is always a single `<document>` (default
136/// LaTeXML namespace), so its last close tag is an unambiguous splice point.
137fn splice_before_document_close(main: &str, appendices: &str) -> Result<String, String> {
138  let close = "</document>";
139  match main.rfind(close) {
140    Some(pos) => {
141      let mut out = String::with_capacity(main.len() + appendices.len());
142      out.push_str(&main[..pos]);
143      out.push_str(appendices);
144      out.push_str(&main[pos..]);
145      Ok(out)
146    },
147    None => Err("main document has no </document> close tag to splice into".to_string()),
148  }
149}
150
151#[cfg(test)]
152mod tests {
153  use super::*;
154
155  const MAIN: &str = concat!(
156    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
157    "<document xmlns=\"http://dlmf.nist.gov/LaTeXML\">",
158    "<title>Main Paper</title>",
159    "<section inlist=\"toc\" labels=\"LABEL:sec:intro\" xml:id=\"S1\">",
160    "<para xml:id=\"S1.p1\"><p>See <ref labelref=\"LABEL:sec:intro\"/>.</p></para>",
161    "</section>",
162    "</document>\n"
163  );
164  const SUPP: &str = concat!(
165    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
166    "<document xmlns=\"http://dlmf.nist.gov/LaTeXML\">",
167    "<resource src=\"LaTeXML.css\" type=\"text/css\"/>",
168    "<title>Supplementary Information for Main Paper</title>",
169    "<section inlist=\"toc\" labels=\"LABEL:sec:extra\" xml:id=\"S1\">",
170    "<para xml:id=\"S1.p1\"><p>See <ref labelref=\"LABEL:sec:extra\"/>.</p></para>",
171    "</section>",
172    "</document>\n"
173  );
174
175  #[test]
176  fn no_supplements_returns_main_verbatim() {
177    assert_eq!(join_core_documents(MAIN, &[]).unwrap(), MAIN);
178  }
179
180  #[test]
181  fn supplement_appended_as_prefixed_appendix() {
182    let joined = join_core_documents(MAIN, &[SUPP.to_string()]).unwrap();
183    // Exactly one <document> — a single joined core document.
184    assert_eq!(joined.matches("<document").count(), 1);
185    // The main's ids/labels are untouched…
186    assert!(joined.contains("xml:id=\"S1\""));
187    assert!(joined.contains("LABEL:sec:intro"));
188    // …the supplement's are prefixed, so they cannot collide.
189    assert!(joined.contains("xml:id=\"as1_S1\""));
190    assert!(joined.contains("xml:id=\"as1_S1.p1\""));
191    assert!(joined.contains("LABEL:as1_sec:extra"));
192    assert!(joined.contains("labelref=\"LABEL:as1_sec:extra\""));
193    // The supplement is an appendix titled by its own <title>.
194    assert!(joined.contains("class=\"ltx_appendix\""));
195    assert!(joined.contains("Supplementary Information for Main Paper"));
196    // The supplement's per-doc CSS <resource> is dropped.
197    let appendix_start = joined.find("ltx_appendix").unwrap();
198    assert!(!joined[appendix_start..].contains("<resource"));
199    // Appendix is inside the document (before its close).
200    let close = joined.rfind("</document>").unwrap();
201    assert!(appendix_start < close);
202  }
203
204  #[test]
205  fn two_supplements_get_distinct_prefixes() {
206    let joined = join_core_documents(MAIN, &[SUPP.to_string(), SUPP.to_string()]).unwrap();
207    assert!(joined.contains("xml:id=\"as1_S1\""));
208    assert!(joined.contains("xml:id=\"as2_S1\""));
209    assert_eq!(joined.matches("class=\"ltx_appendix\"").count(), 2);
210  }
211}