Skip to main content

latexml_post/
writer.rs

1//! XML/HTML output sink — port of `LaTeXML::Post::Writer`.
2//!
3//! Two related concerns live here:
4//!
5//! 1. The [`Writer`] post-processor (last in the chain) that serializes a `PostDocument` to its
6//!    `destination`, handling DOCTYPE removal, TEMPORARY_DOCUMENT_ID cleanup, and HTML vs XML
7//!    serialization.
8//! 2. Free-standing helpers ([`write_output`], [`ensure_parent_dir`]) used by binary main()s that
9//!    already have the serialized string in hand (post-processing returns a `String`) and need to
10//!    route it to a destination path or stdout. Replaces the duplicated `File::create + write! +
11//!    ensure_parent_dir` boilerplate that used to live in `latexml_oxide.rs` (and the now-retired
12//!    `latexmlpost_oxide.rs`).
13//!
14//! Companion module: [`crate::pack`] (the `LaTeXML::Post::Pack` analog)
15//! handles archive bundling when the destination is a zip.
16
17use std::{
18  fs,
19  io::{self, Write},
20  path::Path,
21};
22
23use libxml::tree::{Node, SaveOptions};
24
25use crate::{
26  document::{PostDocument, get_xml_id},
27  processor::{PostError, ProcessResult, Processor},
28};
29
30/// Write the serialized output `content` to `dest` if `Some`, else to
31/// stdout. Creates parent directories as needed.
32///
33/// Used by `latexml_oxide.rs`'s main() (XML-input mode included) for
34/// the "write a single HTML/XML file" exit path. For the zip-archive
35/// exit path, use [`crate::pack::pack_archive`].
36pub fn write_output(content: &str, dest: Option<&str>) -> io::Result<()> {
37  match dest {
38    Some(path) => {
39      ensure_parent_dir(path)?;
40      fs::write(path, content)?;
41      Info!(
42        "writer",
43        "wrote",
44        "Wrote '{}' ({} bytes)",
45        path,
46        content.len()
47      );
48      Ok(())
49    },
50    None => io::stdout().write_all(content.as_bytes()),
51  }
52}
53
54/// Like [`write_output`] but writes several segments back-to-back without
55/// concatenating them into one buffer first. Used for the conversion log,
56/// where the core and post-phase segments are each already-allocated and
57/// large for real articles — a `format!("{core}{post}")` would allocate a
58/// third copy of their combined size on the conversion hot path. Segments are
59/// written verbatim and in order through a single `BufWriter` (one file
60/// open/truncate); insert any separators (e.g. `"\n"`) as their own segments.
61pub fn write_output_segments(segments: &[&str], dest: Option<&str>) -> io::Result<()> {
62  match dest {
63    Some(path) => {
64      ensure_parent_dir(path)?;
65      let mut writer = io::BufWriter::new(fs::File::create(path)?);
66      let mut total = 0usize;
67      for seg in segments {
68        writer.write_all(seg.as_bytes())?;
69        total += seg.len();
70      }
71      writer.flush()?;
72      Info!("writer", "wrote", "Wrote '{}' ({} bytes)", path, total);
73      Ok(())
74    },
75    None => {
76      let stdout = io::stdout();
77      let mut handle = stdout.lock();
78      for seg in segments {
79        handle.write_all(seg.as_bytes())?;
80      }
81      Ok(())
82    },
83  }
84}
85
86/// Ensure the parent directory of `path` exists, creating it (and any
87/// missing ancestors) as needed. No-op when `path` has no parent or
88/// the parent is the current directory.
89pub fn ensure_parent_dir(path: &str) -> io::Result<()> {
90  if let Some(parent) = Path::new(path).parent() {
91    if !parent.as_os_str().is_empty() {
92      fs::create_dir_all(parent)?;
93    }
94  }
95  Ok(())
96}
97
98/// Output format for the writer.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum OutputFormat {
101  Xml,
102  Html,
103}
104
105/// Writer post-processor: serializes document to file.
106///
107/// Port of `LaTeXML::Post::Writer`.
108pub struct Writer {
109  name:         String,
110  format:       OutputFormat,
111  omit_doctype: bool,
112  is_html:      bool,
113}
114
115impl Writer {
116  pub fn new(format: Option<OutputFormat>, omit_doctype: bool, is_html: bool) -> Self {
117    Writer {
118      name: "Writer".to_string(),
119      format: format.unwrap_or(OutputFormat::Xml),
120      omit_doctype,
121      is_html,
122    }
123  }
124}
125
126impl Processor for Writer {
127  fn get_name(&self) -> &str { &self.name }
128
129  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
130    match doc.get_document_element() {
131      Some(el) => vec![el],
132      None => vec![],
133    }
134  }
135
136  fn process(&mut self, mut doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
137    let mut root = match nodes.into_iter().next() {
138      Some(r) => r,
139      None => return Ok(vec![doc]),
140    };
141
142    // Remove the internal DTD subset if requested (Perl Writer.pm L38:
143    // `$doc->getDocument->removeInternalSubset if $$self{omit_doctype}`).
144    // Backed by `libxml::tree::Document::remove_internal_subset`, added
145    // in rust-libxml 0.3.11 specifically to close this gap.
146    if self.omit_doctype {
147      doc.get_document_mut().remove_internal_subset();
148    }
149
150    // Remove TEMPORARY_DOCUMENT_ID if present (Perl Writer.pm L41-42).
151    // NS-aware forms: the bare get/remove_attribute("xml:id") pair silently
152    // no-opped, so this last-chance strip never fired (the split path
153    // removes the temp id earlier via core_interface, but this writer-side
154    // guard is the one Perl relies on).
155    if let Some(id) = get_xml_id(&root) {
156      if id == "TEMPORARY_DOCUMENT_ID" {
157        let _ = root.remove_attribute_ns("id", latexml_core::common::xml::XML_NS);
158      }
159    }
160
161    // Serialize: HTML uses toStringHTML, XML uses toString(1)  (Perl Writer.pm L44-47)
162    let serialized = if self.is_html {
163      doc.get_document().to_string_with_options(SaveOptions {
164        as_html: true,
165        format: true,
166        ..SaveOptions::default()
167      })
168    } else {
169      doc.get_document().to_string_with_options(SaveOptions {
170        format: true,
171        ..SaveOptions::default()
172      })
173    };
174
175    if let Some(destination) = doc.get_destination() {
176      // Create destination directory if needed
177      if let Some(destdir) = doc.get_destination_directory() {
178        fs::create_dir_all(destdir).map_err(|e| {
179          PostError::Io(io::Error::new(
180            e.kind(),
181            format!("Couldn't create directory '{}': {}", destdir, e),
182          ))
183        })?;
184      }
185      fs::write(destination, &serialized).map_err(|e| {
186        PostError::Io(io::Error::new(
187          e.kind(),
188          format!("Couldn't write '{}': {}", destination, e),
189        ))
190      })?;
191      Info!(
192        "writer",
193        "wrote",
194        "Wrote '{}' ({})",
195        destination,
196        serialized.len()
197      );
198    } else {
199      print!("{}", serialized);
200    }
201
202    Ok(vec![doc])
203  }
204}
205
206#[cfg(test)]
207mod tests {
208  use super::*;
209
210  #[test]
211  fn output_format_variants() {
212    let _ = OutputFormat::Xml;
213    let _ = OutputFormat::Html;
214  }
215
216  #[test]
217  fn output_format_partial_eq() {
218    assert_eq!(OutputFormat::Xml, OutputFormat::Xml);
219    assert_ne!(OutputFormat::Xml, OutputFormat::Html);
220    assert_eq!(OutputFormat::Html, OutputFormat::Html);
221  }
222
223  #[test]
224  fn output_format_copy_clone() {
225    // Copy trait: move-after-use still works.
226    let a = OutputFormat::Xml;
227    let b = a;
228    let _ = a; // still usable due to Copy
229    assert_eq!(a, b);
230  }
231
232  #[test]
233  fn writer_new_default_format_xml() {
234    let w = Writer::new(None, false, false);
235    assert_eq!(w.get_name(), "Writer");
236    assert_eq!(w.format, OutputFormat::Xml);
237    assert!(!w.omit_doctype);
238    assert!(!w.is_html);
239  }
240
241  #[test]
242  fn writer_new_explicit_format() {
243    let w = Writer::new(Some(OutputFormat::Html), true, true);
244    assert_eq!(w.format, OutputFormat::Html);
245    assert!(w.omit_doctype);
246    assert!(w.is_html);
247  }
248
249  #[test]
250  fn writer_get_name_is_writer() {
251    let w = Writer::new(None, false, false);
252    assert_eq!(w.get_name(), "Writer");
253  }
254
255  #[test]
256  /// `Writer::process` with `omit_doctype = true` drops the
257  /// `<!DOCTYPE …>` preamble from the output, mirroring Perl
258  /// `Post::Writer` L38 behaviour.
259  fn writer_omit_doctype_strips_doctype_preamble() {
260    use crate::document::{PostDocument, PostDocumentOptions};
261
262    let xml = r#"<?xml version="1.0"?>
263<!DOCTYPE root SYSTEM "example.dtd">
264<root><child>hi</child></root>"#;
265    let doc = PostDocument::new_from_string(xml, PostDocumentOptions::default())
266      .expect("parse test fixture");
267
268    // omit_doctype=true → DOCTYPE stripped after Writer::process.
269    let mut writer = Writer::new(
270      None, /* omit_doctype= */ true, /* is_html= */ false,
271    );
272    let to_process = writer.to_process(&doc);
273    let result = writer.process(doc, to_process).expect("process");
274    let after = result
275      .into_iter()
276      .next()
277      .expect("at least one doc")
278      .get_document()
279      .to_string();
280    assert!(
281      !after.contains("<!DOCTYPE"),
282      "expected DOCTYPE stripped, got: {after}"
283    );
284    assert!(after.contains("<root>"));
285  }
286
287  #[test]
288  /// `Writer::process` with `omit_doctype = false` (the default)
289  /// preserves the `<!DOCTYPE …>` preamble — opt-in behaviour.
290  fn writer_default_preserves_doctype() {
291    use crate::document::{PostDocument, PostDocumentOptions};
292
293    let xml = r#"<?xml version="1.0"?>
294<!DOCTYPE root SYSTEM "example.dtd">
295<root><child>hi</child></root>"#;
296    let doc = PostDocument::new_from_string(xml, PostDocumentOptions::default())
297      .expect("parse test fixture");
298
299    let mut writer = Writer::new(
300      None, /* omit_doctype= */ false, /* is_html= */ false,
301    );
302    let to_process = writer.to_process(&doc);
303    let result = writer.process(doc, to_process).expect("process");
304    let after = result
305      .into_iter()
306      .next()
307      .expect("at least one doc")
308      .get_document()
309      .to_string();
310    assert!(
311      after.contains("<!DOCTYPE"),
312      "expected DOCTYPE preserved, got: {after}"
313    );
314  }
315}