affine_preview 0.1.0

Native Mermaid and Typst SVG rendering for AFFiNE.
Documentation
use std::{borrow::Cow, path::PathBuf};

use typst::{
  diag::FileResult,
  foundations::Bytes,
  layout::{Abs, PagedDocument},
  syntax::{FileId, Source},
};
use typst_as_lib::{
  TypstEngine,
  cached_file_resolver::{CachedFileResolver, IntoCachedFileResolver},
  file_resolver::FileResolver,
  package_resolver::{FileSystemCache, PackageResolver},
  typst_kit_options::TypstKitFontOptions,
};

use crate::{PreviewError, Result};

const PACKAGE_CACHE_DIR: &str = "typst-package-cache";

#[derive(Debug, Default)]
pub struct TypstRenderOptions {
  pub font_dirs: Vec<PathBuf>,
  pub cache_dir: Option<PathBuf>,
}

enum PreviewPackageResolver {
  FileSystem(CachedFileResolver<PackageResolver<FileSystemCache>>),
  InMemory(CachedFileResolver<PackageResolver<typst_as_lib::package_resolver::InMemoryCache>>),
}

impl FileResolver for PreviewPackageResolver {
  fn resolve_binary(&self, id: FileId) -> FileResult<Cow<'_, Bytes>> {
    match self {
      Self::FileSystem(resolver) => resolver.resolve_binary(id),
      Self::InMemory(resolver) => resolver.resolve_binary(id),
    }
  }

  fn resolve_source(&self, id: FileId) -> FileResult<Cow<'_, Source>> {
    match self {
      Self::FileSystem(resolver) => resolver.resolve_source(id),
      Self::InMemory(resolver) => resolver.resolve_source(id),
    }
  }
}

pub fn render_typst_svg(source: &str, options: TypstRenderOptions) -> Result<String> {
  let search_options = TypstKitFontOptions::new()
    .include_system_fonts(false)
    .include_embedded_fonts(true)
    .include_dirs(options.font_dirs);
  let package_resolver = package_resolver(options.cache_dir)?;
  let engine = TypstEngine::builder()
    .main_file(source)
    .search_fonts_with(search_options)
    .add_file_resolver(package_resolver)
    .build();
  let document = engine
    .compile::<PagedDocument>()
    .output
    .map_err(|error| PreviewError::Typst(error.to_string()))?;

  Ok(normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0))))
}

fn package_resolver(cache_dir: Option<PathBuf>) -> Result<PreviewPackageResolver> {
  match cache_dir {
    Some(cache_dir) => {
      let cache_dir = cache_dir.join(PACKAGE_CACHE_DIR);
      std::fs::create_dir_all(&cache_dir)?;
      Ok(PreviewPackageResolver::FileSystem(
        PackageResolver::builder()
          .cache(FileSystemCache(cache_dir))
          .build()
          .into_cached(),
      ))
    }
    None => Ok(PreviewPackageResolver::InMemory(
      PackageResolver::builder().with_in_memory_cache().build().into_cached(),
    )),
  }
}

fn normalize_typst_svg(mut svg: String) -> String {
  let marker = r##"<path class="typst-shape""##;
  let mut cursor = 0;
  while let Some(relative_idx) = svg[cursor..].find(marker) {
    let idx = cursor + relative_idx;
    let Some(relative_end) = svg[idx..].find("/>") else {
      break;
    };
    let end = idx + relative_end + 2;
    let fragment = &svg[idx..end];
    if fragment.contains(r#"d="M 0 0v "#) && fragment.contains(r#" h "#) && fragment.contains(r#" v -"#) {
      svg.replace_range(idx..end, "");
      cursor = idx;
    } else {
      cursor = end;
    }
  }
  svg
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn renders_without_page_background() {
    let svg = render_typst_svg("= Preview", TypstRenderOptions::default()).unwrap();
    assert!(svg.starts_with("<svg"));
    assert!(!svg.contains(r#"d="M 0 0v "#));
  }
}