diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 2962355ec..6a5ca21e0 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -85,7 +85,7 @@ pub fn compile_once( match result { // Export the PDF / PNG. Ok(document) => { - export(&document, command)?; + export(world, &document, command, watching)?; let duration = start.elapsed(); tracing::info!("Compilation succeeded in {duration:?}"); @@ -128,10 +128,19 @@ pub fn compile_once( } /// Export into the target format. -fn export(document: &Document, command: &CompileCommand) -> StrResult<()> { +fn export( + world: &mut SystemWorld, + document: &Document, + command: &CompileCommand, + watching: bool, +) -> StrResult<()> { match command.output_format()? { - OutputFormat::Png => export_image(document, command, ImageExportFormat::Png), - OutputFormat::Svg => export_image(document, command, ImageExportFormat::Svg), + OutputFormat::Png => { + export_image(world, document, command, watching, ImageExportFormat::Png) + } + OutputFormat::Svg => { + export_image(world, document, command, watching, ImageExportFormat::Svg) + } OutputFormat::Pdf => export_pdf(document, command), } } @@ -153,8 +162,10 @@ enum ImageExportFormat { /// Export to one or multiple PNGs. fn export_image( + world: &mut SystemWorld, document: &Document, command: &CompileCommand, + watching: bool, fmt: ImageExportFormat, ) -> StrResult<()> { // Determine whether we have a `{n}` numbering. @@ -171,6 +182,7 @@ fn export_image( let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize; let mut storage; + let cache = world.export_cache(); for (i, frame) in document.pages.iter().enumerate() { let path = if numbered { storage = string.replace("{n}", &format!("{:0width$}", i + 1)); @@ -178,6 +190,14 @@ fn export_image( } else { output.as_path() }; + + // If we are not watching, don't use the cache. + // If the frame is in the cache, skip it. + // If the file does not exist, always create it. + if watching && cache.is_cached(i, frame) && path.exists() { + continue; + } + match fmt { ImageExportFormat::Png => { let pixmap = @@ -188,7 +208,7 @@ fn export_image( } ImageExportFormat::Svg => { let svg = typst::export::svg(frame); - fs::write(path, svg) + fs::write(path, svg.as_bytes()) .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; } } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index bd9ef414b..500b64e59 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -10,9 +10,11 @@ use filetime::FileTime; use same_file::Handle; use siphasher::sip128::{Hasher128, SipHasher13}; use typst::diag::{FileError, FileResult, StrResult}; +use typst::doc::Frame; use typst::eval::{eco_format, Bytes, Datetime, Library}; use typst::font::{Font, FontBook}; use typst::syntax::{FileId, Source, VirtualPath}; +use typst::util::hash128; use typst::World; use crate::args::SharedArgs; @@ -42,6 +44,9 @@ pub struct SystemWorld { /// The current datetime if requested. This is stored here to ensure it is /// always the same within one compilation. Reset between compilations. now: OnceCell>, + /// The export cache, used for caching output files in `typst watch` + /// sessions. + export_cache: ExportCache, } impl SystemWorld { @@ -81,6 +86,7 @@ impl SystemWorld { hashes: RefCell::default(), slots: RefCell::default(), now: OnceCell::new(), + export_cache: ExportCache::new(), }) } @@ -122,6 +128,11 @@ impl SystemWorld { pub fn lookup(&self, id: FileId) -> Source { self.source(id).expect("file id does not point to any source file") } + + /// Gets access to the export cache. + pub fn export_cache(&mut self) -> &mut ExportCache { + &mut self.export_cache + } } impl World for SystemWorld { @@ -326,6 +337,38 @@ impl PathHash { } } +/// Caches exported files so that we can avoid re-exporting them if they haven't +/// changed. +/// +/// This is done by having a list of size `files.len()` that contains the hashes +/// of the last rendered frame in each file. If a new frame is inserted, this +/// will invalidate the rest of the cache, this is deliberate as to decrease the +/// complexity and memory usage of such a cache. +pub struct ExportCache { + /// The hashes of last compilation's frames. + pub cache: Vec, +} + +impl ExportCache { + /// Creates a new export cache. + pub fn new() -> Self { + Self { cache: Vec::with_capacity(32) } + } + + /// Returns true if the entry is cached and appends the new hash to the + /// cache (for the next compilation). + pub fn is_cached(&mut self, i: usize, frame: &Frame) -> bool { + let hash = hash128(frame); + + if i >= self.cache.len() { + self.cache.push(hash); + return false; + } + + std::mem::replace(&mut self.cache[i], hash) == hash + } +} + /// Read a file. fn read(path: &Path) -> FileResult> { let f = |e| FileError::from_io(e, path); diff --git a/crates/typst/src/export/pdf/font.rs b/crates/typst/src/export/pdf/font.rs index bd7cfb312..d99927042 100644 --- a/crates/typst/src/export/pdf/font.rs +++ b/crates/typst/src/export/pdf/font.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::sync::Arc; use ecow::{eco_format, EcoString}; use pdf_writer::types::{CidFontType, FontFlags, SystemInfo, UnicodeCmap}; @@ -7,7 +8,6 @@ use ttf_parser::{name_id, GlyphId, Tag}; use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; use super::{deflate, EmExt, PdfContext}; -use crate::eval::Bytes; use crate::font::Font; use crate::util::SliceExt; @@ -168,7 +168,7 @@ pub fn write_fonts(ctx: &mut PdfContext) { /// - For a font with TrueType outlines, this returns the whole OpenType font. /// - For a font with CFF outlines, this returns just the CFF font program. #[comemo::memoize] -fn subset_font(font: &Font, glyphs: &[u16]) -> Bytes { +fn subset_font(font: &Font, glyphs: &[u16]) -> Arc> { let data = font.data(); let profile = subsetter::Profile::pdf(glyphs); let subsetted = subsetter::subset(data, font.index(), profile); @@ -180,7 +180,7 @@ fn subset_font(font: &Font, glyphs: &[u16]) -> Bytes { data = cff; } - deflate(data).into() + Arc::new(deflate(data)) } /// Produce a unique 6 letter tag for a glyph set. diff --git a/crates/typst/src/export/pdf/page.rs b/crates/typst/src/export/pdf/page.rs index c36bed4e8..5e6cd2a2c 100644 --- a/crates/typst/src/export/pdf/page.rs +++ b/crates/typst/src/export/pdf/page.rs @@ -1,4 +1,5 @@ use std::num::NonZeroUsize; +use std::sync::Arc; use ecow::{eco_format, EcoString}; use pdf_writer::types::{ @@ -184,10 +185,16 @@ fn write_page(ctx: &mut PdfContext, i: usize) { annotations.finish(); page_writer.finish(); - let data = deflate(&page.content); + let data = deflate_content(&page.content); ctx.pdf.stream(content_id, &data).filter(Filter::FlateDecode); } +/// Memoized version of [`deflate`] specialized for a page's content stream. +#[comemo::memoize] +fn deflate_content(content: &[u8]) -> Arc> { + Arc::new(deflate(content)) +} + /// Data for an exported page. pub struct Page { /// The indirect object id of the page. diff --git a/tests/src/tests.rs b/tests/src/tests.rs index bdbfd3979..1aed32d7d 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -437,7 +437,7 @@ fn test( let svg = typst::export::svg_merged(&document.pages, Abs::pt(5.0)); fs::create_dir_all(svg_path.parent().unwrap()).unwrap(); - std::fs::write(svg_path, svg).unwrap(); + std::fs::write(svg_path, svg.as_bytes()).unwrap(); if let Ok(ref_pixmap) = sk::Pixmap::load_png(ref_path) { if canvas.width() != ref_pixmap.width()