tikz-gallery-generator

Custum build of stapix for tikz.pablopie.xyz

Commit
50d04f8709483d0fd21ad90a06532a720e44ec3f
Parent
04d70d11e8ddf12551f6b736fdcc31c7e69db199
Author
Pablo <pablo-pie@riseup.net>
Date

Integrated the MuPDF rendering code into the application

Diffstats

8 files changed, 512 insertions, 240 deletions

Status Name Changes Insertions Deletions
Modified Cargo.lock 2 files changed 159 22
Modified Cargo.toml 2 files changed 1 2
Modified mupdf-sys/src/lib.rs 2 files changed 0 22
Modified mupdf-sys/src/wrapper.c 2 files changed 25 25
Modified src/image.rs 2 files changed 2 2
Modified src/log.rs 2 files changed 2 2
Modified src/main.rs 2 files changed 212 165
Added src/mupdf.rs 1 file changed 111 0
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,6 +3,39 @@
 version = 4
 
 [[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
 name = "cc"
 version = "1.0.83"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -13,6 +46,38 @@ dependencies = [
 ]
 
 [[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
 name = "equivalent"
 version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -31,12 +96,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
 
 [[package]]
-name = "hermit-abi"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
-
-[[package]]
 name = "indexmap"
 version = "2.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -47,6 +106,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
 name = "itoa"
 version = "1.0.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -68,6 +136,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
 
 [[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
 name = "libwebp-sys"
 version = "0.14.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -79,13 +157,35 @@ dependencies = [
 ]
 
 [[package]]
-name = "num_cpus"
-version = "1.16.0"
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mupdf-sys"
+version = "1.0.0"
 dependencies = [
- "hermit-abi",
- "libc",
+ "bindgen",
+ "cc",
+ "pkg-config",
+ "regex",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
 ]
 
 [[package]]
@@ -113,6 +213,41 @@ dependencies = [
 ]
 
 [[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
 name = "ryu"
 version = "1.0.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -152,6 +287,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
 name = "simd-adler32"
 version = "0.3.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -169,23 +310,13 @@ dependencies = [
 ]
 
 [[package]]
-name = "threadpool"
-version = "1.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
-dependencies = [
- "num_cpus",
-]
-
-[[package]]
 name = "tikz_gallery_generator"
 version = "1.0.0"
 dependencies = [
  "libwebp-sys",
- "num_cpus",
+ "mupdf-sys",
  "serde",
  "serde_yaml",
- "threadpool",
  "zune-core",
  "zune-jpeg",
  "zune-png",
@@ -204,6 +335,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa"
 
 [[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
 name = "zune-core"
 version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -13,8 +13,7 @@ libwebp-sys = "0.14"
 zune-core = "0.5"
 zune-jpeg = "0.5"
 zune-png = "0.5"
-threadpool = "1.8.1"
-num_cpus = "1.16.0"
+mupdf-sys = { path = "./mupdf-sys/" }
 
 [profile.release]
 debug = true
diff --git a/mupdf-sys/src/lib.rs b/mupdf-sys/src/lib.rs
@@ -15,25 +15,3 @@ pub unsafe fn fz_new_context(
   let version = FZ_VERSION.as_ptr() as *const i8;
   fz_new_context_imp(alloc, locks, max_store, version)
 }
-
-/// Wrapper for [`fz_new_context`].
-pub unsafe fn mupdf_new_context() -> *mut fz_context {
-  use core::ptr;
-
-  let ctx = fz_new_context(
-    ptr::null(),
-    ptr::null(),
-    FZ_STORE_DEFAULT as usize,
-  );
-  if ctx.is_null() { return ctx; }
-
-  // SAFETY: this should really be wrapped with fz_try, but it can only fail if
-  //         ctx does not contain a document handler list (?) or the list is
-  //         already full. seems safe to assume fz_new_documment will handle
-  //         us a sane ctx
-  fz_register_document_handlers(ctx);
-
-  fz_set_warning_callback(ctx, None, ptr::null_mut());
-  fz_set_error_callback(ctx, None, ptr::null_mut());
-  ctx
-}
diff --git a/mupdf-sys/src/wrapper.c b/mupdf-sys/src/wrapper.c
@@ -9,14 +9,13 @@ typedef struct {
   enum fz_error_type type;
 } mupdf_result_t;
 
-fz_document* mupdf_open_doc_from_bytes(fz_context* ctx,
-                                       uint8_t* bytes, size_t size,
-                                       mupdf_result_t* result)
+mupdf_result_t mupdf_open_doc_from_bytes(
+  fz_context*   ctx,
+  uint8_t*      bytes, size_t    size,
+  fz_document** doc,   uint32_t* page_count)
 {
-  fz_document* doc    = NULL;
-  fz_stream*   stream = NULL;
-
-  memset(result, 0, sizeof *result);
+  mupdf_result_t result = {0};
+  fz_stream*     stream = NULL;
 
   fz_try(ctx)
   {
@@ -26,7 +25,10 @@ fz_document* mupdf_open_doc_from_bytes(fz_context* ctx,
     buf.len  = size;
 
     stream = fz_open_buffer(ctx, &buf);
-    doc    = (fz_document*)pdf_open_document_with_stream(ctx, stream);
+    *doc   = (fz_document*)pdf_open_document_with_stream(ctx, stream);
+    if (*doc == NULL) break;
+
+    *page_count = fz_count_pages(ctx, *doc);
   }
   fz_always(ctx)
   {
@@ -34,39 +36,38 @@ fz_document* mupdf_open_doc_from_bytes(fz_context* ctx,
   }
   fz_catch(ctx)
   {
-    result->err_msg = fz_caught_message(ctx);
-    result->type    = fz_caught(ctx);
-    doc = NULL;
+    result.err_msg = fz_caught_message(ctx);
+    result.type    = fz_caught(ctx);
   }
 
-  return doc;
+  return result;
 }
 
-fz_buffer* mupdf_page_to_svg(fz_context* ctx, fz_document* doc,
-                             size_t page_number, mupdf_result_t* result)
+mupdf_result_t mupdf_page_to_svg(fz_context* ctx, fz_document* doc,
+                                 size_t page_number, fz_buffer** buf)
 {
-  fz_buffer* buf  = NULL;
+  mupdf_result_t result = {0};
   fz_page*   page = NULL;
   fz_output* out  = NULL;
   fz_device* dev  = NULL;
 
-  memset(result, 0, sizeof *result);
-
   fz_try(ctx)
   {
-    // TODO: assert page != NULL
     // TODO: page bound checks when running in debug mode?
     page = fz_load_page(ctx, doc, page_number);
+    if (page == NULL) break; // skip to fz_always
 
     fz_rect bbox = fz_bound_page(ctx, page);
     float width  = bbox.x1 - bbox.x0,
           height = bbox.y1 - bbox.y0;
 
-    buf = fz_new_buffer(ctx, 1024);            // TODO: assert buf != NULL
-    out = fz_new_output_with_buffer(ctx, buf); // TODO: assert out != NULL
+    *buf = fz_new_buffer(ctx, 1024);
+    if (*buf == NULL) break; // skip to fz_always
+    out  = fz_new_output_with_buffer(ctx, *buf);
+    if (out == NULL) break;  // skip to fz_always
 
-    // TODO: assert dev != NULL
     dev = fz_new_svg_device(ctx, out, width, height, FZ_SVG_TEXT_AS_PATH, 0);
+    if (dev == NULL) break; // skip to fz_always
 
     fz_run_page(ctx, page, dev, fz_identity, NULL);
     fz_close_device(ctx, dev);
@@ -80,10 +81,9 @@ fz_buffer* mupdf_page_to_svg(fz_context* ctx, fz_document* doc,
   }
   fz_catch(ctx)
   {
-    result->err_msg = fz_caught_message(ctx);
-    result->type    = fz_caught(ctx);
-    buf = NULL;
+    result.err_msg = fz_caught_message(ctx);
+    result.type    = fz_caught(ctx);
   }
 
-  return buf;
+  return result;
 }
diff --git a/src/image.rs b/src/image.rs
@@ -43,8 +43,8 @@ use crate::create_file;
 
 #[derive(Debug)]
 pub struct Image {
-  width:     usize,
-  height:    usize,
+  width:  usize,
+  height: usize,
 
   pixels: PixelBuffer,
 }
diff --git a/src/log.rs b/src/log.rs
@@ -77,8 +77,8 @@ pub fn version(program_name: &str) {
 }
 
 pub fn usage(program: &str) {
-  use crate::{FULL_BUILD_OPT, N_THREADS_OPT};
-  println!("     {BOLD_YELLOW}Usage{RESET} {program} [{FULL_BUILD_OPT}] [{N_THREADS_OPT} <jobs>] <config.yml>");
+  use crate::FULL_BUILD_OPT;
+  println!("     {BOLD_YELLOW}Usage{RESET} {program} [{FULL_BUILD_OPT}] <config.yml>");
 }
 
 pub fn usage_config() {
diff --git a/src/main.rs b/src/main.rs
@@ -1,15 +1,12 @@
 use std::{
-  cmp,
   env,
   fmt::{self, Display},
   fs::{self, File},
-  io::{self, Write},
+  io::{self, Read, Write},
   path::{Path, PathBuf},
-  process::{ExitCode, Command},
-  sync::mpsc,
+  process::ExitCode,
   time::Instant,
 };
-use threadpool::ThreadPool;
 
 use gallery_entry::{GalleryEntry, FileFormat, LicenseType};
 use escape::Escaped;
@@ -17,9 +14,16 @@ use escape::Escaped;
 #[macro_use]
 mod log;
 mod image;
+mod mupdf;
 mod gallery_entry;
 mod escape;
 
+struct RenderingJob<'a> {
+  pub path:       &'a Path,
+  pub file_name:  &'a str,
+  pub thumb_path: PathBuf,
+}
+
 /// A wrapper for displaying the path for the thumbnail of a given path
 pub struct ThumbPath<'a>(pub &'a GalleryEntry);
 
@@ -27,8 +31,6 @@ pub struct ThumbPath<'a>(pub &'a GalleryEntry);
 pub struct HtmlFileName<'a>(pub &'a str);
 
 const FULL_BUILD_OPT: &str = "-B";
-const N_THREADS_OPT:  &str = "-j";
-const BOTH_OPTS:      &str = "-Bj";
 
 const TARGET_PATH: &str = "./site";
 const PAGES_PATH:  &str = "figures";
@@ -56,39 +58,10 @@ fn main() -> ExitCode {
   };
 
   let mut full_build = false;
-  let total_cores = num_cpus::get();
-  let mut num_cores = total_cores - 1;
-  while let Some(arg) = args.next() {
-    let mut is_valid_arg = false;
-
-    if arg == FULL_BUILD_OPT || arg == BOTH_OPTS {
+  for arg in args {
+    if arg == FULL_BUILD_OPT {
       full_build = true;
-      is_valid_arg = true;
-    }
-
-    if arg == N_THREADS_OPT || arg == BOTH_OPTS {
-      is_valid_arg = true;
-
-      let val = match args.next() {
-        Some(val) => val,
-        None      => {
-          errorln!("Expected one more argument, got none");
-          log::usage(&program);
-          return ExitCode::FAILURE;
-        }
-      };
-
-      match val.parse() {
-        Ok(val) => num_cores = val,
-        Err(_)  => {
-          errorln!("Expected a number, got {val:?}");
-          log::usage(&program);
-          return ExitCode::FAILURE;
-        }
-      }
-    }
-
-    if !is_valid_arg {
+    } else {
       if arg.starts_with("-") {
         errorln!("Unknown option: {arg:?}");
       } else {
@@ -111,7 +84,7 @@ fn main() -> ExitCode {
       log::usage_config();
       return ExitCode::FAILURE;
     }
-    Ok(Ok(pics)) => if render_gallery(pics, full_build, num_cores, total_cores).is_err() {
+    Ok(Ok(pics)) => if render_gallery(pics, full_build).is_err() {
       return ExitCode::FAILURE;
     },
   }
@@ -123,18 +96,20 @@ fn main() -> ExitCode {
 fn render_gallery(
   pics: Vec<GalleryEntry>,
   full_build: bool,
-  num_cores: usize,
-  total_cores: usize,
 ) -> Result<(), ()> {
   struct Job {
     pic_id:     usize,
     image_path: PathBuf,
-    thumb_path: PathBuf,
     page_path:  PathBuf,
   }
 
   let start = Instant::now();
 
+  let mut tex_jobs  = Vec::with_capacity(pics.len());
+  let mut svg_jobs  = Vec::new();
+  let mut png_jobs  = Vec::new();
+  let mut jpeg_jobs = Vec::new();
+
   let mut skipped = 0;
   let mut jobs    = Vec::with_capacity(pics.len());
   for (pic_id, pic) in pics.iter().enumerate() {
@@ -142,8 +117,6 @@ fn render_gallery(
     image_path.push(IMAGES_PATH);
     image_path.push(&pic.file_name);
 
-    let thumb_path: PathBuf = ThumbPath(pic).into();
-
     let mut page_path = PathBuf::from(TARGET_PATH);
     page_path.push(PAGES_PATH);
     page_path.push(format!("{}", HtmlFileName(&pic.file_name)));
@@ -156,9 +129,39 @@ fn render_gallery(
     }
 
     if full_build || needs_update(pic, &image_path)
-                  || needs_update(pic, &thumb_path)
                   || needs_update(pic, &page_path) {
-      jobs.push(Job { pic_id, image_path, thumb_path, page_path, });
+      jobs.push(Job { pic_id, image_path, page_path, });
+
+      match pic.file_format {
+        FileFormat::TeX => {
+          tex_jobs.push(RenderingJob {
+            path:      &pic.path,
+            file_name: &pic.file_name,
+            thumb_path: format!("{TARGET_PATH}/{}.svg", pic.file_name).into(),
+          });
+        }
+        FileFormat::Svg => {
+          svg_jobs.push(RenderingJob {
+            path:      &pic.path,
+            file_name: &pic.file_name,
+            thumb_path: format!("{TARGET_PATH}/{}", pic.file_name).into(),
+          });
+        }
+        FileFormat::Png => {
+          png_jobs.push(RenderingJob {
+            path:      &pic.path,
+            file_name: &pic.file_name,
+            thumb_path: format!("{TARGET_PATH}/{}.webp", pic.file_name).into(),
+          });
+        }
+        FileFormat::Jpeg => {
+          jpeg_jobs.push(RenderingJob {
+            path:      &pic.path,
+            file_name: &pic.file_name,
+            thumb_path: format!("{TARGET_PATH}/{}.webp", pic.file_name).into(),
+          });
+        }
+      }
     } else {
       skipped += 1;
     }
@@ -196,50 +199,30 @@ fn render_gallery(
   infoln!("Copied image files to the target directory");
 
   // ========================================================================
-  let num_cores = cmp::min(num_cores, jobs.len());
-
-  // NOTE: only spawn the threads if necessary
-  if num_cores > 1 {
-    infoln!("Rendering thumbnails... (using {num_cores}/{total_cores} cores)");
-    let rendering_pool = ThreadPool::with_name(
-      String::from("thumbnails renderer"),
-      num_cores,
-    );
-    let (sender, reciever) = mpsc::channel();
-
-    for Job { pic_id, thumb_path, .. } in &jobs {
-      let pic_id = *pic_id;
-      let thumb_path = thumb_path.clone();
-      let pic = pics[pic_id].clone();
-      let sender = sender.clone();
-
-      rendering_pool.execute(move || {
-        // NOTE: we need to send the picture id back so that the main thread
-        //       knows how to log the fact we finished rendering it
-        let _ = sender.send(
-          render_thumbnail(&pic, &thumb_path).map(|()| pic_id)
-        );
-      });
-    }
+  infoln!("Rendering thumbnails...");
 
-    for _ in 0..jobs.len() {
-      let msg = reciever.recv();
-      // propagate the panic to the main thread: reciever.recv should
-      // only fail if some of the rendering threads panicked
-      if msg.is_err() { panic!("rendering thread panicked!"); }
+  // TODO: log something while compiling the TeX?
+  render_tikz_thumbnails(&tex_jobs)?;
 
-      let pic_id = msg.unwrap()?;
-      let pic = &pics[pic_id];
-      log::job_finished(&pic.file_name);
-    }
-  } else {
-    infoln!("Rendering thumbnails... (using 1/{total_cores} core)");
-    for Job { pic_id, thumb_path, .. } in &jobs {
-      let pic = &pics[*pic_id];
+  for pic in svg_jobs {
+    let mut src_path = PathBuf::from(TARGET_PATH);
+    src_path.push(IMAGES_PATH);
+    src_path.push(pic.file_name);
 
-      render_thumbnail(pic, thumb_path)?;
-      log::job_finished(&pic.file_name);
-    }
+    copy(&src_path, &pic.thumb_path)?;
+    log::job_finished(&pic.file_name);
+  }
+
+  const TARGET_HEIGHT: usize = 500;
+  for pic in png_jobs {
+    let img = image::parse_png(pic.path, TARGET_HEIGHT)?;
+    image::encode_webp(&img, &pic.thumb_path)?;
+    log::job_finished(&pic.file_name);
+  }
+  for pic in jpeg_jobs {
+    let img = image::parse_jpeg(pic.path, TARGET_HEIGHT)?;
+    image::encode_webp(&img, &pic.thumb_path)?;
+    log::job_finished(&pic.file_name);
   }
 
   // ==========================================================================
@@ -433,67 +416,133 @@ fn write_license(f: &mut File) -> io::Result<()> {
   writeln!(f, "{}", LICENSE_COMMENT)
 }
 
-fn render_thumbnail(pic: &GalleryEntry, thumb_path: &Path) -> Result<(), ()> {
-  const TARGET_HEIGHT: usize = 500;
+fn render_tikz_thumbnails(pics: &[RenderingJob<'_>]) -> Result<(), ()> {
+  let mut tmp_dir = env::temp_dir();
+  tmp_dir.push(random_dir_name());
 
-  match pic.file_format {
-    FileFormat::TeX => {
-      // tikztosvg -o thumb_path
-      //           -p relsize
-      //           -p xfrac
-      //           -l matrix
-      //           -l patterns
-      //           -l shapes.geometric
-      //           -l arrows
-      //           -q
-      //           pic.path
-      let mut tikztosvg_cmd = Command::new("tikztosvg");
-      tikztosvg_cmd.arg("-o")
-        .arg(thumb_path)
-        .args([
-          "-p", "relsize",
-          "-p", "xfrac",
-          "-l", "matrix",
-          "-l", "patterns",
-          "-l", "shapes.geometric",
-          "-l", "arrows",
-          "-q",
-        ])
-        .arg(&pic.path);
-
-      let exit_code = tikztosvg_cmd
-        .status()
-        .map_err(|e| errorln!("Failed to run tikztosvg: {e}"))?;
-
-      if !exit_code.success() {
-        errorln!(
-          "Failed to run tikztosvg: {tikztosvg_cmd:?} returned exit code {exit_code}"
-        );
-        return Err(());
-      }
-    }
-    FileFormat::Svg => {
-      let mut src_path = PathBuf::from(TARGET_PATH);
-      src_path.push(IMAGES_PATH);
-      src_path.push(&pic.file_name);
+  if let Err(e) = fs::create_dir(&tmp_dir) {
+    errorln!("Could not create {tmp_dir:?}: {e}");
+    return Err(());
+  }
+
+  let result = render_impl(pics, &tmp_dir);
+  if let Err(e) = fs::remove_dir_all(&tmp_dir) {
+    errorln!("Could not delete {tmp_dir:?}: {e}");
+    return Err(());
+  }
+
+  return result;
 
-      copy(&src_path, thumb_path)?;
+  fn render_impl(pics: &[RenderingJob<'_>], tmp_dir: &Path) -> Result<(), ()> {
+    use std::process::{Command, Stdio};
+
+    const TEX_ENGINE:    &str = "lualatex";
+    const TEX_FILE_PATH: &str = "drawings.tex";
+    const PDF_FILE_PATH: &str = "drawings.pdf";
+
+    // ========================================================================
+    let mut tex_path = PathBuf::from(tmp_dir);
+    tex_path.push(TEX_FILE_PATH);
+
+    let mut tex_f = create_file(&tex_path).map_err(|_| ())?;
+
+    let tex_contents = generate_tex_contents(pics)?;
+    if let Err(e) = tex_f.write_all(tex_contents.as_bytes()) {
+      errorln!("Could not write to {tex_path:?}: {e}");
+      return Err(());
     }
-    FileFormat::Jpeg => {
-      // NOTE: even if the picture is no taller than TARGET_HEIGHT * 2, it is
-      //       faster to downsample and then encode
-      let img = image::parse_jpeg(&pic.path, TARGET_HEIGHT)?;
-      image::encode_webp(&img, thumb_path)?;
+    drop(tex_f);
+
+    let mut tex_cmd = Command::new(TEX_ENGINE);
+    tex_cmd
+      .arg("-halt-on-error")
+      .arg(format!("-output-directory={}", tmp_dir.to_string_lossy()))
+      .arg(&tex_path)
+      .stdout(Stdio::null())
+      .stderr(Stdio::null());
+
+    let exit_code = tex_cmd
+      .status()
+      .map_err(|e| errorln!("Failed to run {TEX_ENGINE}: {e}"))?;
+
+    if !exit_code.success() {
+      errorln!(
+        "Failed to run {TEX_ENGINE}: {tex_cmd:?} returned exit code {exit_code}"
+      );
+      return Err(());
     }
-    FileFormat::Png => {
-      // NOTE: even if the picture is no taller than TARGET_HEIGHT * 2, it is
-      //       faster to downsample and then encode
-      let img = image::parse_png(&pic.path, TARGET_HEIGHT)?;
-      image::encode_webp(&img, thumb_path)?;
+
+    // ========================================================================
+    let mut pdf_path = PathBuf::from(tmp_dir);
+    pdf_path.push(PDF_FILE_PATH);
+
+    let pdf_contents = fs::read(&pdf_path)
+      .map_err(|e| { errorln!("Could not read {pdf_path:?}: {e}") })?;
+
+    let pdf_doc = mupdf::Document::open_from_bytes(&pdf_contents)
+      .map_err(|e| errorln!("Could not parse {pdf_path:?}: {e}"))?;
+
+    for (page, pic) in pics.iter().enumerate() {
+      let buf = pdf_doc.render_page_to_svg(page)
+        .map_err(|e| {
+          errorln!("Could not render {:?} to SVG: {e}", pic.file_name)
+        })?;
+
+      let mut svg_f = create_file(&pic.thumb_path).map_err(|_| ())?;
+      if let Err(e) = svg_f.write_all(&buf) {
+        errorln!("Could not write to {:?}: {e}", pic.thumb_path);
+        return Err(());
+      }
+
+      log::job_finished(&pic.file_name);
     }
+
+    Ok(())
   }
+}
 
-  Ok(())
+fn generate_tex_contents(pics: &[RenderingJob<'_>]) -> Result<String, ()> {
+  use std::fmt::Write;
+
+  const LATEX_PACKAGES: &[&str] = &["tikz", "pgfplots", "amsmath", "amssymb"];
+  const TIKZ_LIBRARIES: &[&str] = &[
+    "matrix",
+    "patterns",
+    "shapes.geometric",
+    "arrows",
+  ];
+
+  let mut sb = String::with_capacity(1024);
+
+  let _ = writeln!(&mut sb, "\\documentclass[crop, tikz]{{standalone}}");
+
+  let _ = write!(&mut sb, "\\usepackage{{");
+  for (i, pkg) in LATEX_PACKAGES.iter().enumerate() {
+    if i != 0 { sb.push_str(", "); }
+    sb.push_str(pkg);
+  }
+  let _ = writeln!(&mut sb, "}}");
+
+  let _ = write!(&mut sb, "\\usetikzlibrary{{");
+  for (i, lib) in TIKZ_LIBRARIES.iter().enumerate() {
+    if i != 0 { sb.push_str(", "); }
+    sb.push_str(lib);
+  }
+  let _ = writeln!(&mut sb, "}}");
+
+  let _ = writeln!(&mut sb, "\\pgfplotsset{{compat=1.18}}");
+  let _ = writeln!(&mut sb, "\\begin{{document}}");
+
+  for pic in pics {
+    sb.push('\n');
+    let _ = File::open(pic.path)
+      .and_then(|mut f| f.read_to_string(&mut sb))
+      .map_err(|e| errorln!("Could not read {:?}: {e}", pic.path))?;
+  }
+
+  let _ = writeln!(&mut sb, "\n\\end{{document}}");
+
+  Ok(sb)
 }
 
 fn needs_update(pic: &GalleryEntry, dst: &Path) -> bool {
@@ -518,6 +567,27 @@ fn copy(from: &Path, to: &Path) -> Result<(), ()> {
     .map_err(|e| errorln!("Failed to copy {from:?} to {to:?}: {e}"))
 }
 
+fn random_dir_name() -> String {
+  use std::time::{self, SystemTime};
+
+  const RND_DIR_NAME_SIZE: usize = 16;
+  const CHARSET:           &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
+
+  let mut sb = String::with_capacity(RND_DIR_NAME_SIZE);
+  let rng = SystemTime::now()
+    .duration_since(time::UNIX_EPOCH)
+    .unwrap()
+    .as_nanos();
+  let mut rng = (rng % (usize::MAX as u128)) as usize;
+
+  for _ in 0..RND_DIR_NAME_SIZE {
+    rng = rng.wrapping_mul(1103515245).wrapping_add(12345) % CHARSET.len();
+    sb.push(CHARSET[rng] as char);
+  }
+
+  sb
+}
+
 impl Display for ThumbPath<'_> {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
     write!(f, "{THUMBS_PATH}/{name}", name = Escaped(&self.0.file_name))?;
@@ -532,29 +602,6 @@ impl Display for ThumbPath<'_> {
   }
 }
 
-impl From<ThumbPath<'_>> for PathBuf {
-  fn from(thumb_path: ThumbPath<'_>) -> Self {
-    let pic = thumb_path.0;
-
-    let mut result = PathBuf::from(TARGET_PATH);
-    result.push(THUMBS_PATH);
-
-    match pic.file_format {
-      FileFormat::TeX => {
-        result.push(pic.file_name.clone() + ".svg");
-      }
-      FileFormat::Svg => {
-        result.push(&pic.file_name);
-      }
-      FileFormat::Jpeg | FileFormat::Png => {
-        result.push(pic.file_name.clone() + ".webp");
-      }
-    }
-
-    result
-  }
-}
-
 impl Display for HtmlFileName<'_> {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
     write!(f, "{}.html", self.0)
diff --git /dev/null b/src/mupdf.rs
@@ -0,0 +1,111 @@
+//! Safe MuPDF wrappers
+use std::{ptr, slice, ops::Deref, ffi::CStr};
+use mupdf_sys::*;
+
+#[derive(Debug)]
+pub struct Document {
+  ctx:   *mut fz_context,
+  inner: *mut fz_document,
+
+  page_count: usize,
+}
+
+#[derive(Debug)]
+pub struct Buffer<'doc> {
+  doc:   &'doc Document,
+  inner: *mut fz_buffer,
+}
+
+impl Document {
+  pub fn open_from_bytes(bytes: &[u8]) -> Result<Self, String> {
+    let ctx = unsafe {
+      fz_new_context(ptr::null(), ptr::null(), FZ_STORE_DEFAULT as usize)
+    };
+    assert!(!ctx.is_null());
+
+    // SAFETY: this should really be wrapped with fz_try, but it can only
+    //         fail if ctx does not contain a document handler list (?) or
+    //         the list is already full. seems safe to assume
+    //         fz_new_documment will handle us a sane ctx
+    unsafe { fz_register_document_handlers(ctx); }
+
+    unsafe {
+      fz_set_warning_callback(ctx, None, ptr::null_mut());
+      fz_set_error_callback(ctx, None, ptr::null_mut());
+    }
+
+    let mut doc = ptr::null_mut();
+    let mut page_count = 0;
+    let result = unsafe {
+      mupdf_open_doc_from_bytes(
+        ctx,
+        bytes.as_ptr() as *mut _,
+        bytes.len(),
+        &mut doc,
+        &mut page_count,
+      )
+    };
+
+    if result.type_ != FZ_ERROR_NONE {
+      return Err(unsafe {
+        CStr::from_ptr(result.err_msg).to_string_lossy().to_string()
+      });
+    }
+
+    assert!(!doc.is_null());
+    let page_count = page_count as usize;
+    Ok(Self { ctx, inner: doc, page_count, })
+  }
+
+  pub fn render_page_to_svg(
+    &self,
+    page: usize,
+  ) -> Result<Buffer<'_>, String> {
+    assert!(page < self.page_count,
+      "page {page} is out of bounds: document only has {} pages",
+      self.page_count);
+
+    let mut buf = ptr::null_mut();
+    let result = unsafe {
+      mupdf_page_to_svg(self.ctx, self.inner, page, &mut buf)
+    };
+
+    if result.type_ != FZ_ERROR_NONE {
+      return Err(unsafe {
+        CStr::from_ptr(result.err_msg).to_string_lossy().to_string()
+      });
+    }
+
+    assert!(!buf.is_null());
+    Ok(Buffer { doc: self, inner: buf, })
+  }
+}
+
+impl Drop for Document {
+  fn drop(&mut self) {
+    unsafe {
+      fz_drop_document(self.ctx, self.inner);
+      fz_drop_context(self.ctx);
+    }
+  }
+}
+
+impl<'doc> Deref for Buffer<'doc> {
+  type Target = [u8];
+
+  fn deref(&self) -> &[u8] {
+    unsafe {
+      slice::from_raw_parts(
+        (*self.inner).data as *const _,
+        (*self.inner).len
+      )
+    }
+  }
+}
+
+impl<'doc> Drop for Buffer<'doc> {
+  fn drop(&mut self) {
+    unsafe { fz_drop_buffer(self.doc.ctx, self.inner); }
+  }
+}
+