From 6d387f92065890001de5dd608f02e31413a25979 Mon Sep 17 00:00:00 2001
From: messense <messense@icloud.com>
Date: Thu, 6 Mar 2025 21:45:19 +0800
Subject: [PATCH] Allow passing typst template file as bytes

---
 python/typst/__init__.pyi | 44 +++++++++---------
 src/compiler.rs           | 14 +++---
 src/lib.rs                | 20 ++++++---
 src/query.rs              |  4 --
 src/world.rs              | 95 ++++++++++++++++++---------------------
 5 files changed, 86 insertions(+), 91 deletions(-)

diff --git a/python/typst/__init__.pyi b/python/typst/__init__.pyi
index 8e23fd9..4c8af5a 100644
--- a/python/typst/__init__.pyi
+++ b/python/typst/__init__.pyi
@@ -1,22 +1,22 @@
 import pathlib
 from typing import List, Optional, TypeVar, overload, Dict, Union, Literal
 
-PathLike = TypeVar("PathLike", str, pathlib.Path)
+Input = TypeVar("Input", str, pathlib.Path, bytes)
 OutputFormat = Literal["pdf", "svg", "png", "html"]
 
 class Compiler:
     def __init__(
         self,
-        input: PathLike,
-        root: Optional[PathLike] = None,
-        font_paths: List[PathLike] = [],
+        input: Input,
+        root: Optional[Input] = None,
+        font_paths: List[Input] = [],
         ignore_system_fonts: bool = False,
         sys_inputs: Dict[str, str] = {},
         pdf_standards: Optional[Union[Literal["1.7", "a-2b", "a-3b"], List[Literal["1.7", "a-2b", "a-3b"]]]] = []
     ) -> None:
         """Initialize a Typst compiler.
         Args:
-            input (PathLike): Project's main .typ file.
+            input: .typ file bytes or path to project's main .typ file.
             root (Optional[PathLike], optional): Root path for the Typst project.
             font_paths (List[PathLike]): Folders with fonts.
             ignore_system_fonts (bool): Ignore system fonts.
@@ -25,7 +25,7 @@ class Compiler:
 
     def compile(
         self,
-        output: Optional[PathLike] = None,
+        output: Optional[Input] = None,
         format: Optional[OutputFormat] = None,
         ppi: Optional[float] = None,
     ) -> Optional[Union[bytes, List[bytes]]]:
@@ -59,10 +59,10 @@ class Compiler:
 
 @overload
 def compile(
-    input: PathLike,
-    output: PathLike,
-    root: Optional[PathLike] = None,
-    font_paths: List[PathLike] = [],
+    input: Input,
+    output: Input,
+    root: Optional[Input] = None,
+    font_paths: List[Input] = [],
     ignore_system_fonts: bool = False,
     format: Optional[OutputFormat] = None,
     ppi: Optional[float] = None,
@@ -71,10 +71,10 @@ def compile(
 ) -> None: ...
 @overload
 def compile(
-    input: PathLike,
+    input: Input,
     output: None = None,
-    root: Optional[PathLike] = None,
-    font_paths: List[PathLike] = [],
+    root: Optional[Input] = None,
+    font_paths: List[Input] = [],
     ignore_system_fonts: bool = False,
     format: Optional[OutputFormat] = None,
     ppi: Optional[float] = None,
@@ -82,10 +82,10 @@ def compile(
     pdf_standards: Optional[Union[Literal["1.7", "a-2b", "a-3b"], List[Literal["1.7", "a-2b", "a-3b"]]]] = []
 ) -> bytes: ...
 def compile(
-    input: PathLike,
-    output: Optional[PathLike] = None,
-    root: Optional[PathLike] = None,
-    font_paths: List[PathLike] = [],
+    input: Input,
+    output: Optional[Input] = None,
+    root: Optional[Input] = None,
+    font_paths: List[Input] = [],
     ignore_system_fonts: bool = False,
     format: Optional[OutputFormat] = None,
     ppi: Optional[float] = None,
@@ -94,7 +94,7 @@ def compile(
 ) -> Optional[Union[bytes, List[bytes]]]:
     """Compile a Typst project.
     Args:
-        input (PathLike): Project's main .typ file.
+        input: .typ file bytes or path to project's main .typ file.
         output (Optional[PathLike], optional): Path to save the compiled file.
         Allowed extensions are `.pdf`, `.svg` and `.png`
         root (Optional[PathLike], optional): Root path for the Typst project.
@@ -109,19 +109,19 @@ def compile(
     """
 
 def query(
-    input: PathLike,
+    input: Input,
     selector: str,
     field: Optional[str] = None,
     one: bool = False,
     format: Optional[Literal["json", "yaml"]] = None,
-    root: Optional[PathLike] = None,
-    font_paths: List[PathLike] = [],
+    root: Optional[Input] = None,
+    font_paths: List[Input] = [],
     ignore_system_fonts: bool = False,
     sys_inputs: Dict[str, str] = {},
 ) -> str:
     """Query a Typst document.
     Args:
-        input (PathLike): Project's main .typ file.
+        input: .typ file bytes or path to project's main .typ file.
         selector (str): Typst selector like `<label>`.
         field (Optional[str], optional): Field to query.
         one (bool, optional): Query only one element.
diff --git a/src/compiler.rs b/src/compiler.rs
index a8ee35c..fd360d2 100644
--- a/src/compiler.rs
+++ b/src/compiler.rs
@@ -7,7 +7,7 @@ use typst::foundations::Datetime;
 use typst::html::HtmlDocument;
 use typst::layout::PagedDocument;
 use typst::syntax::{FileId, Source, Span};
-use typst::{World, WorldExt};
+use typst::WorldExt;
 
 use crate::world::SystemWorld;
 
@@ -21,10 +21,6 @@ impl SystemWorld {
         ppi: Option<f32>,
         pdf_standards: &[typst_pdf::PdfStandard],
     ) -> StrResult<Vec<Vec<u8>>> {
-        // Reset everything and ensure that the main file is present.
-        self.reset();
-        self.source(self.main()).map_err(|err| err.to_string())?;
-
         let Warned { output, warnings } = typst::compile(self);
 
         match output {
@@ -40,7 +36,10 @@ impl SystemWorld {
                     "png" => Ok(export_image(&document, ImageExportFormat::Png, ppi)?),
                     "svg" => Ok(export_image(&document, ImageExportFormat::Svg, ppi)?),
                     "html" => {
-                        let Warned { output, warnings } = typst::compile::<HtmlDocument>(self);
+                        let Warned {
+                            output,
+                            warnings: _,
+                        } = typst::compile::<HtmlDocument>(self);
                         Ok(vec![export_html(&output.unwrap(), self)?])
                     }
                     fmt => Err(eco_format!("unknown format: {fmt}")),
@@ -70,11 +69,10 @@ fn export_pdf(
     world: &SystemWorld,
     standards: typst_pdf::PdfStandards,
 ) -> StrResult<Vec<u8>> {
-    let ident = world.input().to_string_lossy();
     let buffer = typst_pdf::pdf(
         document,
         &typst_pdf::PdfOptions {
-            ident: typst::foundations::Smart::Custom(&ident),
+            ident: typst::foundations::Smart::Auto,
             timestamp: now().map(typst_pdf::Timestamp::new_utc),
             standards,
             ..Default::default()
diff --git a/src/lib.rs b/src/lib.rs
index 1004d9c..ad5aabc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -43,6 +43,12 @@ mod output_template {
     }
 }
 
+#[derive(FromPyObject)]
+pub enum Input {
+    Path(PathBuf),
+    Bytes(Vec<u8>),
+}
+
 /// A typst compiler
 #[pyclass(module = "typst._typst")]
 pub struct Compiler {
@@ -104,17 +110,19 @@ impl Compiler {
         sys_inputs = HashMap::new()
     ))]
     fn new(
-        input: PathBuf,
+        input: Input,
         root: Option<PathBuf>,
         font_paths: Vec<PathBuf>,
         ignore_system_fonts: bool,
         sys_inputs: HashMap<String, String>,
     ) -> PyResult<Self> {
-        let input = input.canonicalize()?;
         let root = if let Some(root) = root {
             root.canonicalize()?
-        } else if let Some(dir) = input.parent() {
-            dir.into()
+        } else if let Input::Path(path) = &input {
+            path.canonicalize()?
+                .parent()
+                .map(Into::into)
+                .unwrap_or_else(|| PathBuf::new())
         } else {
             PathBuf::new()
         };
@@ -227,7 +235,7 @@ impl Compiler {
 #[allow(clippy::too_many_arguments)]
 fn compile(
     py: Python<'_>,
-    input: PathBuf,
+    input: Input,
     output: Option<PathBuf>,
     root: Option<PathBuf>,
     font_paths: Vec<PathBuf>,
@@ -260,7 +268,7 @@ fn compile(
 #[allow(clippy::too_many_arguments)]
 fn py_query(
     py: Python<'_>,
-    input: PathBuf,
+    input: Input,
     selector: &str,
     field: Option<&str>,
     one: bool,
diff --git a/src/query.rs b/src/query.rs
index 23852b2..fff5444 100644
--- a/src/query.rs
+++ b/src/query.rs
@@ -35,10 +35,6 @@ pub enum SerializationFormat {
 
 /// Execute a query command.
 pub fn query(world: &mut SystemWorld, command: &QueryCommand) -> StrResult<String> {
-    // Reset everything and ensure that the main file is present.
-    world.reset();
-    world.source(world.main()).map_err(|err| err.to_string())?;
-
     let Warned { output, warnings } = typst::compile(world);
 
     match output {
diff --git a/src/world.rs b/src/world.rs
index d755939..dd0b5ec 100644
--- a/src/world.rs
+++ b/src/world.rs
@@ -3,7 +3,6 @@ use std::path::{Path, PathBuf};
 use std::sync::{Mutex, OnceLock};
 
 use chrono::{DateTime, Datelike, Local};
-use ecow::eco_format;
 use typst::diag::{FileError, FileResult, StrResult};
 use typst::foundations::{Bytes, Datetime, Dict};
 use typst::syntax::{FileId, Source, VirtualPath};
@@ -17,14 +16,12 @@ use typst_kit::{
 
 use std::collections::HashMap;
 
-use crate::download::SlientDownload;
+use crate::{download::SlientDownload, Input};
 
 /// A world that provides access to the operating system.
 pub struct SystemWorld {
     /// The working directory.
     workdir: Option<PathBuf>,
-    /// The canonical path to the input file.
-    input: PathBuf,
     /// The root relative to which absolute paths are resolved.
     root: PathBuf,
     /// The input path.
@@ -86,8 +83,8 @@ impl World for SystemWorld {
 }
 
 impl SystemWorld {
-    pub fn builder(root: PathBuf, main: PathBuf) -> SystemWorldBuilder {
-        SystemWorldBuilder::new(root, main)
+    pub fn builder(root: PathBuf, input: Input) -> SystemWorldBuilder {
+        SystemWorldBuilder::new(root, input)
     }
 
     /// Access the canonical slot for the given file id.
@@ -99,11 +96,6 @@ impl SystemWorld {
         f(map.entry(id).or_insert_with(|| FileSlot::new(id)))
     }
 
-    /// The id of the main source file.
-    pub fn main(&self) -> FileId {
-        self.main
-    }
-
     /// The root relative to which absolute paths are resolved.
     pub fn root(&self) -> &Path {
         &self.root
@@ -114,19 +106,6 @@ impl SystemWorld {
         self.workdir.as_deref().unwrap_or(Path::new("."))
     }
 
-    /// Reset the compilation state in preparation of a new compilation.
-    pub fn reset(&mut self) {
-        for slot in self.slots.lock().unwrap().values_mut() {
-            slot.reset();
-        }
-        self.now.take();
-    }
-
-    /// Return the canonical path to the input file.
-    pub fn input(&self) -> &PathBuf {
-        &self.input
-    }
-
     /// Lookup a source file by id.
     #[track_caller]
     pub fn lookup(&self, id: FileId) -> Source {
@@ -137,17 +116,17 @@ impl SystemWorld {
 
 pub struct SystemWorldBuilder {
     root: PathBuf,
-    main: PathBuf,
+    input: Input,
     font_paths: Vec<PathBuf>,
     ignore_system_fonts: bool,
     inputs: Dict,
 }
 
 impl SystemWorldBuilder {
-    pub fn new(root: PathBuf, main: PathBuf) -> Self {
+    pub fn new(root: PathBuf, input: Input) -> Self {
         Self {
             root,
-            main,
+            input,
             font_paths: Vec::new(),
             ignore_system_fonts: false,
             inputs: Dict::default(),
@@ -174,18 +153,36 @@ impl SystemWorldBuilder {
             .include_system_fonts(!self.ignore_system_fonts)
             .search_with(&self.font_paths);
 
-        let input = self.main.canonicalize().map_err(|_| {
-            eco_format!("input file not found (searched at {})", self.main.display())
-        })?;
-        // Resolve the virtual path of the main file within the project root.
-        let main_path = VirtualPath::within_root(&self.main, &self.root)
-            .ok_or("input file must be contained in project root")?;
+        let mut slots = HashMap::new();
+        let main = match self.input {
+            Input::Path(path) => {
+                // Resolve the virtual path of the main file within the project root.
+                let path = path
+                    .canonicalize()
+                    .map_err(|err| format!("Failed to canonicalize path: {}", err))?;
+                FileId::new(
+                    None,
+                    VirtualPath::within_root(&path, &self.root)
+                        .ok_or("input file must be contained in project root")?,
+                )
+            }
+            Input::Bytes(bytes) => {
+                // Fake file ID
+                let file_id = FileId::new_fake(VirtualPath::new("<bytes>"));
+                let mut file_slot = FileSlot::new(file_id);
+                file_slot
+                    .source
+                    .init(Source::new(file_id, decode_utf8(&bytes)?.to_string()));
+                file_slot.file.init(Bytes::new(bytes));
+                slots.insert(file_id, file_slot);
+                file_id
+            }
+        };
 
         let world = SystemWorld {
             workdir: std::env::current_dir().ok(),
-            input,
             root: self.root,
-            main: FileId::new(None, main_path),
+            main,
             library: LazyHash::new(
                 LibraryBuilder::default()
                     .with_features(vec![typst::Feature::Html].into_iter().collect())
@@ -194,7 +191,7 @@ impl SystemWorldBuilder {
             ),
             book: LazyHash::new(fonts.book),
             fonts: fonts.fonts,
-            slots: Mutex::default(),
+            slots: Mutex::new(slots),
             package_storage: PackageStorage::new(None, None, crate::download::downloader()),
             now: OnceLock::new(),
         };
@@ -224,17 +221,14 @@ impl FileSlot {
         }
     }
 
-    /// Marks the file as not yet accessed in preparation of the next
-    /// compilation.
-    fn reset(&mut self) {
-        self.source.reset();
-        self.file.reset();
-    }
-
-    fn source(&mut self, root: &Path, package_storage: &PackageStorage) -> FileResult<Source> {
+    fn source(
+        &mut self,
+        project_root: &Path,
+        package_storage: &PackageStorage,
+    ) -> FileResult<Source> {
         let id = self.id;
         self.source.get_or_init(
-            || system_path(root, id, package_storage),
+            || system_path(project_root, id, package_storage),
             |data, prev| {
                 let text = decode_utf8(&data)?;
                 if let Some(mut prev) = prev {
@@ -247,10 +241,10 @@ impl FileSlot {
         )
     }
 
-    fn file(&mut self, root: &Path, package_storage: &PackageStorage) -> FileResult<Bytes> {
+    fn file(&mut self, project_root: &Path, package_storage: &PackageStorage) -> FileResult<Bytes> {
         let id = self.id;
         self.file.get_or_init(
-            || system_path(root, id, package_storage),
+            || system_path(project_root, id, package_storage),
             |data, _| Ok(Bytes::new(data)),
         )
     }
@@ -292,10 +286,9 @@ impl<T: Clone> SlotCell<T> {
         }
     }
 
-    /// Marks the cell as not yet accessed in preparation of the next
-    /// compilation.
-    fn reset(&mut self) {
-        self.accessed = false;
+    fn init(&mut self, data: T) {
+        self.data = Some(Ok(data));
+        self.accessed = true;
     }
 
     /// Gets the contents of the cell or initialize them.