From 04780a4423c8be1136c79b80a888fdf376e1687d Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 6 Sep 2026 18:22:56 +0200 Subject: [PATCH] fix: re-read files from disk on every compilation A compiler filled its file slots on first access and never invalidated them, so the main file and every import stayed pinned to whatever they contained during the first compilation. Keeping one compiler alive across renders is the recommended way to use the library, and a template redeployed underneath a running process went on rendering the retired content with no error to show for it. Every compilation now starts by marking the slots as unread, the way typst-cli does between watch runs. A file whose content has not changed is still recognised by its hash and is not parsed again, and a document handed over as a string keeps its content: there is no file behind it to read. The reset takes the slot mutex rather than reaching past it with get_mut. The map is grown by every first access to a file, so iterating it unlocked would turn the documented one-compiler-per-thread rule from a stale-read hazard into a dangling iterator. Also wraps the calls in tests/input_path.rs in unsafe blocks. The exported functions became unsafe after those tests were written, so the test crate no longer compiled and the native test step failed. --- README.md | 7 + RELEASENOTES.md | 1 + src/typst_core/src/lib.rs | 2 +- src/typst_core/src/world.rs | 169 +++++++++++++- src/typst_core/tests/file_reload.rs | 327 ++++++++++++++++++++++++++++ src/typst_core/tests/input_path.rs | 38 ++-- src/typstsharp.tests/Tests.cs | 70 ++++++ src/typstsharp/TypstCompiler.cs | 14 ++ 8 files changed, 605 insertions(+), 23 deletions(-) create mode 100644 src/typst_core/tests/file_reload.rs diff --git a/README.md b/README.md index 3bac4b5..1a93c9c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,13 @@ builds reproducible: whatever is deployed is exactly what gets compiled. You can easily use this inside of an ASP.Net Server (just ensure you lazy load and cache the TypstCompiler to reduce from 40ms to around 3ms for a normal compile). +A cached compiler stays current: every compilation reads the template, its imports and its data +files from disk again, so redeploying a template takes effect without restarting the process. Two +things follow from that. Replace template files atomically — write a temporary file and rename it — +because a compilation that lands halfway through a plain overwrite renders the half-written file. +And a warm compilation now costs one stat and one read per file it touches, so a template split +across many files is measurably more expensive to recompile than a single one. + ## Prerequisites - [.NET SDK 10.0](https://dotnet.microsoft.com/) – required to build the managed projects. diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a9d1263..3aec845 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Fixed +- Fixed a compiler reused across compilations never seeing a change made to the files it reads. The main file and every import were read on first access and then kept forever, so a cached `TypstCompiler` — which the README recommends, because it takes a compile from around 40ms down to 3ms — went on rendering the template it had read at startup, with no error to show for it. Every compilation now starts by invalidating the file slots, the way `typst-cli` does between watch runs, so a template redeployed underneath a running process takes effect. Files whose content has not changed are recognised by their hash and are not parsed again, and a document passed in as a string is unaffected: there is no file behind it. Two consequences worth knowing about: templates should be replaced atomically, because a compilation that lands halfway through a plain overwrite now renders the half-written file; and a warm compilation costs one stat and one read per file it touches, so a template split across many files is measurably more expensive to recompile than a single one. - Fixed an input path in a subfolder aborting the process instead of compiling. The path was handed to Typst verbatim, and a Typst virtual path only accepts forward slashes, so on Windows an ordinary relative path such as `templates\letter.typ` panicked inside a native call and took the host process down with it. Paths are now resolved against the project root before being converted, and a path that genuinely leaves the root reports an error instead of panicking. Note that the root is matched against an absolute input path textually, so on Windows both have to be spelled with the same casing. - Fixed `ua-1` (PDF/UA-1, the accessibility standard) being rejected by `pdfStandards`. The accepted names are now taken from `typst_pdf::PdfStandard` itself, so every standard Typst supports is accepted, including ones added by later Typst releases. The documented `v-` prefix on plain PDF versions still works, and applies only to them: `v-a-2b` is not a spelling of `a-2b`. diff --git a/src/typst_core/src/lib.rs b/src/typst_core/src/lib.rs index 9391eac..aa8f561 100644 --- a/src/typst_core/src/lib.rs +++ b/src/typst_core/src/lib.rs @@ -240,7 +240,7 @@ fn compile_inner( ppi: f32, standards: &[typst_pdf::PdfStandard], ) -> StrResult<(Vec>, Vec)> { - world.reset_time(); + world.reset(); let (document, warnings) = match typst::compile::(world) { Warned { output, warnings } => { let doc = output.map_err(|errors| { diff --git a/src/typst_core/src/world.rs b/src/typst_core/src/world.rs index 1e94220..f1c156e 100644 --- a/src/typst_core/src/world.rs +++ b/src/typst_core/src/world.rs @@ -114,7 +114,7 @@ impl SystemWorld { let mut slots = HashMap::new(); if let Some(content) = input_content { let mut main_slot = FileSlot::new(main_id); - main_slot.source.init(Source::new(main_id, content)); + main_slot.source.init_in_memory(Source::new(main_id, content)); slots.insert(main_id, main_slot); } @@ -172,8 +172,25 @@ impl SystemWorld { Ok(()) } - /// Resets the cached date/time between compilations. - pub fn reset_time(&mut self) { + /// Prepares the world for a new compilation. + /// + /// Drops the cached date/time and marks every file slot as not yet accessed, so + /// the next access reads the file from disk again. A compiler is meant to be kept + /// alive across compilations for its incremental cache, and one that held on to + /// the content each file had when it was first read would go on rendering a + /// template that has since been rewritten, and give no sign of it. + /// + /// Slots holding a document that was handed over in memory keep it: there is no + /// file behind them to read. + /// + /// The lock is taken rather than reached past with `Mutex::get_mut`: `slot` below + /// inserts into the map and can reallocate it, and the only thing keeping that off + /// another thread is the caller honouring the rule that a compiler belongs to one + /// thread. + pub fn reset(&mut self) { + for slot in self.slots.lock().unwrap().values_mut() { + slot.reset(); + } self.now.reset(); } @@ -228,6 +245,19 @@ impl FileSlot { |data, _| Ok(Bytes::new(data)), ) } + + /// Sends both views of the file back to disk for the next compilation. + /// + /// Package files are included. A package addressed with a fixed version cannot + /// legitimately change under it, but a deployment that vendors its templates as + /// local packages redeploys them exactly the way it redeploys a bare `.typ`, and + /// that is the case this exists for. A package that failed to resolve is looked up + /// again for the same reason: one that is vendored afterwards starts working, + /// rather than staying broken for the life of the compiler. + fn reset(&mut self) { + self.source.reset(); + self.file.reset(); + } } fn system_path( @@ -248,6 +278,9 @@ struct SlotCell { data: Option>, fingerprint: u128, accessed: bool, + /// Whether the value was handed over rather than read from a file. Such a cell + /// has no path behind it, so it is the one thing a reset must not invalidate. + in_memory: bool, } impl SlotCell { @@ -256,12 +289,31 @@ impl SlotCell { data: None, fingerprint: 0, accessed: false, + in_memory: false, } } - fn init(&mut self, data: T) { + /// Fills the cell with a value that did not come from a file, and pins it there. + /// + /// Only a caller that has the content in hand may use this: the cell keeps the + /// value for the lifetime of the world, because a reset has no file to send it + /// back to. + fn init_in_memory(&mut self, data: T) { self.data = Some(Ok(data)); self.accessed = true; + self.in_memory = true; + } + + /// Sends the cell back to the file for the next compilation, unless it holds a + /// value that was handed over rather than read. + /// + /// The cached value and its fingerprint are kept either way: if the file turns + /// out to be unchanged, the value is handed out again instead of being decoded a + /// second time, which is what keeps recompiling an unchanged document cheap. + fn reset(&mut self) { + if !self.in_memory { + self.accessed = false; + } } fn get_or_init( @@ -306,3 +358,112 @@ fn decode_utf8(buf: &[u8]) -> FileResult<&str> { buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf), )?) } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + /// A throwaway directory, removed when the test ends. + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new(name: &str) -> Self { + // A counter keeps parallel tests apart without pulling in a temp-file crate. + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + + let path = std::env::temp_dir().join(format!( + "typst_core-slot-{}-{}-{}", + name, + std::process::id(), + unique + )); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + /// Writes a file into the directory and returns its path. + fn write(&self, name: &str, content: &str) -> PathBuf { + let file = self.path.join(name); + std::fs::write(&file, content).unwrap(); + file + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + /// A cell holds its value until it is reset, and then reports whatever the file + /// says. Decoding is the expensive half of a read, so it has to happen only when + /// the content has actually changed: the fingerprint is what tells the two apart. + #[test] + fn a_reset_cell_re_reads_the_file_but_decodes_only_what_changed() { + let dir = TempDir::new("re-read"); + let path = dir.write("note.txt", "first"); + + let decodes = Cell::new(0usize); + let decode = |data: Vec, _: Option| { + decodes.set(decodes.get() + 1); + Ok(String::from_utf8(data).unwrap()) + }; + let mut cell = SlotCell::::new(); + + assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "first"); + assert_eq!(decodes.get(), 1); + + // Within one compilation the file is read once, however often it is asked for. + std::fs::write(&path, "second").unwrap(); + assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "first"); + assert_eq!(decodes.get(), 1); + + cell.reset(); + assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "second"); + assert_eq!(decodes.get(), 2); + + cell.reset(); + assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "second"); + assert_eq!(decodes.get(), 2, "an unchanged file was decoded a second time"); + } + + /// A failed read is cached like a successful one, so a file that appears later has + /// to be picked up rather than reported missing forever. + #[test] + fn a_reset_cell_picks_up_a_file_that_did_not_exist_yet() { + let dir = TempDir::new("appearing"); + let path = dir.path.join("late.txt"); + + let decode = |data: Vec, _: Option| Ok(String::from_utf8(data).unwrap()); + let mut cell = SlotCell::::new(); + + assert!(cell.get_or_init(|| Ok(path.clone()), decode).is_err()); + + std::fs::write(&path, "here now").unwrap(); + cell.reset(); + + assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "here now"); + } + + /// A value that was handed over has no file behind it, so a reset must leave it + /// alone. Both closures fail the test if the cell goes looking for one. + #[test] + fn an_in_memory_cell_is_untouched_by_a_reset() { + let mut cell = SlotCell::::new(); + cell.init_in_memory("handed over".to_string()); + + cell.reset(); + + let value = cell.get_or_init( + || panic!("an in-memory cell must not be resolved to a path"), + |_, _| unreachable!("an in-memory cell must not be decoded"), + ); + assert_eq!(value.unwrap(), "handed over"); + } +} diff --git a/src/typst_core/tests/file_reload.rs b/src/typst_core/tests/file_reload.rs new file mode 100644 index 0000000..e9e2da8 --- /dev/null +++ b/src/typst_core/tests/file_reload.rs @@ -0,0 +1,327 @@ +//! Tests that a compiler reused across compilations sees the current content of +//! the files it reads. +//! +//! Keeping one compiler alive is the recommended way to benefit from the incremental +//! cache, so the files it reads must not be pinned to whatever they contained during +//! the first compilation. Documents that were handed over in memory have no file +//! behind them and have to survive the reset instead. + +use std::ffi::{c_char, CString}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use typst_core::{Compiler, compile, create_compiler, free_compile_result, free_compiler}; + +/// A throwaway project directory, removed when the test ends. +struct Project { + root: PathBuf, +} + +impl Project { + fn new(name: &str) -> Self { + // A counter keeps parallel tests apart without pulling in a temp-file crate. + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + + let root = std::env::temp_dir().join(format!( + "typst_core-{}-{}-{}", + name, + std::process::id(), + unique + )); + std::fs::create_dir_all(&root).unwrap(); + Self { root } + } + + /// Writes a file into the project, replacing whatever was there before. + fn write(&self, name: &str, content: &str) { + let path = self.root.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + /// Removes a file from the project. + fn remove(&self, name: &str) { + std::fs::remove_file(self.root.join(name)).unwrap(); + } +} + +impl Drop for Project { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// Creates a compiler over a project root, with the document taken from a file, from +/// memory, or from both. +fn compiler_for(root: &Path, input_path: Option<&str>, source: Option<&str>) -> *mut Compiler { + let root = CString::new(root.to_str().unwrap()).unwrap(); + let input_path = input_path.map(|path| CString::new(path).unwrap()); + let sys_inputs = CString::new("{}").unwrap(); + + let compiler = unsafe { + create_compiler( + root.as_ptr(), + input_path.as_ref().map_or(std::ptr::null(), |path| path.as_ptr()), + source.map_or(std::ptr::null(), |source| source.as_ptr()), + source.map_or(0, |source| source.len()), + std::ptr::null::<*const c_char>(), + 0, + std::ptr::null(), + sys_inputs.as_ptr(), + true, + true, + ) + }; + assert!(!compiler.is_null(), "failed to create compiler"); + compiler +} + +/// Creates a compiler over a file, the way `TypstCompiler.FromFile` does. +fn compiler_for_file(root: &Path, input_path: &str) -> *mut Compiler { + compiler_for(root, Some(input_path), None) +} + +/// Creates a compiler over an in-memory document, the way `TypstCompiler.FromSource` does. +fn compiler_for_source(root: &Path, source: &str) -> *mut Compiler { + compiler_for(root, None, Some(source)) +} + +/// One compilation to a PDF: the error message on failure, the PDF length on success. +fn compile_to_pdf(compiler: *mut Compiler) -> Result { + let result = unsafe { compile(compiler, std::ptr::null(), 96.0, std::ptr::null()) }; + + let outcome = if result.error_ptr.is_null() { + assert_eq!(result.buffers_len, 1, "expected exactly one PDF buffer"); + Ok(unsafe { (*result.buffers).len }) + } else { + Err(unsafe { + String::from_utf8_lossy(std::slice::from_raw_parts( + result.error_ptr, + result.error_len, + )) + .into_owned() + }) + }; + + unsafe { free_compile_result(result) }; + outcome +} + +/// Compiles and fails the test unless the document is rejected. +fn compile_expecting_error(compiler: *mut Compiler) -> String { + match compile_to_pdf(compiler) { + Ok(_) => panic!("an invalid document compiled without an error"), + Err(error) => error, + } +} + +/// Compiles and fails the test unless the document is accepted. +fn compile_expecting_success(compiler: *mut Compiler) -> usize { + match compile_to_pdf(compiler) { + Ok(len) => len, + Err(error) => panic!("compilation failed: {error}"), + } +} + +/// Rewriting the main file has to change what the next compilation sees. The two +/// versions fail on differently named variables, so the error message says which of +/// them was compiled rather than only that the file was read again. +#[test] +fn a_rewritten_main_file_is_compiled_again() { + let project = Project::new("rewritten-main"); + project.write("main.typ", "#before_the_change"); + let compiler = compiler_for_file(&project.root, "main.typ"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("before_the_change"), + "unexpected compiler error: {error}" + ); + + project.write("main.typ", "#after_the_change"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("after_the_change"), + "the compiler kept serving the content it read first: {error}" + ); + + unsafe { free_compiler(compiler) }; +} + +/// Imports are read through the same slots as the main file, so a template that is +/// split across files has to be picked up in the same way. +#[test] +fn a_rewritten_import_is_compiled_again() { + let project = Project::new("rewritten-import"); + project.write( + "letter.typ", + "#import \"salutation.typ\": salutation\n#salutation", + ); + project.write("salutation.typ", "#let salutation = [Dear customer]"); + let compiler = compiler_for_file(&project.root, "letter.typ"); + + assert!( + compile_expecting_success(compiler) > 0, + "produced an empty PDF" + ); + + project.write("salutation.typ", "#let salutation = after_the_change"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("after_the_change"), + "the compiler kept serving the import it read first: {error}" + ); + + unsafe { free_compiler(compiler) }; +} + +/// A failed read is cached like a successful one, so fixing the file on disk has to +/// clear the error as well. Without this a compiler that outlived one broken deploy +/// would keep reporting the same error forever. +#[test] +fn a_file_repaired_on_disk_compiles_again() { + let project = Project::new("repaired-file"); + project.write("report.typ", "#not_yet_defined"); + let compiler = compiler_for_file(&project.root, "report.typ"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("not_yet_defined"), + "unexpected compiler error: {error}" + ); + + project.write("report.typ", "= A working report"); + + assert!( + compile_expecting_success(compiler) > 0, + "produced an empty PDF" + ); + + unsafe { free_compiler(compiler) }; +} + +/// A document handed over as a string is not backed by a file. Re-reading its slot +/// would look for `
` on disk and fail, so the reset has to leave it alone. +#[test] +fn an_in_memory_document_survives_a_second_compilation() { + let project = Project::new("in-memory"); + let compiler = compiler_for_source(&project.root, "= Hello from memory"); + + assert!( + compile_expecting_success(compiler) > 0, + "produced an empty PDF" + ); + assert!( + compile_expecting_success(compiler) > 0, + "the in-memory document was lost between compilations" + ); + + unsafe { free_compiler(compiler) }; +} + +/// The exemption belongs to the one slot that was handed a value, not to every file a +/// document in memory reaches. Its imports still live on disk and still have to be +/// read again. +#[test] +fn an_in_memory_document_still_re_reads_its_imports() { + let project = Project::new("in-memory-import"); + project.write("salutation.typ", "#let salutation = [Dear customer]"); + let compiler = compiler_for_source( + &project.root, + "#import \"salutation.typ\": salutation\n#salutation", + ); + + assert!( + compile_expecting_success(compiler) > 0, + "produced an empty PDF" + ); + + project.write("salutation.typ", "#let salutation = after_the_change"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("after_the_change"), + "the compiler kept serving the import it read first: {error}" + ); + + unsafe { free_compiler(compiler) }; +} + +/// A file reached through `read` is held in a second slot, decoded as bytes rather +/// than as Typst source. That is the slot behind `#image`, `#json` and `#csv`, so it +/// has to follow the file just as the source slot does. +#[test] +fn a_rewritten_data_file_is_read_again() { + let project = Project::new("rewritten-data"); + project.write("report.typ", "#panic(read(\"data.txt\"))"); + project.write("data.txt", "before_the_change"); + let compiler = compiler_for_file(&project.root, "report.typ"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("before_the_change"), + "unexpected compiler error: {error}" + ); + + project.write("data.txt", "after_the_change"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("after_the_change"), + "the compiler kept serving the data it read first: {error}" + ); + + unsafe { free_compiler(compiler) }; +} + +/// A template that disappears has to be reported rather than rendered from memory. A +/// compiler that answered from its first read would hand out a document for a file +/// that is no longer deployed. +#[test] +fn a_deleted_file_is_reported_as_missing() { + let project = Project::new("deleted-file"); + project.write("letter.typ", "= Dear customer"); + let compiler = compiler_for_file(&project.root, "letter.typ"); + + assert!( + compile_expecting_success(compiler) > 0, + "produced an empty PDF" + ); + + project.remove("letter.typ"); + + let error = compile_expecting_error(compiler); + assert!( + error.contains("file not found"), + "unexpected compiler error: {error}" + ); + + unsafe { free_compiler(compiler) }; +} + +/// Handing over both a path and a source is not something the managed wrapper does, +/// but the boundary accepts it. The source wins, for every compilation and not only +/// the first: it occupies the slot the file would otherwise be read into. +#[test] +fn an_in_memory_source_shadows_the_file_it_names() { + let project = Project::new("shadowed-file"); + project.write("letter.typ", "#on_disk"); + let compiler = compiler_for(&project.root, Some("letter.typ"), Some("= In memory")); + + assert!( + compile_expecting_success(compiler) > 0, + "the file on disk was compiled instead of the source in memory" + ); + + project.write("letter.typ", "#still_on_disk"); + + assert!( + compile_expecting_success(compiler) > 0, + "the file on disk was compiled instead of the source in memory" + ); + + unsafe { free_compiler(compiler) }; +} diff --git a/src/typst_core/tests/input_path.rs b/src/typst_core/tests/input_path.rs index 5d12d18..daf754f 100644 --- a/src/typst_core/tests/input_path.rs +++ b/src/typst_core/tests/input_path.rs @@ -46,23 +46,25 @@ fn compiler_for_file(root: &Path, input_path: &str) -> *mut Compiler { let input_path = CString::new(input_path).unwrap(); let sys_inputs = CString::new("{}").unwrap(); - create_compiler( - root.as_ptr(), - input_path.as_ptr(), - std::ptr::null(), - 0, - std::ptr::null::<*const c_char>(), - 0, - std::ptr::null(), - sys_inputs.as_ptr(), - true, - true, - ) + unsafe { + create_compiler( + root.as_ptr(), + input_path.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null::<*const c_char>(), + 0, + std::ptr::null(), + sys_inputs.as_ptr(), + true, + true, + ) + } } /// Compiles to a PDF and returns its length, failing the test on a compiler error. fn compile_to_pdf_len(compiler: *mut Compiler) -> usize { - let result = compile(compiler, std::ptr::null(), 96.0, std::ptr::null()); + let result = unsafe { compile(compiler, std::ptr::null(), 96.0, std::ptr::null()) }; assert!( result.error_ptr.is_null(), @@ -78,7 +80,7 @@ fn compile_to_pdf_len(compiler: *mut Compiler) -> usize { assert_eq!(result.buffers_len, 1, "expected exactly one PDF buffer"); let len = unsafe { (*result.buffers).len }; - free_compile_result(result); + unsafe { free_compile_result(result) }; len } @@ -101,7 +103,7 @@ fn nested_relative_input_path_is_compiled() { ); assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF"); - free_compiler(compiler); + unsafe { free_compiler(compiler) }; } /// The separator is spelled out rather than taken from `Path`, so this stays a @@ -119,7 +121,7 @@ fn windows_separator_in_the_input_path_is_compiled() { ); assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF"); - free_compiler(compiler); + unsafe { free_compiler(compiler) }; } /// A path that steps out of a subfolder and back in never leaves the root, so it @@ -139,7 +141,7 @@ fn input_path_leaving_and_reentering_the_root_is_compiled() { assert!(!compiler.is_null(), "`{input_path}` was rejected"); assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF"); - free_compiler(compiler); + unsafe { free_compiler(compiler) }; } /// The same file named absolutely resolves to the same document. @@ -152,7 +154,7 @@ fn absolute_input_path_inside_the_root_is_compiled() { assert!(!compiler.is_null(), "absolute input path was rejected"); assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF"); - free_compiler(compiler); + unsafe { free_compiler(compiler) }; } /// A relative path that climbs out of the root has to be refused. Reaching outside diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs index 4a5b967..96dd0e2 100644 --- a/src/typstsharp.tests/Tests.cs +++ b/src/typstsharp.tests/Tests.cs @@ -149,6 +149,50 @@ public async Task NestedRelativeInputPathWithoutARootIsCompiled() } } + /// + /// Caching a compiler is the recommended way to serve documents, and templates get + /// redeployed underneath a long-running process. A compiler that pinned the content + /// it read first would keep rendering the retired template with no error to show for + /// it. + /// + [Test] + public async Task ReusedCompilerRendersATemplateRewrittenOnDisk() + { + using var project = new ProjectDirectory(); + project.AddTemplate("letter.typ", "= Dear customer"); + + using var compiler = TypstCompiler.FromFile("letter.typ", root: project.Path); + await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Dear customer"); + + project.AddTemplate("letter.typ", "= Dear supplier"); + + await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Dear supplier"); + } + + /// + /// A template is usually split across files, and the imports are read through the + /// same mechanism as the main file. + /// + [Test] + public async Task ReusedCompilerRendersAnImportRewrittenOnDisk() + { + const string letter = """ + #import "salutation.typ": salutation + = #salutation + """; + + using var project = new ProjectDirectory(); + project.AddTemplate("letter.typ", letter); + project.AddTemplate("salutation.typ", "#let salutation = \"Dear customer\""); + + using var compiler = TypstCompiler.FromFile("letter.typ", root: project.Path); + await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Dear customer"); + + project.AddTemplate("salutation.typ", "#let salutation = \"Dear supplier\""); + + await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Dear supplier"); + } + [Test] public async Task CompilePdfToFileAndAsync() { @@ -479,6 +523,32 @@ public async Task BundledPackageResolvesWithSystemPackagesExcluded() await Assert.That(plainText).Contains("Hello from a bundled package"); } + /// + /// A deployment that vendors its templates as local packages redeploys them the same + /// way it redeploys a bare .typ file, so a reused compiler has to pick up the new + /// contents of a package it has already resolved. + /// + [Test] + public async Task ReusedCompilerRendersABundledPackageRewrittenOnDisk() + { + using var packages = new PackageDirectory(); + packages.AddPackage("local", "greet", "0.1.0", "#let greet() = [Hello from the first version]"); + + using var compiler = TypstCompiler.FromSource( + """ + #import "@local/greet:0.1.0": greet + #greet() + """, + packagePath: packages.Path, + includeSystemPackages: false); + + await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Hello from the first version"); + + packages.AddPackage("local", "greet", "0.1.0", "#let greet() = [Hello from the second version]"); + + await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Hello from the second version"); + } + /// /// `@preview/example:0.1.0` is published on Typst Universe, so this compiles only if the /// registry is reachable. Excluding system packages has to turn it into a hard failure diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs index bbf1b22..5d18c12 100644 --- a/src/typstsharp/TypstCompiler.cs +++ b/src/typstsharp/TypstCompiler.cs @@ -34,6 +34,13 @@ public class TypstCompiler : IDisposable /// that directory, which keeps compilation off the network. /// /// Thrown when the Typst compiler fails to initialize. + /// + /// The compiler may be kept and compiled repeatedly, which is what makes its incremental + /// cache worthwhile. Each compilation reads the file, and everything it imports, from disk + /// again, so a template rewritten underneath a running process takes effect. Write templates + /// atomically: a compilation that lands halfway through a plain overwrite renders whatever + /// the file held at that moment. + /// public TypstCompiler(string inputPath, Fonts? fonts = null, Dictionary? sysInputs = null, string? root = null, string? packagePath = null, bool includeSystemPackages = true) : this(inputPath, null, fonts, sysInputs, root, packagePath, includeSystemPackages) { @@ -72,6 +79,13 @@ public static TypstCompiler FromSource(string source, Fonts? fonts = null, Dicti /// that directory, which keeps compilation off the network. /// /// A new instance. + /// + /// The compiler may be kept and compiled repeatedly, which is what makes its incremental + /// cache worthwhile. Each compilation reads the file, and everything it imports, from disk + /// again, so a template rewritten underneath a running process takes effect. Write templates + /// atomically: a compilation that lands halfway through a plain overwrite renders whatever + /// the file held at that moment. + /// public static TypstCompiler FromFile(string path, Fonts? fonts = null, Dictionary? sysInputs = null, string? root = null, string? packagePath = null, bool includeSystemPackages = true) { return new TypstCompiler(path, null, fonts, sysInputs, root, packagePath, includeSystemPackages);