univert 0.4.0

Universal file converter (library and CLI)
Documentation
//! Produce PDFs from images, plain text, and HTML using pure-Rust pipelines.
//! Images and plain text go through `printpdf`; HTML is translated to Typst
//! markup and compiled by the `typst` crate.

use std::fs;
use std::io::BufWriter;

use printpdf::{
  BuiltinFont, Image, ImageTransform, ImageXObject, Mm, PdfDocument, Px,
};

use crate::converters::{html_typst, typst_fmt};
use crate::{Error, Job, Result};

const PAGE_WIDTH_MM: f32 = 210.0; // A4
const PAGE_HEIGHT_MM: f32 = 297.0;
const MARGIN_MM: f32 = 15.0;
const FONT_SIZE_PT: f32 = 10.0;
const LINE_HEIGHT_MM: f32 = 4.5;

pub fn image_to_pdf(job: &Job) -> Result<()> {
  let img = image::open(job.input)
    .map_err(|e| Error::other(format!("image decode: {e}")))?
    .to_rgb8();
  let (px_w, px_h) = img.dimensions();

  let (doc, page1, layer1) = PdfDocument::new(
    "univert",
    Mm(PAGE_WIDTH_MM),
    Mm(PAGE_HEIGHT_MM),
    "Layer 1",
  );

  let xobject = ImageXObject {
    width: Px(px_w as usize),
    height: Px(px_h as usize),
    color_space: printpdf::ColorSpace::Rgb,
    bits_per_component: printpdf::ColorBits::Bit8,
    interpolate: true,
    image_data: img.into_raw(),
    image_filter: None,
    smask: None,
    clipping_bbox: None,
  };
  let image = Image { image: xobject };

  // Fit image inside page bounds (A4 minus margin).
  let max_w_mm = PAGE_WIDTH_MM - 2.0 * MARGIN_MM;
  let max_h_mm = PAGE_HEIGHT_MM - 2.0 * MARGIN_MM;
  let img_w_mm_at_72dpi = px_w as f32 * 25.4 / 72.0;
  let img_h_mm_at_72dpi = px_h as f32 * 25.4 / 72.0;
  let scale = (max_w_mm / img_w_mm_at_72dpi)
    .min(max_h_mm / img_h_mm_at_72dpi)
    .min(1.0);
  let draw_w_mm = img_w_mm_at_72dpi * scale;
  let draw_h_mm = img_h_mm_at_72dpi * scale;
  let tx = (PAGE_WIDTH_MM - draw_w_mm) / 2.0;
  let ty = (PAGE_HEIGHT_MM - draw_h_mm) / 2.0;

  image.add_to_layer(
    doc.get_page(page1).get_layer(layer1),
    ImageTransform {
      translate_x: Some(Mm(tx)),
      translate_y: Some(Mm(ty)),
      scale_x: Some(scale),
      scale_y: Some(scale),
      rotate: None,
      dpi: Some(72.0),
    },
  );

  let file = fs::File::create(job.output)?;
  doc
    .save(&mut BufWriter::new(file))
    .map_err(|e| Error::other(format!("pdf save: {e}")))?;
  Ok(())
}

pub fn txt_to_pdf(job: &Job) -> Result<()> {
  let text = fs::read_to_string(job.input)?;

  let (doc, page1, layer1) = PdfDocument::new(
    "univert",
    Mm(PAGE_WIDTH_MM),
    Mm(PAGE_HEIGHT_MM),
    "Layer 1",
  );
  let font = doc
    .add_builtin_font(BuiltinFont::Helvetica)
    .map_err(|e| Error::other(format!("pdf font: {e}")))?;

  let usable_h_mm = PAGE_HEIGHT_MM - 2.0 * MARGIN_MM;
  let lines_per_page = (usable_h_mm / LINE_HEIGHT_MM).floor() as usize;
  let lines: Vec<&str> = text.lines().collect();

  let mut layer_ref = doc.get_page(page1).get_layer(layer1);
  let mut first = true;
  for chunk in lines.chunks(lines_per_page.max(1)) {
    if !first {
      let (p, l) =
        doc.add_page(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), "Layer 1");
      layer_ref = doc.get_page(p).get_layer(l);
    }
    first = false;
    layer_ref.begin_text_section();
    layer_ref.set_font(&font, FONT_SIZE_PT);
    layer_ref.set_line_height(FONT_SIZE_PT * 1.2);
    layer_ref.set_text_cursor(Mm(MARGIN_MM), Mm(PAGE_HEIGHT_MM - MARGIN_MM));
    for line in chunk {
      layer_ref.write_text(*line, &font);
      layer_ref.add_line_break();
    }
    layer_ref.end_text_section();
  }

  let file = fs::File::create(job.output)?;
  doc
    .save(&mut BufWriter::new(file))
    .map_err(|e| Error::other(format!("pdf save: {e}")))?;
  Ok(())
}

/// HTML → PDF in pure Rust: translate HTML to Typst markup, then compile via
/// the `typst` crate. CSS and JavaScript are ignored; structural tags
/// (headings, paragraphs, lists, tables, links, inline/fenced code,
/// blockquotes, horizontal rules, local images) are preserved.
pub fn html_to_pdf(job: &Job) -> Result<()> {
  let html = fs::read_to_string(job.input)?;
  let typst_source = html_typst::html_to_typst(&html);
  let input_abs = fs::canonicalize(job.input)?;
  let root = input_abs
    .parent()
    .ok_or_else(|| Error::other("html: input has no parent directory"))?
    .to_path_buf();
  typst_fmt::compile_source(&typst_source, root, "pdf", job.output)
}