1use 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#[derive(Debug, Clone)]
28pub enum DviMethod {
29 DviPng,
31 DviSvgm,
33 Dvips,
35}
36
37#[derive(Debug)]
39struct ImageEntry {
40 tex: String,
42 key: String,
44 nodes: Vec<Node>,
46 dests: Vec<String>,
48}
49
50pub 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 pub fn clean_tex(tex: &str) -> String {
96 let mut s = tex.to_string();
97 let mut style = String::new();
98
99 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 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 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 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 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 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 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; }
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 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 }
231
232 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 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 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 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 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 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 while dimensions.len() <= index {
401 dimensions.push(None);
402 }
403 dimensions[index] = Some((width, height, depth));
404 }
405 }
406
407 dimensions
408 }
409
410 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 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 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 pub fn pt_to_pixels(&self, pt: f64) -> f64 { pt * self.magnification * self.dpi as f64 / 72.27 }
440
441 pub fn can_process(&self) -> bool {
448 let latex_available = std::process::Command::new("latex")
450 .arg("--version")
451 .output()
452 .is_ok();
453 if !latex_available {
454 Error!(
457 "expected",
458 "latex",
459 "No latex command found; image generation will be skipped"
460 );
461 return false;
462 }
463 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 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 pub fn convert_image(&self, src: &str, dest: &str) -> Option<(u32, u32)> {
496 match self.dvi_method {
497 DviMethod::DviSvgm => {
498 if let Err(e) = std::fs::copy(src, dest) {
500 Error!("I/O", dest, "Failed to copy {} to {}: {}", src, dest, e);
502 return None;
503 }
504 Some((0, 0))
506 },
507 DviMethod::DviPng => {
508 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 Some((0, 0))
515 },
516 DviMethod::Dvips => {
517 Info!(
520 "latex_images",
521 "convert",
522 "Would convert EPS {} to {} via ImageMagick",
523 src,
524 dest
525 );
526 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 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}