univert 0.4.0

Universal file converter (library and CLI)
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use chrono::{DateTime, Datelike, FixedOffset, Local, Utc};
use typst::diag::{FileError, FileResult, SourceDiagnostic};
use typst::foundations::{Bytes, Datetime, Smart};
use typst::layout::PagedDocument;
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Feature, Features, Library, LibraryExt, World};
use typst_html::HtmlDocument;
use typst_kit::fonts::{FontSlot, Fonts};
use typst_pdf::{PdfOptions, PdfStandards};

use crate::{Error, Job, Result, extension_of};

pub fn typ_to(job: &Job) -> Result<()> {
  let to = extension_of(job.output).unwrap_or_default();

  let input_abs = fs::canonicalize(job.input)?;
  let root = input_abs
    .parent()
    .ok_or_else(|| Error::other("typst: input has no parent directory"))?
    .to_path_buf();
  let main_vpath = VirtualPath::within_root(&input_abs, &root)
    .ok_or_else(|| Error::other("typst: input is outside project root"))?;
  let main = FileId::new(None, main_vpath);

  let world = build_world(root, main, None, &to);
  emit(&world, &to, job.output)
}

/// Compile an in-memory Typst source string and emit `target` to `output`.
/// `root` is used to resolve relative paths referenced from the source (e.g.
/// `#image("foo.png")`). The main file is registered under a synthetic name
/// inside `root`.
pub fn compile_source(
  source_text: &str,
  root: PathBuf,
  target: &str,
  output: &Path,
) -> Result<()> {
  let main_vpath = VirtualPath::new("__univert_main.typ");
  let main = FileId::new(None, main_vpath);
  let source = Source::new(main, source_text.to_owned());
  let world = build_world(root, main, Some(source), target);
  emit(&world, target, output)
}

fn build_world(
  root: PathBuf,
  main: FileId,
  preloaded: Option<Source>,
  target: &str,
) -> SystemWorld {
  let mut builder = Library::builder();
  if target == "html" {
    let features: Features = [Feature::Html].into_iter().collect();
    builder = builder.with_features(features);
  }
  let library = builder.build();

  let fonts = Fonts::searcher().search();

  let mut slots: HashMap<FileId, FileSlot> = HashMap::new();
  if let Some(src) = preloaded {
    slots.insert(
      main,
      FileSlot {
        source: Some(Ok(src)),
        bytes: None,
      },
    );
  }

  SystemWorld {
    root,
    main,
    library: LazyHash::new(library),
    book: LazyHash::new(fonts.book),
    fonts: fonts.fonts,
    slots: Mutex::new(slots),
    now: Utc::now(),
  }
}

fn emit(world: &SystemWorld, target: &str, output: &Path) -> Result<()> {
  match target {
    "html" => {
      let document = typst::compile::<HtmlDocument>(world)
        .output
        .map_err(diag_error)?;
      let html = typst_html::html(&document).map_err(diag_error)?;
      fs::write(output, html)?;
    }
    "pdf" => {
      let document = typst::compile::<PagedDocument>(world)
        .output
        .map_err(diag_error)?;
      let options = PdfOptions {
        ident: Smart::Auto,
        timestamp: None,
        page_ranges: None,
        standards: PdfStandards::default(),
        tagged: false,
      };
      let buffer = typst_pdf::pdf(&document, &options).map_err(diag_error)?;
      fs::write(output, buffer)?;
    }
    "png" => {
      let document = typst::compile::<PagedDocument>(world)
        .output
        .map_err(diag_error)?;
      let page = document
        .pages
        .first()
        .ok_or_else(|| Error::other("typst: document has no pages"))?;
      let pixmap = typst_render::render(page, 144.0 / 72.0);
      let bytes = pixmap
        .encode_png()
        .map_err(|e| Error::other(format!("typst png encode: {e}")))?;
      fs::write(output, bytes)?;
    }
    "svg" => {
      let document = typst::compile::<PagedDocument>(world)
        .output
        .map_err(diag_error)?;
      let page = document
        .pages
        .first()
        .ok_or_else(|| Error::other("typst: document has no pages"))?;
      let svg = typst_svg::svg(page);
      fs::write(output, svg)?;
    }
    other => return Err(Error::other(format!("typst cannot emit .{other}"))),
  }
  Ok(())
}

fn diag_error<I>(diags: I) -> Error
where
  I: IntoIterator,
  I::Item: std::borrow::Borrow<SourceDiagnostic>,
{
  use std::borrow::Borrow;
  let msgs: Vec<String> = diags
    .into_iter()
    .map(|d| d.borrow().message.to_string())
    .collect();
  Error::other(format!("typst: {}", msgs.join("; ")))
}

struct SystemWorld {
  root: PathBuf,
  main: FileId,
  library: LazyHash<Library>,
  book: LazyHash<FontBook>,
  fonts: Vec<FontSlot>,
  slots: Mutex<HashMap<FileId, FileSlot>>,
  now: DateTime<Utc>,
}

#[derive(Default)]
struct FileSlot {
  source: Option<FileResult<Source>>,
  bytes: Option<FileResult<Bytes>>,
}

impl SystemWorld {
  fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
    if id.package().is_some() {
      return Err(FileError::Other(Some(
        "typst package imports are not supported by univert".into(),
      )));
    }
    id.vpath()
      .resolve(&self.root)
      .ok_or(FileError::AccessDenied)
  }

  fn load(&self, id: FileId) -> FileResult<Vec<u8>> {
    let path = self.resolve(id)?;
    fs::read(&path).map_err(|e| FileError::from_io(e, &path))
  }
}

impl World for SystemWorld {
  fn library(&self) -> &LazyHash<Library> {
    &self.library
  }

  fn book(&self) -> &LazyHash<FontBook> {
    &self.book
  }

  fn main(&self) -> FileId {
    self.main
  }

  fn source(&self, id: FileId) -> FileResult<Source> {
    let mut slots = self.slots.lock().unwrap();
    let slot = slots.entry(id).or_default();
    if let Some(r) = &slot.source {
      return r.clone();
    }
    let r = self.load(id).and_then(|data| {
      let buf = data.strip_prefix(b"\xef\xbb\xbf").unwrap_or(&data);
      let text = std::str::from_utf8(buf).map_err(FileError::from)?;
      Ok(Source::new(id, text.into()))
    });
    slot.source = Some(r.clone());
    r
  }

  fn file(&self, id: FileId) -> FileResult<Bytes> {
    let mut slots = self.slots.lock().unwrap();
    let slot = slots.entry(id).or_default();
    if let Some(r) = &slot.bytes {
      return r.clone();
    }
    let r = self.load(id).map(Bytes::new);
    slot.bytes = Some(r.clone());
    r
  }

  fn font(&self, index: usize) -> Option<Font> {
    self.fonts.get(index)?.get()
  }

  fn today(&self, offset: Option<i64>) -> Option<Datetime> {
    let with_offset = match offset {
      None => self.now.with_timezone(&Local).fixed_offset(),
      Some(hours) => {
        let seconds = i32::try_from(hours).ok()?.checked_mul(3600)?;
        self.now.with_timezone(&FixedOffset::east_opt(seconds)?)
      }
    };
    Datetime::from_ymd(
      with_offset.year(),
      with_offset.month().try_into().ok()?,
      with_offset.day().try_into().ok()?,
    )
  }
}