Skip to main content

latexml_post/
latex_images.rs

1//! LaTeX-based image generation processor.
2//!
3//! Port of `LaTeXML::Post::LaTeXImages` (539 lines of Perl).
4//! Base class for processors that generate images by running LaTeX + dvipng/dvips
5//! on extracted TeX fragments. Used by MathImages and PictureImages.
6//!
7//! Pipeline:
8//! 1. Collect TeX fragments from document nodes via `extractTeX()`
9//! 2. Deduplicate: same TeX → same image (keyed by processor+type+tex)
10//! 3. Check cache for previously generated images
11//! 4. Generate a LaTeX document with all pending fragments
12//! 5. Run `latex` to produce DVI
13//! 6. Run `dvipng`/`dvips`/`dvisvgm` to produce individual images
14//! 7. Parse dimensions from LaTeX log (LXIMAGE lines)
15//! 8. Optionally convert/crop via ImageMagick
16//! 9. Store results in cache; set node attributes
17
18use libxml::tree::Node;
19use rustc_hash::FxHashMap as HashMap;
20
21use crate::{
22  document::PostDocument,
23  processor::{PostError, ProcessResult, Processor, find_documentclass_and_packages},
24};
25
26/// DVI-to-image conversion method.
27#[derive(Debug, Clone)]
28pub enum DviMethod {
29  /// dvipng (fast, PNG only)
30  DviPng,
31  /// dvisvgm (SVG output)
32  DviSvgm,
33  /// dvips + ImageMagick (general purpose, slow)
34  Dvips,
35}
36
37/// A pending image entry to be generated.
38#[derive(Debug)]
39struct ImageEntry {
40  /// The TeX source fragment.
41  tex:   String,
42  /// Cache key.
43  key:   String,
44  /// Nodes that reference this image.
45  nodes: Vec<Node>,
46  /// Desired destination paths.
47  dests: Vec<String>,
48}
49
50/// LaTeX image generation processor.
51///
52/// Port of `LaTeXML::Post::LaTeXImages`.
53pub struct LaTeXImages {
54  name:               String,
55  resource_directory: String,
56  resource_prefix:    String,
57  image_type:         String,
58  dvi_method:         DviMethod,
59  magnification:      f64,
60  max_width:          u32,
61  dpi:                u32,
62  background:         String,
63  padding:            u32,
64  clipping_fudge:     u32,
65  clipping_rule:      f64,
66}
67
68impl LaTeXImages {
69  pub fn new(resource_directory: &str, resource_prefix: &str, image_type: &str) -> Self {
70    let dvi_method = match image_type {
71      "svg" => DviMethod::DviSvgm,
72      "png" => DviMethod::DviPng,
73      _ => DviMethod::Dvips,
74    };
75
76    LaTeXImages {
77      name: "LaTeXImages".to_string(),
78      resource_directory: resource_directory.to_string(),
79      resource_prefix: resource_prefix.to_string(),
80      image_type: image_type.to_string(),
81      dvi_method,
82      magnification: 1.33333,
83      max_width: 800,
84      dpi: 100,
85      background: "#FFFFFF".to_string(),
86      padding: 2,
87      clipping_fudge: 3,
88      clipping_rule: 0.90,
89    }
90  }
91
92  /// Clean a TeX string for image generation.
93  ///
94  /// Port of `LaTeXImages::cleanTeX`.
95  pub fn clean_tex(tex: &str) -> String {
96    let mut s = tex.to_string();
97    let mut style = String::new();
98
99    // Save leading math style
100    for prefix in &[
101      "\\displaystyle",
102      "\\textstyle",
103      "\\scriptstyle",
104      "\\scriptscriptstyle",
105    ] {
106      if let Some(rest) = s.trim_start().strip_prefix(prefix) {
107        style = prefix.to_string();
108        s = rest.to_string();
109        break;
110      }
111    }
112
113    // Trim leading/trailing TeX spacing commands
114    let spacing_re = regex::Regex::new(r"^(?:\\[,!>;:/ ]|\\ )*").unwrap();
115    s = spacing_re.replace(&s, "").to_string();
116    let trailing_re = regex::Regex::new(r"(?:\\[,!>;:/ ]|\\ )*$").unwrap();
117    s = trailing_re.replace(&s, "").to_string();
118
119    // Strip comments (but not escaped %)
120    // Note: Rust regex doesn't support lookbehinds, so we use a simple approach
121    let comment_re = regex::Regex::new(r"([^\\])%[^\n]*\n").unwrap();
122    s = comment_re.replace_all(&s, "$1").to_string();
123
124    if !style.is_empty() {
125      format!("{} {}", style, s)
126    } else {
127      s
128    }
129  }
130
131  /// Set the image attributes on a node.
132  ///
133  /// Port of `LaTeXImages::setTeXImage`.
134  pub fn set_tex_image(node: &mut Node, path: &str, width: u32, height: u32, depth: Option<u32>) {
135    node.set_attribute("imagesrc", path).ok();
136    node.set_attribute("imagewidth", &width.to_string()).ok();
137    node.set_attribute("imageheight", &height.to_string()).ok();
138    if let Some(d) = depth {
139      node.set_attribute("imagedepth", &d.to_string()).ok();
140    }
141  }
142
143  /// Generate images for the given nodes.
144  ///
145  /// Port of `LaTeXImages::generateImages`.
146  /// This is the main pipeline entry point.
147  pub fn generate_images(
148    &self,
149    doc: &mut PostDocument,
150    nodes: &[Node],
151    extract_tex: &dyn Fn(&PostDocument, &Node) -> Option<String>,
152  ) -> Result<(), PostError> {
153    // Step 1: Collect unique TeX strings
154    let mut table: HashMap<String, ImageEntry> = HashMap::default();
155    let mut n_total = 0u32;
156
157    for node in nodes {
158      let tex = match extract_tex(doc, node) {
159        Some(t) if !t.trim().is_empty() => t,
160        _ => continue,
161      };
162      n_total += 1;
163
164      let key = format!("{}:{}:{}", self.name, self.image_type, tex);
165      let entry = table.entry(key.clone()).or_insert_with(|| ImageEntry {
166        tex:   tex.clone(),
167        key:   key.clone(),
168        nodes: Vec::new(),
169        dests: Vec::new(),
170      });
171      entry.nodes.push(node.clone());
172    }
173
174    let n_unique = table.len();
175    if n_unique == 0 {
176      return Ok(());
177    }
178
179    // Step 2: Check cache for already-generated images
180    let mut pending = Vec::new();
181    for key in table.keys() {
182      if let Some(cached) = doc.cache_lookup(key) {
183        if cached.contains(';') {
184          continue; // Already cached
185        }
186      }
187      pending.push(key.clone());
188    }
189
190    Info!(
191      "latex_images",
192      "count",
193      "LaTeXImages: {} total, {} unique, {} pending",
194      n_total,
195      n_unique,
196      pending.len()
197    );
198
199    if !pending.is_empty() {
200      // Step 3: Generate LaTeX source
201      let (preamble, body_prefix) = self.pre_preamble(doc);
202      let tex_body = self.generate_tex_document(
203        &preamble,
204        &body_prefix,
205        &pending
206          .iter()
207          .map(|k| table[k].tex.as_str())
208          .collect::<Vec<_>>(),
209      );
210
211      Info!(
212        "latex_images",
213        "generate",
214        "LaTeXImages: generated LaTeX document ({} bytes)",
215        tex_body.len()
216      );
217      log::debug!(
218        target: "latex_images:run",
219        "Would run: latex + {} to produce {} images",
220        match self.dvi_method {
221          DviMethod::DviPng => "dvipng",
222          DviMethod::DviSvgm => "dvisvgm",
223          DviMethod::Dvips => "dvips + convert",
224        },
225        pending.len()
226      );
227
228      // Steps 4-8: Would run external commands here
229      // For now, log the intent
230    }
231
232    // Step 9: Apply cached results to nodes
233    for entry in table.values() {
234      if let Some(cached) = doc.cache_lookup(&entry.key) {
235        let parts: Vec<&str> = cached.split(';').collect();
236        if parts.len() == 4 {
237          let (image, width, height, depth) = (parts[0], parts[1], parts[2], parts[3]);
238          let w: u32 = width.parse().unwrap_or(0);
239          let h: u32 = height.parse().unwrap_or(0);
240          let d: u32 = depth.parse().unwrap_or(0);
241          for node in &entry.nodes {
242            let mut node_mut = node.clone();
243            Self::set_tex_image(&mut node_mut, image, w, h, Some(d));
244          }
245        }
246      }
247    }
248
249    Ok(())
250  }
251
252  /// Generate the LaTeX preamble.
253  ///
254  /// Port of `LaTeXImages::pre_preamble`.
255  fn pre_preamble(&self, doc: &PostDocument) -> (String, String) {
256    let (class_info, packages) = find_documentclass_and_packages(doc);
257    let class = &class_info.name;
258    let class_options = &class_info.options;
259    let oldstyle = class_info.oldstyle.is_some();
260    let document_command = if oldstyle {
261      "\\documentstyle"
262    } else {
263      "\\documentclass"
264    };
265
266    let mut pkg_lines = String::new();
267    for pkg in &packages {
268      if oldstyle {
269        pkg_lines.push_str(&format!("\\RequirePackage{{{}}}\n", pkg.name));
270      } else if pkg.name == "english" {
271        pkg_lines.push_str("\\usepackage[english]{babel}\n");
272      } else if pkg.options.is_empty() {
273        pkg_lines.push_str(&format!("\\usepackage{{{}}}\n", pkg.name));
274      } else {
275        pkg_lines.push_str(&format!("\\usepackage[{}]{{{}}}\n", pkg.options, pkg.name));
276      }
277    }
278
279    let pts_per_pixel = 72.27 / self.dpi as f64 / self.magnification;
280    let w = (self.max_width as f64 * pts_per_pixel).ceil() as u32;
281    let gap = (self.padding + self.clipping_fudge) as f64 * pts_per_pixel;
282    let th = match self.dvi_method {
283      DviMethod::DviSvgm => 0.0,
284      _ => self.clipping_rule * pts_per_pixel,
285    };
286
287    let preamble = format!(
288      r"\batchmode
289\def\inlatexml{{true}}
290{document_command}[{class_options}]{{{class}}}
291{pkg_lines}
292\makeatletter
293\setlength{{\hoffset}}{{0pt}}\setlength{{\voffset}}{{0pt}}
294\setlength{{\textwidth}}{{{w}pt}}
295\newcount\lxImageNumber\lxImageNumber=0\relax
296\newbox\lxImageBox
297\newdimen\lxImageBoxSep
298\setlength\lxImageBoxSep{{{gap:.4}pt}}
299\newdimen\lxImageBoxRule
300\setlength\lxImageBoxRule{{{th:.4}pt}}
301\def\lxShowImage{{%
302  \global\advance\lxImageNumber1\relax
303  \@tempdima\wd\lxImageBox
304  \advance\@tempdima-\lxImageBoxSep
305  \advance\@tempdima-\lxImageBoxSep
306  \typeout{{LXIMAGE \the\lxImageNumber\space= \the\@tempdima\space x \the\ht\lxImageBox\space + \the\dp\lxImageBox}}%
307  \@tempdima\lxImageBoxRule
308  \advance\@tempdima\lxImageBoxSep
309  \advance\@tempdima\dp\lxImageBox
310  \hbox{{\lower\@tempdima\hbox{{\vbox{{%
311    \hrule\@height\lxImageBoxRule%
312    \hbox{{\vrule\@width\lxImageBoxRule%
313      \vbox{{\vskip\lxImageBoxSep\box\lxImageBox\vskip\lxImageBoxSep}}%
314      \vrule\@width\lxImageBoxRule}}%
315    \hrule\@height\lxImageBoxRule}}}}}}%
316}}%
317\def\lxBeginImage{{\setbox\lxImageBox\hbox\bgroup\color@begingroup\kern\lxImageBoxSep}}
318\def\lxEndImage{{\kern\lxImageBoxSep\color@endgroup\egroup}}
319\makeatother",
320      document_command = document_command,
321      class_options = class_options,
322      class = class,
323      pkg_lines = pkg_lines,
324      w = w,
325      gap = gap,
326      th = th
327    );
328
329    // Body prefix: neutralize page styles, captions, citations
330    let body_prefix = "\\makeatletter\\thispagestyle{empty}\\pagestyle{empty}\n\
331       \\let\\@@toccaption\\@gobble\n\
332       \\let\\@@caption\\@gobble\n\
333       \\let\\cite\\@gobble\n\
334       \\def\\@@bibref#1#2#3#4{}\n\
335       \\renewcommand{\\cite}[2][]{}\n\
336       \\title{}\\date{}\n\
337       \\makeatother\n"
338      .to_string();
339
340    (preamble, body_prefix)
341  }
342
343  /// Build the complete LaTeX document.
344  fn generate_tex_document(
345    &self,
346    preamble: &str,
347    body_prefix: &str,
348    tex_fragments: &[&str],
349  ) -> String {
350    let mut doc = String::new();
351    doc.push_str(preamble);
352    doc.push_str("\n\\begin{document}\n");
353    doc.push_str(body_prefix);
354    for tex in tex_fragments {
355      doc.push_str(tex);
356      doc.push_str("\\clearpage\n");
357    }
358    doc.push_str("\\end{document}\n");
359    doc
360  }
361
362  /// Get the DVI command string.
363  pub fn dvi_command(&self) -> String {
364    let mag = (self.magnification * 1000.0) as u32;
365    let dpi = (self.dpi as f64 * self.magnification) as u32;
366    match self.dvi_method {
367      DviMethod::DviSvgm => format!(
368        "dvisvgm --page=1- --bbox=1pt --scale={} --no-fonts -o imgx-%03p",
369        self.magnification
370      ),
371      DviMethod::DviPng => format!(
372        "dvipng -bg Transparent -T tight -q -D{} -o imgx-%03d.png",
373        dpi
374      ),
375      DviMethod::Dvips => format!("dvips -q -S1 -i -E -j0 -x{} -o imgx", mag),
376    }
377  }
378
379  /// Parse LXIMAGE dimension lines from a LaTeX log file.
380  ///
381  /// Port of the log parsing in `generateImages`.
382  /// Each line has format: `LXIMAGE N = Wpt x Hpt + Dpt`
383  /// Returns a vector indexed by image number: (width_pt, height_pt, depth_pt).
384  pub fn parse_log_dimensions(log_content: &str) -> Vec<Option<(f64, f64, f64)>> {
385    let re = regex::Regex::new(
386      r"^\s*LXIMAGE\s+(\d+)\s*=\s*([\+\-\d\.]+)pt\s*x\s*([\+\-\d\.]+)pt\s*\+\s*([\+\-\d\.]+)pt\s*$",
387    )
388    .unwrap();
389
390    let mut dimensions: Vec<Option<(f64, f64, f64)>> = Vec::new();
391
392    for line in log_content.lines() {
393      if let Some(caps) = re.captures(line) {
394        let index: usize = caps[1].parse().unwrap_or(0);
395        let width: f64 = caps[2].parse().unwrap_or(0.0);
396        let height: f64 = caps[3].parse().unwrap_or(0.0);
397        let depth: f64 = caps[4].parse().unwrap_or(0.0);
398
399        // Ensure vector is large enough
400        while dimensions.len() <= index {
401          dimensions.push(None);
402        }
403        dimensions[index] = Some((width, height, depth));
404      }
405    }
406
407    dimensions
408  }
409
410  /// Compute the output filename for a given image index.
411  ///
412  /// Port of `sprintf($$self{dvicmd_output_name}, $index)`.
413  pub fn output_filename(&self, index: u32) -> String {
414    match self.dvi_method {
415      DviMethod::DviSvgm => format!("imgx-{:03}.svg", index),
416      DviMethod::DviPng => format!("imgx-{:03}.png", index),
417      DviMethod::Dvips => format!("imgx{:03}", index),
418    }
419  }
420
421  /// Get the output image type for the DVI method.
422  pub fn output_type(&self) -> &str {
423    match self.dvi_method {
424      DviMethod::DviSvgm => "svg",
425      DviMethod::DviPng => "png32",
426      DviMethod::Dvips => "eps",
427    }
428  }
429
430  /// Whether the DVI output needs frame-based cropping.
431  pub fn needs_frame_output(&self) -> bool {
432    match self.dvi_method {
433      DviMethod::DviSvgm => false,
434      DviMethod::DviPng | DviMethod::Dvips => true,
435    }
436  }
437
438  /// Convert TeX points to pixels at the configured DPI and magnification.
439  pub fn pt_to_pixels(&self, pt: f64) -> f64 { pt * self.magnification * self.dpi as f64 / 72.27 }
440
441  /// Check whether this processor has the needed external tools.
442  ///
443  /// Port of `LaTeXImages::canProcess`.
444  /// Checks for:
445  /// - Image processing library (ImageMagick or similar)
446  /// - LaTeX command availability
447  pub fn can_process(&self) -> bool {
448    // Check for latex command
449    let latex_available = std::process::Command::new("latex")
450      .arg("--version")
451      .output()
452      .is_ok();
453    if !latex_available {
454      // Perl LaTeXImages.pm L134: Error('expected', $LATEXCMD, undef,
455      //   "No latex command ($LATEXCMD) found; Skipping.", ...)
456      Error!(
457        "expected",
458        "latex",
459        "No latex command found; image generation will be skipped"
460      );
461      return false;
462    }
463    // Check for DVI converter
464    let dvi_cmd = match self.dvi_method {
465      DviMethod::DviPng => "dvipng",
466      DviMethod::DviSvgm => "dvisvgm",
467      DviMethod::Dvips => "dvips",
468    };
469    let dvi_available = std::process::Command::new(dvi_cmd)
470      .arg("--version")
471      .output()
472      .is_ok();
473    if !dvi_available {
474      // Perl LaTeXImages.pm dvi-converter check: Error('expected',
475      //   $$self{dvicmd}, …) (parallel to the latex check at L134).
476      Error!(
477        "expected",
478        dvi_cmd,
479        "No {} command found; image generation will be skipped",
480        dvi_cmd
481      );
482      return false;
483    }
484    true
485  }
486
487  /// Convert a DVI-output image (EPS/PNG) to final format with cropping.
488  ///
489  /// Port of `LaTeXImages::convert_image`.
490  /// For dvipng output: already cropped, just copy.
491  /// For dvips output (EPS): needs ImageMagick conversion + trim.
492  /// For dvisvgm output (SVG): already in final format.
493  ///
494  /// Returns (width, height) in pixels, or None on failure.
495  pub fn convert_image(&self, src: &str, dest: &str) -> Option<(u32, u32)> {
496    match self.dvi_method {
497      DviMethod::DviSvgm => {
498        // SVG: just copy
499        if let Err(e) = std::fs::copy(src, dest) {
500          // Perl LaTeXImages.pm I/O failure: Error('I/O', $dest, …)
501          Error!("I/O", dest, "Failed to copy {} to {}: {}", src, dest, e);
502          return None;
503        }
504        // SVG dimensions from file would need XML parsing
505        Some((0, 0))
506      },
507      DviMethod::DviPng => {
508        // PNG: already cropped by dvipng -T tight
509        if let Err(e) = std::fs::copy(src, dest) {
510          Error!("I/O", dest, "Failed to copy {} to {}: {}", src, dest, e);
511          return None;
512        }
513        // Would read PNG dimensions from file header
514        Some((0, 0))
515      },
516      DviMethod::Dvips => {
517        // EPS: needs ImageMagick conversion
518        // Would run: convert -density DPI -trim src dest
519        Info!(
520          "latex_images",
521          "convert",
522          "Would convert EPS {} to {} via ImageMagick",
523          src,
524          dest
525        );
526        // Shave off clipping fudge + rule
527        let fudge = (self.clipping_fudge as f64 + self.clipping_rule).round() as u32;
528        log::debug!(target: "latex_images:shave", "  Shave: {}px from each edge", fudge);
529        Some((0, 0))
530      },
531    }
532  }
533
534  /// Compute pixels-per-point for dimension conversions.
535  pub fn pixels_per_pt(&self) -> f64 { self.magnification * self.dpi as f64 / 72.27 }
536}
537
538impl Processor for LaTeXImages {
539  fn get_name(&self) -> &str { &self.name }
540
541  fn resource_directory(&self) -> Option<&str> { Some(&self.resource_directory) }
542
543  fn resource_prefix(&self) -> Option<&str> { Some(&self.resource_prefix) }
544
545  fn process(&mut self, doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
546    Info!(
547      "latex_images",
548      "process",
549      "LaTeXImages: {} nodes to process",
550      nodes.len()
551    );
552    Ok(vec![doc])
553  }
554}