1use latexml_core::common::xml::XML_NS;
26use latexml_post::document::{PostDocument, PostDocumentOptions};
27use libxml::tree::{Node, NodeType};
28
29const DROP_FRONTMATTER: &[&str] = &[
33 "resource",
34 "creator",
35 "date",
36 "abstract",
37 "keywords",
38 "classification",
39];
40
41pub 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
54fn 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 Ok(format!(
85 "<section class=\"ltx_appendix\" inlist=\"toc\" xml:id=\"as{idx}\">{title_xml}{body}</section>"
86 ))
87}
88
89fn prefix_id_space(node: &Node, prefix: &str) {
94 if node.get_type() == Some(NodeType::ElementNode) {
95 let mut n = node.clone();
96 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
126fn 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
134fn 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 assert_eq!(joined.matches("<document").count(), 1);
185 assert!(joined.contains("xml:id=\"S1\""));
187 assert!(joined.contains("LABEL:sec:intro"));
188 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 assert!(joined.contains("class=\"ltx_appendix\""));
195 assert!(joined.contains("Supplementary Information for Main Paper"));
196 let appendix_start = joined.find("ltx_appendix").unwrap();
198 assert!(!joined[appendix_start..].contains("<resource"));
199 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}