1use std::{
73 io::Write,
74 path::{Path, PathBuf},
75};
76
77use libxml::{
78 reader::{ReaderEvent, TextReader},
79 tree::{Document, Namespace, Node, NodeType},
80};
81use rustc_hash::{FxHashMap as HashMap, FxHashSet};
82
83use crate::{
84 document::{LTX_NSURI, SplitArm, SplitCond, collect_split_pages, is_ltx, parse_split_union},
85 split::SplitNaming,
86};
87
88pub struct StreamedSplitPage {
90 pub path: PathBuf,
92 pub destination: String,
95}
96
97pub struct StreamSplitOutcome {
99 pub pages: Vec<StreamedSplitPage>,
101 pub latexml_pis: Vec<String>,
103 pub picture_xml: String,
106}
107
108pub fn supports_union(union_xpath: &str) -> bool { parse_split_union(union_xpath).is_some() }
113
114pub fn stream_split(
121 source_path: &str,
122 union_xpath: &str,
123 naming: SplitNaming,
124 destination: Option<&str>,
125 spill_dir: &Path,
126) -> Result<Option<StreamSplitOutcome>, String> {
127 let arms = parse_split_union(union_xpath)
128 .ok_or_else(|| format!("split union not streamable: {union_xpath}"))?;
129 const OPTIONS: i32 = 1 + 32 + 64 + 524_288 ;
139 let reader = TextReader::from_file(source_path, OPTIONS)
140 .map_err(|()| format!("cannot open '{source_path}' for streaming"))?;
141 let mut splitter = Splitter::new(arms, naming, destination, spill_dir);
142 splitter.run(reader)?;
143 if splitter.metas.len() <= 1 {
144 Info!("split", "result", "[not split]");
148 for meta in &splitter.metas {
149 let _ = std::fs::remove_file(&meta.file);
150 }
151 return Ok(None);
152 }
153 splitter.prename();
154 splitter.patch_inlist_toc()?;
155 let n = splitter.metas.len();
156 Info!("split", "result", " [Split into {} pages]", n);
157 let Splitter {
158 metas,
159 latexml_pis,
160 picture_xml,
161 ..
162 } = splitter;
163 let pages = metas
164 .into_iter()
165 .map(|m| StreamedSplitPage {
166 path: m.file,
167 destination: m.name,
168 })
169 .collect();
170 Ok(Some(StreamSplitOutcome {
171 pages,
172 latexml_pis,
173 picture_xml,
174 }))
175}
176
177struct PageMeta {
180 file: PathBuf,
182 children: Vec<usize>,
185 localname: String,
186 xml_id: Option<String>,
187 labels: Option<String>,
188 inlist: Option<String>,
189 class_appended: bool,
194 name: String,
196}
197
198struct Level {
202 kind: LevelKind,
203 localname: String,
204 ltx: bool,
205 content: String,
207 seen_ltx: FxHashSet<String>,
210 run_toc: Vec<String>,
212 run_active: bool,
213 has_lists_toc: bool,
216 has_direct_date: bool,
218 attrs: Vec<(String, String)>,
220 lang: Option<String>,
222 bg: Option<String>,
223 ns_decls: Vec<(String, String)>,
226}
227
228enum LevelKind {
229 Root,
230 Page(usize),
232}
233
234struct Splitter {
235 arms: Vec<SplitArm>,
236 naming: SplitNaming,
237 root_destination: String,
238 spill_dir: PathBuf,
239 levels: Vec<Level>,
240 metas: Vec<PageMeta>,
241 root_prolog: String,
243 root_tail: String,
245 latexml_pis: Vec<String>,
247 resources_xml: Vec<String>,
249 dates_xml: Vec<String>,
251 navs_xml: Vec<String>,
253 root_class: Option<String>,
255 picture_xml: String,
257 first_page_spilled: bool,
258 warned_late: bool,
259 unnamed_counter: u32,
260}
261
262impl Splitter {
263 fn new(
264 arms: Vec<SplitArm>,
265 naming: SplitNaming,
266 destination: Option<&str>,
267 spill_dir: &Path,
268 ) -> Self {
269 Splitter {
270 arms,
271 naming,
272 root_destination: destination.unwrap_or("").to_string(),
273 spill_dir: spill_dir.to_path_buf(),
274 levels: Vec::new(),
275 metas: Vec::new(),
276 root_prolog: String::new(),
277 root_tail: String::new(),
278 latexml_pis: Vec::new(),
279 resources_xml: Vec::new(),
280 dates_xml: Vec::new(),
281 navs_xml: Vec::new(),
282 root_class: None,
283 picture_xml: String::new(),
284 first_page_spilled: false,
285 warned_late: false,
286 unnamed_counter: 0,
287 }
288 }
289
290 fn run(&mut self, mut reader: TextReader) -> Result<(), String> {
294 let mut advanced = reader.read().map_err(|()| "XML parse error".to_string())?;
295 while advanced {
296 match reader.event() {
297 ReaderEvent::Element => {
298 let localname = reader.local_name().unwrap_or_default();
299 let ns = reader.namespace_uri();
300 let ltx = ns.as_deref() == Some(LTX_NSURI);
301 let empty = reader.is_empty_element();
302 if self.levels.is_empty() {
303 if !self.metas.is_empty() {
304 return Err("multiple root elements in stream".to_string());
307 }
308 let attrs = reader.attributes_qname();
311 self.open_root(localname, ltx, attrs);
312 if empty {
313 self.close_top()?;
314 }
315 } else if ltx && self.is_page_here(&localname) {
316 let attrs = reader.attributes_qname();
317 self.open_page(localname, attrs);
318 if empty {
319 self.close_top()?;
320 }
321 } else {
322 self.top().seen_ltx_insert(ltx, &localname);
323 let outer = reader
324 .outer_xml()
325 .ok_or_else(|| "outer_xml failed mid-stream".to_string())?;
326 if ltx && localname == "navigation" {
327 self.navs_xml.push(outer);
330 } else if self.needs_dom_descent(&outer) {
331 let mut minidoc = reader
332 .expand_to_document()
333 .ok_or_else(|| "expand_to_document failed mid-stream".to_string())?;
334 self.descend_wrapper(&mut minidoc)?;
335 } else {
336 self.append_bulk(&outer, &localname, ltx);
337 }
338 advanced = reader
341 .read_next()
342 .map_err(|()| "XML parse error".to_string())?;
343 continue;
344 }
345 },
346 ReaderEvent::EndElement => {
347 self.close_top()?;
348 },
349 ReaderEvent::Text
350 | ReaderEvent::SignificantWhitespace
351 | ReaderEvent::Whitespace
352 | ReaderEvent::CData => {
353 if !self.levels.is_empty() {
356 let text = reader.value().unwrap_or_default();
357 self.flush_run();
358 self.top().content.push_str(&text_escape(&text));
359 }
360 },
362 ReaderEvent::Comment => {
363 let text = reader.value().unwrap_or_default();
364 let serialized = format!("<!--{text}-->");
365 self.append_misc(serialized);
366 },
367 ReaderEvent::ProcessingInstruction => {
368 let target = reader.local_name().unwrap_or_default();
369 let body = reader.value().unwrap_or_default();
370 let serialized = if body.is_empty() {
371 format!("<?{target}?>")
372 } else {
373 format!("<?{target} {body}?>")
374 };
375 if target == "latexml" {
376 self.template_pi(body);
377 }
378 self.append_misc(serialized);
379 },
380 ReaderEvent::EntityReference => {
381 return Err("unexpected unresolved entity reference in stream".to_string());
382 },
383 _ => {},
384 }
385 advanced = reader.read().map_err(|()| "XML parse error".to_string())?;
386 }
387 if !self.levels.is_empty() {
388 return Err("premature end of input (unclosed elements)".to_string());
389 }
390 if self.metas.is_empty() {
391 return Err("no root element found".to_string());
392 }
393 Ok(())
394 }
395
396 fn append_misc(&mut self, serialized: String) {
399 if self.levels.is_empty() {
400 if self.metas.is_empty() {
401 self.root_prolog.push_str(&serialized);
402 self.root_prolog.push('\n');
403 } else {
404 self.root_tail.push_str(&serialized);
405 self.root_tail.push('\n');
406 }
407 } else {
408 self.flush_run();
409 self.top().content.push_str(&serialized);
410 }
411 }
412
413 fn template_pi(&mut self, body: String) {
416 self.warn_if_late("a <?latexml?> PI");
417 self.latexml_pis.push(body);
418 }
419
420 fn warn_if_late(&mut self, what: &str) {
421 if self.first_page_spilled && !self.warned_late {
422 self.warned_late = true;
423 Warn!(
424 "split",
425 "stream",
426 "{} appeared after the first page was written; already-staged pages do not carry it",
427 what
428 );
429 }
430 }
431
432 fn top(&mut self) -> &mut Level {
436 self
437 .levels
438 .last_mut()
439 .expect("level stack must be non-empty")
440 }
441
442 fn is_page_here(&self, localname: &str) -> bool {
447 let parent = self.levels.last().expect("checked non-empty");
448 self.arms.iter().any(|arm| {
449 arm.element == localname
450 && (arm.any_of.is_empty()
451 || arm.any_of.iter().any(|cond| match cond {
452 SplitCond::PrecedingSibling(name) => parent.seen_ltx.contains(name),
453 SplitCond::Parent(name) => parent.ltx && parent.localname == *name,
454 }))
455 })
456 }
457
458 fn open_root(&mut self, localname: String, ltx: bool, attrs: Vec<(String, String)>) {
459 self.root_class = attr_value(&attrs, "class");
460 let lang = attr_value(&attrs, "xml:lang");
461 let bg = attr_value(&attrs, "backgroundcolor");
462 let ns_decls = decl_attrs(&attrs);
463 self.metas.push(PageMeta {
464 file: self.spill_dir.join("page-0000000.xml"),
465 children: Vec::new(),
466 localname: localname.clone(),
467 xml_id: attr_value(&attrs, "xml:id"),
468 labels: attr_value(&attrs, "labels"),
469 inlist: attr_value(&attrs, "inlist"),
470 class_appended: false,
471 name: self.root_destination.clone(),
472 });
473 self.levels.push(Level {
474 kind: LevelKind::Root,
475 localname,
476 ltx,
477 content: String::new(),
478 seen_ltx: FxHashSet::default(),
479 run_toc: Vec::new(),
480 run_active: false,
481 has_lists_toc: false,
482 has_direct_date: false,
483 attrs,
484 lang,
485 bg,
486 ns_decls,
487 });
488 }
489
490 fn open_page(&mut self, localname: String, attrs: Vec<(String, String)>) {
491 let parent_level = self.levels.last().expect("page under an open level");
492 let parent_meta = match parent_level.kind {
493 LevelKind::Root => 0,
494 LevelKind::Page(i) => i,
495 };
496 let lang = attr_value(&attrs, "xml:lang").or_else(|| parent_level.lang.clone());
497 let bg = attr_value(&attrs, "backgroundcolor").or_else(|| parent_level.bg.clone());
498 let idx = self.metas.len();
499 let xml_id = attr_value(&attrs, "xml:id");
500 if let Some(id) = xml_id.clone() {
501 self.top().run_toc.push(id);
502 }
503 self.top().run_active = true;
504 self.top().seen_ltx_insert(true, &localname);
505 self.metas.push(PageMeta {
506 file: self.spill_dir.join(format!("page-{idx:07}.xml")),
507 children: Vec::new(),
508 localname: localname.clone(),
509 xml_id,
510 labels: attr_value(&attrs, "labels"),
511 inlist: attr_value(&attrs, "inlist"),
512 class_appended: false,
513 name: String::new(),
514 });
515 self.metas[parent_meta].children.push(idx);
516 let ns_decls = decl_attrs(&attrs);
517 self.levels.push(Level {
518 kind: LevelKind::Page(idx),
519 localname,
520 ltx: true,
521 content: String::new(),
522 seen_ltx: FxHashSet::default(),
523 run_toc: Vec::new(),
524 run_active: false,
525 has_lists_toc: false,
526 has_direct_date: false,
527 attrs,
528 lang,
529 bg,
530 ns_decls,
531 });
532 }
533
534 fn close_top(&mut self) -> Result<(), String> {
536 self.flush_run();
537 let level = self.levels.pop().expect("close without an open level");
538 match level.kind {
539 LevelKind::Root => self.write_root_spill(level),
540 LevelKind::Page(idx) => self.write_page_spill(level, idx),
541 }
542 }
543
544 fn append_bulk(&mut self, outer: &str, localname: &str, ltx: bool) {
547 self.flush_run();
548 if ltx && localname == "date" {
549 if matches!(self.levels.last().map(|l| &l.kind), Some(LevelKind::Root)) {
550 self.warn_if_late("an ltx:date");
551 self.dates_xml.push(outer.to_string());
552 }
553 self.top().has_direct_date = true;
554 }
555 let is_resource = ltx && localname == "resource";
556 if is_resource {
557 self.warn_if_late("an ltx:resource");
558 self.resources_xml.push(outer.to_string());
559 }
560 self.bulk_probes(outer, is_resource);
561 self.top().content.push_str(outer);
562 }
563
564 fn bulk_probes(&mut self, outer: &str, expected_resource: bool) {
568 if probe_lists_toc(outer) {
569 self.top().has_lists_toc = true;
570 }
571 if outer.contains("<picture") || outer.contains(":picture") {
572 collect_pictures(outer, &mut self.picture_xml);
573 }
574 if !expected_resource
578 && (outer.contains("<resource") || outer.contains(":resource"))
579 && !self.warned_late
580 {
581 self.warned_late = true;
582 Warn!(
583 "split",
584 "stream",
585 "an ltx:resource nested inside content is not propagated to page templates by the streaming split"
586 );
587 }
588 if outer.contains("<?latexml") {
589 for body in extract_pi_bodies(outer) {
590 self.latexml_pis.push(body);
591 }
592 }
593 }
594
595 fn flush_run(&mut self) {
598 let level = self.top();
599 if !level.run_active {
600 return;
601 }
602 level.run_active = false;
603 if level.run_toc.is_empty() {
604 return;
605 }
606 let entries = std::mem::take(&mut level.run_toc);
607 if level.has_lists_toc {
608 return;
609 }
610 let parent_type = level.localname.clone();
611 let toc = self.toc_xml(&parent_type, &entries);
612 self.top().content.push_str(&toc);
613 }
614
615 fn toc_xml(&self, parent_type: &str, ids: &[String]) -> String {
616 let te = self.ltx_qname("tocentry");
617 let re = self.ltx_qname("ref");
618 let entries: String = ids
619 .iter()
620 .map(|id| {
621 format!(
622 "<{te}><{re} idref=\"{id}\" show=\"toctitle\"/></{te}>",
623 id = attr_escape(id)
624 )
625 })
626 .collect();
627 format!(
628 "<{toc}><{list} class=\"ltx_toclist_{ptype}\">{entries}</{list}></{toc}>",
629 toc = self.ltx_qname("TOC"),
630 list = self.ltx_qname("toclist"),
631 ptype = attr_escape(parent_type),
632 )
633 }
634
635 fn ltx_qname(&self, localname: &str) -> String {
642 for level in &self.levels {
643 for (prefix, uri) in &level.ns_decls {
644 if uri == LTX_NSURI {
645 return if prefix.is_empty() {
646 localname.to_string()
647 } else {
648 format!("{prefix}:{localname}")
649 };
650 }
651 }
652 }
653 localname.to_string()
654 }
655
656 fn qname_for(&self, level: &Level) -> String {
659 if level.ltx {
660 for (p, u) in decl_attrs(&level.attrs) {
661 if u == LTX_NSURI {
662 return if p.is_empty() {
663 level.localname.clone()
664 } else {
665 format!("{}:{}", p, level.localname)
666 };
667 }
668 }
669 self.ltx_qname(&level.localname)
670 } else {
671 level.localname.clone()
674 }
675 }
676
677 fn enclosing_decls(&self) -> Vec<(String, String)> {
681 let mut decls: Vec<(String, String)> = Vec::new();
682 for lvl in &self.levels {
683 for (p, u) in &lvl.ns_decls {
684 if !decls.iter().any(|(dp, _)| dp == p) {
685 decls.push((p.clone(), u.clone()));
686 }
687 }
688 }
689 decls
690 }
691
692 fn write_page_spill(&mut self, level: Level, idx: usize) -> Result<(), String> {
693 let mut attrs = level.attrs.clone();
694 if attr_value(&attrs, "xml:lang").is_none()
698 && let Some(lang) = &level.lang
699 {
700 attrs.push(("xml:lang".to_string(), lang.clone()));
701 }
702 if attr_value(&attrs, "backgroundcolor").is_none()
703 && let Some(bg) = &level.bg
704 {
705 attrs.push(("backgroundcolor".to_string(), bg.clone()));
706 }
707 let mut class_appended = false;
709 if let Some(pclass) = &self.root_class {
710 match attrs.iter_mut().find(|(k, _)| k == "class") {
711 Some((_, existing)) if !existing.is_empty() => {
712 existing.push(' ');
713 existing.push_str(pclass);
714 },
715 Some((_, existing)) => *existing = pclass.clone(),
716 None => {
717 attrs.push(("class".to_string(), pclass.clone()));
718 class_appended = true;
719 },
720 }
721 }
722 self.metas[idx].class_appended = class_appended;
723 let mut decls = self.enclosing_decls();
727 let own_decls = decl_attrs(&level.attrs);
728 decls.retain(|(p, _)| !own_decls.iter().any(|(op, _)| op == p));
729 let qname = self.qname_for(&level);
730 let mut content = level.content;
731 for r in &self.resources_xml {
735 content.push_str(r);
736 }
737 if !level.has_direct_date {
738 for d in &self.dates_xml {
739 content.push_str(d);
740 }
741 }
742 for nav in &self.navs_xml {
743 content.push_str(nav);
744 }
745 let mut tag = String::with_capacity(qname.len() + 64);
746 tag.push('<');
747 tag.push_str(&qname);
748 for (p, u) in &decls {
749 if p.is_empty() {
750 tag.push_str(&format!(" xmlns=\"{}\"", attr_escape(u)));
751 } else {
752 tag.push_str(&format!(" xmlns:{}=\"{}\"", p, attr_escape(u)));
753 }
754 }
755 for (k, v) in &attrs {
756 tag.push_str(&format!(" {}=\"{}\"", k, attr_escape(v)));
757 }
758 let out = assemble_spill(&self.spill_prolog(), &tag, &qname, &content, "");
759 write_spill(&self.metas[idx].file, &out)?;
760 self.first_page_spilled = true;
761 Ok(())
762 }
763
764 fn write_root_spill(&mut self, level: Level) -> Result<(), String> {
765 let mut attrs = level.attrs.clone();
766 if attr_value(&attrs, "xml:id").is_none() {
769 attrs.push(("xml:id".to_string(), "TEMPORARY_DOCUMENT_ID".to_string()));
770 self.metas[0].xml_id = Some("TEMPORARY_DOCUMENT_ID".to_string());
771 }
772 let qname = self.qname_for(&level);
773 let mut content = level.content;
774 for nav in &self.navs_xml {
775 content.push_str(nav);
776 }
777 let mut tag = String::with_capacity(128);
778 tag.push('<');
779 tag.push_str(&qname);
780 for (k, v) in &attrs {
781 tag.push_str(&format!(" {}=\"{}\"", k, attr_escape(v)));
782 }
783 let prolog = format!(
784 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
785 self.root_prolog
786 );
787 let out = assemble_spill(&prolog, &tag, &qname, &content, &self.root_tail);
788 write_spill(&self.metas[0].file, &out)
789 }
790
791 fn spill_prolog(&self) -> String {
794 let mut prolog = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
795 for body in &self.latexml_pis {
796 prolog.push_str(&format!("<?latexml {body}?>\n"));
797 }
798 prolog
799 }
800
801 fn needs_dom_descent(&self, outer: &str) -> bool {
809 if contains_element_probe(outer, "navigation") {
810 return true;
811 }
812 self
813 .arms
814 .iter()
815 .any(|arm| contains_element_probe(outer, &arm.element))
816 }
817
818 fn descend_wrapper(&mut self, minidoc: &mut Document) -> Result<(), String> {
824 let root = minidoc
825 .get_root_element()
826 .ok_or_else(|| "wrapper mini-document has no root".to_string())?;
827 let mut navs: Vec<Node> = Vec::new();
830 collect_ltx_descendants(&root, "navigation", &mut navs);
831 for nav in &mut navs {
832 let s = minidoc.node_to_string(nav);
833 nav.unlink_node();
834 self.navs_xml.push(s);
835 }
836 let mut page_nodes: Vec<Node> = Vec::new();
838 collect_split_pages(&root, &self.arms, &mut page_nodes);
839 let parent_level = self.levels.last().expect("wrapper under an open level");
840 let (lang, bg) = (parent_level.lang.clone(), parent_level.bg.clone());
841 self.descend_element(minidoc, &root, &page_nodes, lang, bg)?;
842 let shell = minidoc.node_to_string(&root);
847 self.flush_run();
848 if probe_lists_toc(&shell) {
849 self.top().has_lists_toc = true;
850 }
851 self.top().content.push_str(&shell);
852 Ok(())
853 }
854
855 fn descend_element(
859 &mut self,
860 doc: &Document,
861 node: &Node,
862 page_nodes: &[Node],
863 lang: Option<String>,
864 bg: Option<String>,
865 ) -> Result<(), String> {
866 let lang = xml_ns_attr(node, "lang").or(lang);
867 let bg = node.get_attribute("backgroundcolor").or(bg);
868 let mut run_toc: Vec<String> = Vec::new();
869 let mut run_active = false;
870 let mut has_lists_toc = false;
873 let node_name = node.get_name();
874 let mut child_opt = node.get_first_child();
875 while let Some(child) = child_opt {
876 let next = child.get_next_sibling();
877 let is_page_child =
878 child.get_type() == Some(NodeType::ElementNode) && page_nodes.contains(&child);
879 if is_page_child {
880 run_active = true;
881 self.dom_extract_page(
882 doc,
883 &child,
884 page_nodes,
885 lang.clone(),
886 bg.clone(),
887 &mut run_toc,
888 )?;
889 let mut extracted = child;
890 extracted.unlink_node();
891 } else {
892 if run_active {
895 run_active = false;
896 if !run_toc.is_empty() && !has_lists_toc {
897 let entries = std::mem::take(&mut run_toc);
898 self.dom_insert_toc(doc, node, Some(&child), &node_name, &entries)?;
899 }
900 run_toc.clear(); }
902 if child.get_type() == Some(NodeType::ElementNode) {
903 let serialized = doc.node_to_string(&child);
904 if self.needs_dom_descent(&serialized) {
905 self.descend_element(doc, &child, page_nodes, lang.clone(), bg.clone())?;
906 } else {
907 self.bulk_probes_dom(&serialized);
908 }
909 if has_lists_toc_node(&child) || has_lists_toc_descendant(&child) {
910 has_lists_toc = true;
911 }
912 }
913 }
914 child_opt = next;
915 }
916 if run_active && !run_toc.is_empty() && !has_lists_toc {
917 let entries = std::mem::take(&mut run_toc);
918 self.dom_insert_toc(doc, node, None, &node_name, &entries)?;
919 }
920 Ok(())
921 }
922
923 fn bulk_probes_dom(&mut self, serialized: &str) {
927 if serialized.contains("<picture") || serialized.contains(":picture") {
928 collect_pictures(serialized, &mut self.picture_xml);
929 }
930 if serialized.contains("<?latexml") {
931 for body in extract_pi_bodies(serialized) {
932 self.latexml_pis.push(body);
933 }
934 }
935 }
936
937 fn dom_extract_page(
941 &mut self,
942 doc: &Document,
943 page: &Node,
944 page_nodes: &[Node],
945 lang: Option<String>,
946 bg: Option<String>,
947 run_toc: &mut Vec<String>,
948 ) -> Result<(), String> {
949 let parent_meta = match self.levels.last().expect("open level").kind {
950 LevelKind::Root => 0,
951 LevelKind::Page(i) => i,
952 };
953 let idx = self.metas.len();
954 let xml_id = crate::document::get_xml_id(page);
955 if let Some(id) = &xml_id {
956 run_toc.push(id.clone());
957 }
958 self.metas.push(PageMeta {
959 file: self.spill_dir.join(format!("page-{idx:07}.xml")),
960 children: Vec::new(),
961 localname: page.get_name(),
962 xml_id,
963 labels: page.get_attribute("labels"),
964 inlist: page.get_attribute("inlist"),
965 class_appended: false,
966 name: String::new(),
967 });
968 self.metas[parent_meta].children.push(idx);
969 self.levels.push(Level {
972 kind: LevelKind::Page(idx),
973 localname: page.get_name(),
974 ltx: true,
975 content: String::new(),
976 seen_ltx: FxHashSet::default(),
977 run_toc: Vec::new(),
978 run_active: false,
979 has_lists_toc: false,
980 has_direct_date: false,
981 attrs: Vec::new(),
982 lang: lang.clone(),
983 bg: bg.clone(),
984 ns_decls: Vec::new(),
985 });
986 let descend_result = self.descend_element(doc, page, page_nodes, lang.clone(), bg.clone());
987 self.levels.pop();
988 descend_result?;
989 let mut serialized = doc.node_to_string(page);
994 let has_direct_date = page
995 .get_child_elements()
996 .iter()
997 .any(|c| c.get_name() == "date" && is_ltx(c));
998 let mut trailer = String::new();
999 for r in &self.resources_xml {
1000 trailer.push_str(r);
1001 }
1002 if !has_direct_date {
1003 for d in &self.dates_xml {
1004 trailer.push_str(d);
1005 }
1006 }
1007 for nav in &self.navs_xml {
1008 trailer.push_str(nav);
1009 }
1010 let effective_lang = xml_ns_attr(page, "lang").or(lang);
1011 let effective_bg = page.get_attribute("backgroundcolor").or(bg);
1012 let mut extra_attrs: Vec<(String, String)> = Vec::new();
1013 if xml_ns_attr(page, "lang").is_none()
1014 && let Some(l) = &effective_lang
1015 {
1016 extra_attrs.push(("xml:lang".to_string(), l.clone()));
1017 }
1018 if page.get_attribute("backgroundcolor").is_none()
1019 && let Some(b) = &effective_bg
1020 {
1021 extra_attrs.push(("backgroundcolor".to_string(), b.clone()));
1022 }
1023 let mut class_appended = false;
1024 let mut class_merge: Option<String> = None;
1025 if let Some(pclass) = &self.root_class {
1026 if page.get_attribute("class").is_some() {
1027 class_merge = Some(pclass.clone());
1028 } else {
1029 extra_attrs.push(("class".to_string(), pclass.clone()));
1030 class_appended = true;
1031 }
1032 }
1033 self.metas[idx].class_appended = class_appended;
1034 let decls = self.enclosing_decls();
1035 amend_serialized_page(
1036 &mut serialized,
1037 &decls,
1038 &extra_attrs,
1039 class_merge.as_deref(),
1040 &trailer,
1041 )?;
1042 let mut out = self.spill_prolog();
1043 out.push_str(&serialized);
1044 out.push('\n');
1045 write_spill(&self.metas[idx].file, &out)?;
1046 self.first_page_spilled = true;
1047 Ok(())
1048 }
1049
1050 fn dom_insert_toc(
1056 &mut self,
1057 doc: &Document,
1058 parent: &Node,
1059 anchor: Option<&Node>,
1060 parent_type: &str,
1061 ids: &[String],
1062 ) -> Result<(), String> {
1063 let mut parent = parent.clone();
1064 let ns = parent
1065 .get_namespaces(doc)
1066 .into_iter()
1067 .find(|ns| ns.get_href() == LTX_NSURI && ns.get_prefix().is_empty())
1068 .or_else(|| {
1069 parent
1070 .get_namespaces(doc)
1071 .into_iter()
1072 .find(|ns| ns.get_href() == LTX_NSURI)
1073 });
1074 let mut toc = parent
1075 .new_child(ns.clone(), "TOC")
1076 .map_err(|e| format!("cannot create TOC element: {e}"))?;
1077 let ns = ns.or_else(|| Namespace::new("", LTX_NSURI, &mut toc).ok());
1078 let mut toclist = toc
1079 .new_child(ns.clone(), "toclist")
1080 .map_err(|e| format!("cannot create toclist: {e}"))?;
1081 toclist
1082 .set_attribute("class", &format!("ltx_toclist_{parent_type}"))
1083 .map_err(|e| format!("cannot set toclist class: {e:?}"))?;
1084 for id in ids {
1085 let mut entry = toclist
1086 .new_child(ns.clone(), "tocentry")
1087 .map_err(|e| format!("cannot create tocentry: {e}"))?;
1088 let mut r = entry
1089 .new_child(ns.clone(), "ref")
1090 .map_err(|e| format!("cannot create ref: {e}"))?;
1091 r.set_attribute("idref", id)
1092 .map_err(|e| format!("cannot set idref: {e:?}"))?;
1093 r.set_attribute("show", "toctitle")
1094 .map_err(|e| format!("cannot set show: {e:?}"))?;
1095 }
1096 if let Some(a) = anchor {
1097 a.clone()
1099 .add_prev_sibling(&mut toc)
1100 .map_err(|e| format!("cannot position TOC: {e:?}"))?;
1101 }
1102 Ok(())
1103 }
1104
1105 fn prename(&mut self) {
1112 let ext = Path::new(&self.root_destination)
1113 .extension()
1114 .map(|e| e.to_string_lossy().to_string())
1115 .unwrap_or_else(|| "xml".to_string());
1116 let mut haschildren: HashMap<String, bool> = HashMap::default();
1119 for m in &self.metas {
1120 if !m.children.is_empty() {
1121 haschildren.insert(m.localname.clone(), true);
1122 }
1123 }
1124 self.prename_rec(0, &ext, &haschildren);
1125 }
1126
1127 fn prename_rec(&mut self, node: usize, ext: &str, haschildren: &HashMap<String, bool>) {
1128 let children = self.metas[node].children.clone();
1129 for &child in &children {
1130 let recursive = haschildren
1131 .get(&self.metas[child].localname)
1132 .copied()
1133 .unwrap_or(false);
1134 let name = self.get_page_name(child, node, ext, recursive);
1135 self.metas[child].name = name;
1136 }
1137 for &child in &children {
1138 self.prename_rec(child, ext, haschildren);
1139 }
1140 }
1141
1142 fn get_page_name(&mut self, page: usize, parent: usize, ext: &str, recursive: bool) -> String {
1144 let use_labels = matches!(self.naming, SplitNaming::Label | SplitNaming::LabelRelative);
1145 let attr_name = if use_labels { "labels" } else { "xml:id" };
1146 let raw = if use_labels {
1147 self.metas[page].labels.clone()
1148 } else {
1149 self.metas[page].xml_id.clone()
1150 };
1151 let mut name = raw.unwrap_or_default();
1152 if let Some(first) = name.split_whitespace().next() {
1153 name = first.to_string();
1154 }
1155 if let Some(stripped) = name.strip_prefix("LABEL:") {
1156 name = stripped.to_string();
1157 }
1158 if name.is_empty() {
1159 if use_labels && let Some(id) = self.metas[page].xml_id.clone() {
1160 Info!(
1161 "split",
1162 "pathname",
1163 "Using '{}' to create page pathname, instead of missing '{}'",
1164 id,
1165 attr_name
1166 );
1167 name = id;
1168 } else {
1169 self.unnamed_counter += 1;
1170 name = format!("FOO{}", self.unnamed_counter);
1171 Info!(
1172 "split",
1173 "pathname",
1174 "Using '{}' to create page pathname, instead of missing '{}'",
1175 name,
1176 attr_name
1177 );
1178 }
1179 }
1180 let as_dir = match self.naming {
1181 SplitNaming::IdRelative | SplitNaming::LabelRelative => {
1182 let parent_attr = if use_labels {
1183 self.metas[parent].labels.clone()
1184 } else {
1185 self.metas[parent].xml_id.clone()
1186 };
1187 if let Some(pname) = parent_attr {
1188 let pname = pname.split_whitespace().next().unwrap_or("");
1189 let pname = pname.strip_prefix("LABEL:").unwrap_or(pname);
1190 if let Some(rest) = name.strip_prefix(pname) {
1191 let rest = rest.trim_start_matches(['.', '_', ':']);
1192 if !rest.is_empty() {
1193 name = rest.to_string();
1194 }
1195 }
1196 }
1197 recursive
1198 },
1199 _ => false,
1200 };
1201 name = name.replace(':', "_");
1202 let parent_path = &self.metas[parent].name;
1203 let parent_dir = Path::new(parent_path)
1204 .parent()
1205 .and_then(|p| p.to_str())
1206 .unwrap_or(".");
1207 let parent_dir = if parent_dir.is_empty() {
1208 "."
1209 } else {
1210 parent_dir
1211 };
1212 if as_dir {
1213 format!("{}/{}/index.{}", parent_dir, name, ext)
1214 } else {
1215 format!("{}/{}.{}", parent_dir, name, ext)
1216 }
1217 }
1218
1219 fn patch_inlist_toc(&mut self) -> Result<(), String> {
1224 for node in 0..self.metas.len() {
1225 let children = &self.metas[node].children;
1226 if children.is_empty() {
1227 continue;
1228 }
1229 let intoc = children.iter().any(|&c| {
1230 self.metas[c]
1231 .inlist
1232 .as_deref()
1233 .is_some_and(|il| il.contains("toc"))
1234 });
1235 if !intoc {
1236 continue;
1237 }
1238 let to_patch: Vec<usize> = children
1239 .iter()
1240 .copied()
1241 .filter(|&c| self.metas[c].inlist.is_none())
1242 .collect();
1243 for c in to_patch {
1244 patch_spill_root_tag(
1245 &self.metas[c].file,
1246 "inlist",
1247 "toc",
1248 self.metas[c].class_appended,
1249 )?;
1250 self.metas[c].inlist = Some("toc".to_string());
1251 }
1252 }
1253 Ok(())
1254 }
1255}
1256
1257impl Level {
1258 fn seen_ltx_insert(&mut self, ltx: bool, localname: &str) {
1259 if ltx {
1260 self.seen_ltx.insert(localname.to_string());
1261 }
1262 }
1263}
1264
1265fn assemble_spill(prolog: &str, tag: &str, qname: &str, content: &str, tail: &str) -> String {
1271 let mut out = String::with_capacity(prolog.len() + tag.len() + content.len() + tail.len() + 16);
1272 out.push_str(prolog);
1273 out.push_str(tag);
1274 if content.is_empty() {
1275 out.push_str("/>\n");
1276 } else {
1277 out.push('>');
1278 out.push_str(content);
1279 out.push_str("</");
1280 out.push_str(qname);
1281 out.push_str(">\n");
1282 }
1283 out.push_str(tail);
1284 out
1285}
1286
1287fn attr_escape(value: &str) -> String {
1289 let mut out = String::with_capacity(value.len());
1290 for ch in value.chars() {
1291 match ch {
1292 '&' => out.push_str("&"),
1293 '<' => out.push_str("<"),
1294 '>' => out.push_str(">"),
1295 '"' => out.push_str("""),
1296 '\n' => out.push_str(" "),
1297 '\r' => out.push_str(" "),
1298 '\t' => out.push_str("	"),
1299 _ => out.push(ch),
1300 }
1301 }
1302 out
1303}
1304
1305fn text_escape(value: &str) -> String {
1307 let mut out = String::with_capacity(value.len());
1308 for ch in value.chars() {
1309 match ch {
1310 '&' => out.push_str("&"),
1311 '<' => out.push_str("<"),
1312 '>' => out.push_str(">"),
1313 '\r' => out.push_str(" "),
1314 _ => out.push(ch),
1315 }
1316 }
1317 out
1318}
1319
1320fn attr_value(attrs: &[(String, String)], name: &str) -> Option<String> {
1321 attrs
1322 .iter()
1323 .find(|(k, _)| k == name)
1324 .map(|(_, v)| v.clone())
1325}
1326
1327fn decl_attrs(attrs: &[(String, String)]) -> Vec<(String, String)> {
1330 let mut out = Vec::new();
1331 for (k, v) in attrs {
1332 if k == "xmlns" {
1333 out.push((String::new(), v.clone()));
1334 } else if let Some(p) = k.strip_prefix("xmlns:") {
1335 out.push((p.to_string(), v.clone()));
1336 }
1337 }
1338 out
1339}
1340
1341fn xml_ns_attr(node: &Node, localname: &str) -> Option<String> {
1345 const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
1346 node
1347 .get_attribute_ns(localname, XML_NS)
1348 .or_else(|| node.get_attribute(&format!("xml:{localname}")))
1349}
1350
1351fn contains_element_probe(outer: &str, localname: &str) -> bool {
1360 let boundary = |rest: &str| {
1361 rest
1362 .as_bytes()
1363 .first()
1364 .is_none_or(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/'))
1365 };
1366 let bare = format!("<{localname}");
1367 for (at, _) in outer.match_indices(&bare) {
1368 if boundary(&outer[at + bare.len()..]) {
1369 return true;
1370 }
1371 }
1372 let prefixed = format!(":{localname}");
1373 for (at, _) in outer.match_indices(&prefixed) {
1374 if boundary(&outer[at + prefixed.len()..]) {
1375 return true;
1376 }
1377 }
1378 false
1379}
1380
1381fn probe_lists_toc(outer: &str) -> bool {
1385 for (start, _) in outer
1386 .match_indices("<TOC")
1387 .chain(outer.match_indices(":TOC"))
1388 {
1389 let rest = &outer[start..];
1390 if let Some(end) = rest.find('>')
1391 && rest[..end].contains(" lists=\"toc\"")
1392 {
1393 return true;
1394 }
1395 }
1396 false
1397}
1398
1399fn collect_pictures(outer: &str, into: &mut String) {
1404 let mut search_from = 0;
1405 while let Some(rel) = outer[search_from..].find("<picture") {
1406 let start = search_from + rel;
1407 match outer[start..].find("</picture>") {
1408 Some(rel_end) => {
1409 let end = start + rel_end + "</picture>".len();
1410 into.push_str(&outer[start..end]);
1411 search_from = end;
1412 },
1413 None => break,
1414 }
1415 }
1416 let mut search_from = 0;
1418 while let Some(rel) = outer[search_from..].find(":picture") {
1419 let colon = search_from + rel;
1420 let after = colon + ":picture".len();
1421 let is_open = outer[..colon].rfind('<').is_some_and(|lt| {
1422 lt + 1 < colon
1423 && !outer[lt..].starts_with("</")
1424 && outer[lt + 1..colon]
1425 .chars()
1426 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1427 });
1428 if is_open {
1429 let lt = outer[..colon].rfind('<').expect("checked above");
1430 if let Some(rel_end) = outer[after..].find(":picture>") {
1431 let end = after + rel_end + ":picture>".len();
1432 into.push_str(&outer[lt..end]);
1433 search_from = end;
1434 continue;
1435 }
1436 }
1437 search_from = after;
1438 }
1439}
1440
1441fn extract_pi_bodies(outer: &str) -> Vec<String> {
1444 let mut out = Vec::new();
1445 let mut from = 0;
1446 while let Some(rel) = outer[from..].find("<?latexml") {
1447 let start = from + rel + "<?latexml".len();
1448 if let Some(rel_end) = outer[start..].find("?>") {
1449 out.push(outer[start..start + rel_end].trim().to_string());
1450 from = start + rel_end + 2;
1451 } else {
1452 break;
1453 }
1454 }
1455 out
1456}
1457
1458fn collect_ltx_descendants(node: &Node, localname: &str, out: &mut Vec<Node>) {
1460 let mut child = node.get_first_child();
1461 while let Some(c) = child {
1462 if c.get_type() == Some(NodeType::ElementNode) {
1463 if c.get_name() == localname && is_ltx(&c) {
1464 out.push(c.clone());
1465 }
1466 collect_ltx_descendants(&c, localname, out);
1467 }
1468 child = c.get_next_sibling();
1469 }
1470}
1471
1472fn has_lists_toc_descendant(node: &Node) -> bool {
1474 let mut child = node.get_first_child();
1475 while let Some(c) = child {
1476 if c.get_type() == Some(NodeType::ElementNode)
1477 && (has_lists_toc_node(&c) || has_lists_toc_descendant(&c))
1478 {
1479 return true;
1480 }
1481 child = c.get_next_sibling();
1482 }
1483 false
1484}
1485
1486fn has_lists_toc_node(node: &Node) -> bool {
1487 node.get_type() == Some(NodeType::ElementNode)
1488 && node.get_name() == "TOC"
1489 && is_ltx(node)
1490 && node.get_attribute("lists").as_deref() == Some("toc")
1491}
1492
1493fn first_tag_end(xml: &str) -> Option<usize> {
1497 let bytes = xml.as_bytes();
1498 let mut in_quote: Option<u8> = None;
1499 for (i, &b) in bytes.iter().enumerate() {
1500 match in_quote {
1501 Some(q) => {
1502 if b == q {
1503 in_quote = None;
1504 }
1505 },
1506 None => match b {
1507 b'"' | b'\'' => in_quote = Some(b),
1508 b'>' => return Some(i),
1509 _ => {},
1510 },
1511 }
1512 }
1513 None
1514}
1515
1516fn amend_serialized_page(
1521 serialized: &mut String,
1522 decls: &[(String, String)],
1523 extra_attrs: &[(String, String)],
1524 class_merge: Option<&str>,
1525 trailer: &str,
1526) -> Result<(), String> {
1527 let tag_end =
1528 first_tag_end(serialized).ok_or_else(|| "malformed page serialization".to_string())?;
1529 let self_closing = serialized[..tag_end].ends_with('/');
1530 let insert_at = if self_closing { tag_end - 1 } else { tag_end };
1531 let mut additions = String::new();
1532 for (p, u) in decls {
1533 let probe = if p.is_empty() {
1534 " xmlns=".to_string()
1535 } else {
1536 format!(" xmlns:{p}=")
1537 };
1538 if !serialized[..tag_end].contains(&probe) {
1539 if p.is_empty() {
1540 additions.push_str(&format!(" xmlns=\"{}\"", attr_escape(u)));
1541 } else {
1542 additions.push_str(&format!(" xmlns:{}=\"{}\"", p, attr_escape(u)));
1543 }
1544 }
1545 }
1546 for (k, v) in extra_attrs {
1547 additions.push_str(&format!(" {}=\"{}\"", k, attr_escape(v)));
1548 }
1549 serialized.insert_str(insert_at, &additions);
1550 if let Some(pclass) = class_merge {
1551 let tag_end = first_tag_end(serialized).ok_or("malformed tag")?;
1552 if let Some(cpos) = serialized[..tag_end].find(" class=\"") {
1553 let vstart = cpos + " class=\"".len();
1554 if let Some(vlen) = serialized[vstart..tag_end].find('"') {
1555 let existing = serialized[vstart..vstart + vlen].to_string();
1556 let merged = if existing.is_empty() {
1557 attr_escape(pclass)
1558 } else {
1559 format!("{} {}", existing, attr_escape(pclass))
1560 };
1561 serialized.replace_range(vstart..vstart + vlen, &merged);
1562 }
1563 }
1564 }
1565 if !trailer.is_empty() {
1566 let tag_end = first_tag_end(serialized).ok_or("malformed tag")?;
1567 if serialized[..=tag_end].ends_with("/>") {
1568 let qname_end = serialized
1570 .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
1571 .unwrap_or(tag_end);
1572 let qname = serialized[1..qname_end].to_string();
1573 serialized.truncate(tag_end - 1);
1574 serialized.push('>');
1575 serialized.push_str(trailer);
1576 serialized.push_str(&format!("</{qname}>"));
1577 } else if let Some(close_at) = serialized.rfind("</") {
1578 serialized.insert_str(close_at, trailer);
1579 }
1580 }
1581 Ok(())
1582}
1583
1584fn patch_spill_root_tag(
1589 file: &Path,
1590 name: &str,
1591 value: &str,
1592 before_appended_class: bool,
1593) -> Result<(), String> {
1594 let content = std::fs::read_to_string(file).map_err(|e| {
1595 format!(
1596 "cannot read staged page {} for patching: {e}",
1597 file.display()
1598 )
1599 })?;
1600 let mut pos = 0;
1602 let root_start = loop {
1603 let rel = content[pos..]
1604 .find('<')
1605 .ok_or_else(|| "no root tag in spill".to_string())?;
1606 let at = pos + rel;
1607 if content[at..].starts_with("<?") {
1608 pos = at + content[at..].find("?>").ok_or("unterminated PI")? + 2;
1609 } else if content[at..].starts_with("<!--") {
1610 pos = at + content[at..].find("-->").ok_or("unterminated comment")? + 3;
1611 } else {
1612 break at;
1613 }
1614 };
1615 let tag_end = root_start
1616 + first_tag_end(&content[root_start..]).ok_or_else(|| "malformed root tag".to_string())?;
1617 let tag_self_close_adjust = if content[..tag_end].ends_with('/') {
1618 tag_end - 1
1619 } else {
1620 tag_end
1621 };
1622 let insert_at = if before_appended_class {
1623 content[root_start..tag_end]
1624 .rfind(" class=\"")
1625 .map(|rel| root_start + rel)
1626 .unwrap_or(tag_self_close_adjust)
1627 } else {
1628 tag_self_close_adjust
1629 };
1630 let mut patched = String::with_capacity(content.len() + 16);
1631 patched.push_str(&content[..insert_at]);
1632 patched.push_str(&format!(" {}=\"{}\"", name, attr_escape(value)));
1633 patched.push_str(&content[insert_at..]);
1634 std::fs::write(file, patched).map_err(|e| format!("cannot rewrite spill: {e}"))
1635}
1636
1637fn write_spill(path: &Path, content: &str) -> Result<(), String> {
1638 let mut f = std::fs::File::create(path)
1639 .map_err(|e| format!("cannot create spill {}: {e}", path.display()))?;
1640 f.write_all(content.as_bytes())
1641 .map_err(|e| format!("cannot write spill {}: {e}", path.display()))
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646 use super::*;
1647
1648 #[test]
1649 fn escaping_matches_libxml() {
1650 assert_eq!(
1651 attr_escape("a<b>&\"c\"\nd\te\r"),
1652 "a<b>&"c" d	e "
1653 );
1654 assert_eq!(text_escape("a<b>&c\r"), "a<b>&c ");
1655 }
1656
1657 #[test]
1658 fn first_tag_end_skips_gt_inside_quotes() {
1659 assert_eq!(first_tag_end(r#"<x a="1>2">rest</x>"#), Some(10));
1660 assert_eq!(first_tag_end("<x/>"), Some(3));
1661 assert_eq!(first_tag_end("<never-closed"), None);
1662 }
1663
1664 #[test]
1665 fn probe_lists_toc_is_exact() {
1666 assert!(probe_lists_toc(r#"<p><TOC lists="toc"><t/></TOC></p>"#));
1667 assert!(probe_lists_toc(r#"<ltx:TOC lists="toc"/>"#));
1668 assert!(!probe_lists_toc(r#"<TOC><toclist class="c"/></TOC>"#));
1670 assert!(!probe_lists_toc(r#"<TOC lists="toc lof"/>"#));
1672 }
1673
1674 #[test]
1675 fn amend_injects_attrs_and_trailer() {
1676 let mut s = r#"<section xml:id="S1"><p>t</p></section>"#.to_string();
1677 amend_serialized_page(
1678 &mut s,
1679 &[(String::new(), "urn:ns".to_string())],
1680 &[("xml:lang".to_string(), "en".to_string())],
1681 None,
1682 "<date>d</date>",
1683 )
1684 .unwrap();
1685 assert_eq!(
1688 s,
1689 r#"<section xml:id="S1" xmlns="urn:ns" xml:lang="en"><p>t</p><date>d</date></section>"#
1690 );
1691 }
1692
1693 #[test]
1694 fn amend_reopens_self_closed_page_for_trailer() {
1695 let mut s = "<chapter/>".to_string();
1696 amend_serialized_page(&mut s, &[], &[], None, "<x/>").unwrap();
1697 assert_eq!(s, "<chapter><x/></chapter>");
1698 }
1699
1700 #[test]
1701 fn amend_merges_class() {
1702 let mut s = r#"<section class="own"><p/></section>"#.to_string();
1703 amend_serialized_page(&mut s, &[], &[], Some("root"), "").unwrap();
1704 assert_eq!(s, r#"<section class="own root"><p/></section>"#);
1705 }
1706
1707 #[test]
1708 fn patch_inserts_before_appended_class() {
1709 let dir = std::env::temp_dir();
1710 let file = dir.join(format!("lxo-patch-test-{}.xml", std::process::id()));
1711 std::fs::write(
1712 &file,
1713 "<?xml version=\"1.0\"?>\n<?latexml p?>\n<section xml:id=\"S1\" class=\"root\"><p/></section>\n",
1714 )
1715 .unwrap();
1716 patch_spill_root_tag(&file, "inlist", "toc", true).unwrap();
1717 let out = std::fs::read_to_string(&file).unwrap();
1718 assert!(
1719 out.contains(r#"<section xml:id="S1" inlist="toc" class="root">"#),
1720 "patched tag order wrong: {out}"
1721 );
1722 std::fs::write(
1724 &file,
1725 "<?xml version=\"1.0\"?>\n<section xml:id=\"S1\" class=\"own\"><p/></section>\n",
1726 )
1727 .unwrap();
1728 patch_spill_root_tag(&file, "inlist", "toc", false).unwrap();
1729 let out = std::fs::read_to_string(&file).unwrap();
1730 assert!(
1731 out.contains(r#"<section xml:id="S1" class="own" inlist="toc">"#),
1732 "patched tag order wrong: {out}"
1733 );
1734 std::fs::remove_file(&file).ok();
1735 }
1736
1737 #[test]
1738 fn collect_pictures_extracts_spans() {
1739 let mut buf = String::new();
1740 collect_pictures(
1741 r#"<p>x</p><picture xml:id="p1"><g/></picture><q/><picture xml:id="p2"/>...</picture>"#,
1742 &mut buf,
1743 );
1744 assert!(buf.starts_with(r#"<picture xml:id="p1"><g/></picture>"#));
1745 }
1746
1747 #[test]
1750 fn wrapper_page_spill_is_namespaced() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let src = dir.path().join("mini.xml");
1753 std::fs::write(
1754 &src,
1755 r#"<?xml version="1.0" encoding="UTF-8"?>
1756<document xmlns="http://dlmf.nist.gov/LaTeXML" class="rc">
1757 <title>T</title>
1758 <chapter xml:id="C1"><title>One</title><para xml:id="C1.p1"><p>x</p></para></chapter>
1759 <backmatter>
1760 <section xml:id="BM.S1"><title>BS</title></section>
1761 <appendix xml:id="A1"><title>App</title></appendix>
1762 </backmatter>
1763</document>
1764"#,
1765 )
1766 .unwrap();
1767 let spill = dir.path().join("spill");
1768 std::fs::create_dir(&spill).unwrap();
1769 let union = "//ltx:section | //ltx:chapter | //ltx:appendix[preceding-sibling::ltx:section or parent::ltx:chapter]";
1770 let outcome = stream_split(
1771 &src.to_string_lossy(),
1772 union,
1773 SplitNaming::Id,
1774 Some("out/mini.html"),
1775 &spill,
1776 )
1777 .expect("split runs")
1778 .expect("split produces pages");
1779 let names: Vec<&str> = outcome
1780 .pages
1781 .iter()
1782 .map(|p| p.destination.as_str())
1783 .collect();
1784 assert_eq!(
1785 names,
1786 vec![
1787 "out/mini.html",
1788 "out/C1.html",
1789 "out/BM.S1.html",
1790 "out/A1.html"
1791 ],
1792 "pre-order destinations"
1793 );
1794 for page in &outcome.pages[1..] {
1795 let content = std::fs::read_to_string(&page.path).unwrap();
1796 assert!(
1797 content.contains("xmlns=\"http://dlmf.nist.gov/LaTeXML\""),
1798 "page {} must declare the ltx namespace:\n{content}",
1799 page.destination
1800 );
1801 let doc = libxml::parser::Parser::default()
1803 .parse_string(&content)
1804 .expect("page reparses");
1805 let root = doc.get_root_element().unwrap();
1806 assert_eq!(
1807 root.get_namespace().map(|n| n.get_href()),
1808 Some(LTX_NSURI.to_string()),
1809 "page {} root not in ltx namespace:\n{content}",
1810 page.destination
1811 );
1812 }
1813 }
1814
1815 #[test]
1816 fn element_probe_respects_name_boundaries() {
1817 assert!(!contains_element_probe(
1819 "<p><indexmark k=\"x\"/></p>",
1820 "index"
1821 ));
1822 assert!(contains_element_probe("<p><index r=\"1\"/></p>", "index"));
1824 assert!(contains_element_probe("<index>", "index"));
1825 assert!(contains_element_probe("<index/>", "index"));
1826 assert!(contains_element_probe("<ltx:index>x</ltx:index>", "index"));
1827 assert!(!contains_element_probe("<subsubsection>", "subsection"));
1828 assert!(!contains_element_probe("plain text", "section"));
1829 }
1830
1831 #[test]
1832 fn pi_bodies_extracted() {
1833 assert_eq!(
1834 extract_pi_bodies(r#"<a><?latexml package="x"?><?other y?><?latexml class="c"?></a>"#),
1835 vec![r#"package="x""#.to_string(), r#"class="c""#.to_string()]
1836 );
1837 }
1838}