Skip to main content

latexml_post/
processor.rs

1//! Abstract post-processor base.
2//!
3//! Port of `LaTeXML::Post::Processor`.
4//! All post-processors implement the [`Processor`] trait.
5
6use std::path::PathBuf;
7
8use libxml::tree::Node;
9use regex::Regex;
10use rustc_hash::FxHashMap as HashMap;
11
12use crate::document::PostDocument;
13
14/// Options for constructing a processor.
15#[derive(Debug, Default, Clone)]
16pub struct ProcessorOptions {
17  pub resource_directory: Option<String>,
18  pub resource_prefix:    Option<String>,
19}
20
21/// Result of processing: the document (possibly split into multiple).
22pub type ProcessResult = Result<Vec<PostDocument>, PostError>;
23
24/// Errors from post-processing.
25#[derive(Debug)]
26pub enum PostError {
27  /// A processing error with context message.
28  Processing(String),
29  /// An I/O error.
30  Io(std::io::Error),
31  /// An XML error.
32  Xml(String),
33}
34
35impl std::fmt::Display for PostError {
36  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37    match self {
38      PostError::Processing(msg) => write!(f, "Post-processing error: {}", msg),
39      PostError::Io(err) => write!(f, "I/O error: {}", err),
40      PostError::Xml(msg) => write!(f, "XML error: {}", msg),
41    }
42  }
43}
44
45impl std::error::Error for PostError {}
46
47impl From<std::io::Error> for PostError {
48  fn from(err: std::io::Error) -> Self { PostError::Io(err) }
49}
50
51/// Abstract base trait for all post-processors.
52///
53/// Corresponds to `LaTeXML::Post::Processor`.
54/// Processors operate on a [`PostDocument`] and return one or more documents
55/// (splitting may produce multiple outputs).
56pub trait Processor {
57  /// Human-readable name for this processor.
58  fn get_name(&self) -> &str;
59
60  /// Resource directory for generated resources (images, etc.).
61  fn resource_directory(&self) -> Option<&str> { None }
62
63  /// Resource prefix for generated resource filenames.
64  fn resource_prefix(&self) -> Option<&str> { None }
65
66  /// Return the nodes to be processed; by default the document element.
67  /// This allows processors to focus on specific kinds of nodes,
68  /// or to skip processing if there are none to process.
69  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
70    match doc.get_document_element() {
71      Some(el) => vec![el],
72      None => vec![],
73    }
74  }
75
76  /// Process the document given the nodes returned by `to_process`.
77  /// Returns the resulting document(s) — splitting may produce multiple.
78  fn process(&mut self, doc: PostDocument, nodes: Vec<Node>) -> ProcessResult;
79
80  /// Hint for a desired resource pathname.
81  fn desired_resource_pathname(
82    &self,
83    _doc: &PostDocument,
84    _node: &Node,
85    _source: Option<&str>,
86    _type_ext: Option<&str>,
87  ) -> Option<PathBuf> {
88    None
89  }
90
91  /// Auto-generate a unique resource pathname using a counter from the doc cache.
92  fn generate_resource_pathname(
93    &self,
94    doc: &mut PostDocument,
95    _node: &Node,
96    _source: Option<&str>,
97    type_ext: Option<&str>,
98  ) -> PathBuf {
99    let subdir = self.resource_directory().unwrap_or("");
100    let prefix = self.resource_prefix().unwrap_or("x");
101    let counter_key = format!("_max_{}_{}_counter_", subdir, prefix);
102    let n = doc
103      .cache_lookup(&counter_key)
104      .and_then(|v| v.parse::<u32>().ok())
105      .unwrap_or(0)
106      + 1;
107    doc.cache_store(&counter_key, &n.to_string());
108    let name = format!("{}{}", prefix, n);
109    let mut path = PathBuf::from(subdir);
110    let filename = if let Some(ext) = type_ext {
111      format!("{}.{}", name, ext)
112    } else {
113      name
114    };
115    path.push(filename);
116    path
117  }
118}
119
120/// Information about a document class or package extracted from processing instructions.
121#[derive(Debug, Clone)]
122pub struct ClassInfo {
123  pub name:     String,
124  pub options:  String,
125  pub oldstyle: Option<String>,
126}
127
128/// Information about a loaded package.
129#[derive(Debug, Clone)]
130pub struct PackageInfo {
131  pub name:    String,
132  pub options: String,
133}
134
135/// Extract the document class and packages from `<?latexml ...?>` processing instructions.
136///
137/// Returns `(class_info, packages)` where `class_info` defaults to "article" if none found.
138///
139/// Port of `Processor::find_documentclass_and_packages`.
140pub fn find_documentclass_and_packages(doc: &PostDocument) -> (ClassInfo, Vec<PackageInfo>) {
141  let pi_re = Regex::new(r#"\s*([\w\-_]*)=[\"'](.*?)[\"']"#).unwrap();
142  let mut class: Option<String> = None;
143  let mut classoptions = String::from("onecolumn");
144  let mut oldstyle: Option<String> = None;
145  let mut packages = Vec::new();
146
147  for pi in doc.findnodes("//processing-instruction('latexml')") {
148    let data = pi.get_content();
149    let mut entry = HashMap::default();
150    for cap in pi_re.captures_iter(&data) {
151      entry.insert(cap[1].to_string(), cap[2].to_string());
152    }
153    if let Some(cls) = entry.get("class") {
154      class = Some(cls.clone());
155      classoptions = entry
156        .get("options")
157        .cloned()
158        .unwrap_or_else(|| "onecolumn".to_string());
159      oldstyle = entry.get("oldstyle").cloned();
160    } else if let Some(pkg) = entry.get("package") {
161      let opts = entry.get("options").cloned().unwrap_or_default();
162      for p in pkg.split(',').map(str::trim).filter(|s| !s.is_empty()) {
163        packages.push(PackageInfo {
164          name:    p.to_string(),
165          options: opts.clone(),
166        });
167      }
168    }
169  }
170
171  if class.is_none() {
172    // Perl Post.pm:226 — Warn('expected', 'class', undef,
173    //   "No document class found; using article")
174    Warn!(
175      "expected",
176      "class",
177      "No document class found; using article"
178    );
179  }
180
181  let class_info = ClassInfo {
182    name: class.unwrap_or_else(|| "article".to_string()),
183    options: classoptions,
184    oldstyle,
185  };
186  (class_info, packages)
187}
188
189/// Extract preamble data from `<?latexml ...?>` processing instructions.
190///
191/// Port of `Processor::find_preambles`.
192pub fn find_preambles(doc: &PostDocument) -> String {
193  let pi_re = Regex::new(r#"\s*([\w\-_]*)=[\"'](.*?)[\"']"#).unwrap();
194  let mut preambles = Vec::new();
195
196  for pi in doc.findnodes("//processing-instruction('latexml')") {
197    let data = pi.get_content();
198    for cap in pi_re.captures_iter(&data) {
199      if &cap[1] == "preamble" {
200        preambles.push(cap[2].to_string());
201      }
202    }
203  }
204
205  preambles.join("\n")
206}
207
208/// Copy foreign-namespace attributes from `source` to `target`.
209///
210/// "Foreign" means attributes with a namespace prefix (contains ':')
211/// but NOT `xml:*` attributes.
212///
213/// Port of `Processor::copy_foreign_attributes`.
214pub fn copy_foreign_attributes(target: &mut Node, source: &Node) {
215  let props = source.get_properties();
216  for (key, value) in &props {
217    if key.starts_with("xml:") {
218      continue;
219    }
220    if !key.contains(':') {
221      continue;
222    }
223    // Only set if target doesn't already have this attribute
224    if target.get_attribute(key).is_none() {
225      target.set_attribute(key, value).ok();
226    }
227  }
228}
229
230#[cfg(test)]
231mod tests {
232  use super::*;
233
234  #[test]
235  fn processor_options_default_is_empty() {
236    let o = ProcessorOptions::default();
237    assert!(o.resource_directory.is_none());
238    assert!(o.resource_prefix.is_none());
239  }
240
241  #[test]
242  fn processor_options_clone_preserves() {
243    let o = ProcessorOptions {
244      resource_directory: Some("/tmp".to_string()),
245      resource_prefix:    Some("pre".to_string()),
246    };
247    let c = o.clone();
248    assert_eq!(c.resource_directory, Some("/tmp".to_string()));
249    assert_eq!(c.resource_prefix, Some("pre".to_string()));
250  }
251
252  #[test]
253  fn post_error_display_processing() {
254    let e = PostError::Processing("boom".to_string());
255    let s = format!("{e}");
256    assert!(s.contains("Post-processing error"));
257    assert!(s.contains("boom"));
258  }
259
260  #[test]
261  fn post_error_display_xml() {
262    let e = PostError::Xml("malformed".to_string());
263    let s = format!("{e}");
264    assert!(s.contains("XML error"));
265    assert!(s.contains("malformed"));
266  }
267
268  #[test]
269  fn post_error_from_io_error() {
270    let io = std::io::Error::new(std::io::ErrorKind::NotFound, "x");
271    let pe: PostError = io.into();
272    match pe {
273      PostError::Io(_) => {},
274      other => panic!("expected Io, got {other:?}"),
275    }
276  }
277
278  #[test]
279  fn post_error_display_io() {
280    let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
281    let e = PostError::Io(io);
282    let s = format!("{e}");
283    assert!(s.contains("I/O error"));
284  }
285
286  #[test]
287  fn post_error_impls_std_error() {
288    // The blanket impl lets us box it as dyn Error.
289    fn take_err<E: std::error::Error>(_: &E) {}
290    let e = PostError::Processing("x".to_string());
291    take_err(&e);
292  }
293}