diff --git a/Cargo.lock b/Cargo.lock index 4cd4ad8..473ce10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2915,6 +2915,7 @@ dependencies = [ "tracing", "tracing-subscriber", "typst", + "typst-bundle", "typst-html", "typst-kit", "typst-layout", diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9163eb4..5a61ebc 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -247,66 +247,12 @@ fn resolve_build_dir( } } -fn get_output_filename(typ_file: &std::path::Path) -> Result { - typ_file - .file_stem() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - .ok_or_else(|| RheoError::project_config(format!("invalid .typ filename: {:?}", typ_file))) -} - -/// Per-plugin invariants shared across all files in a single-plugin compilation pass. -struct PerFileCtx<'a> { - plugin: &'a dyn FormatPlugin, - plugin_output_dir: &'a Path, - project: &'a ProjectConfig, - output_config: &'a OutputConfig, - spine: &'a TracedSpine, - plugin_section: &'a PluginSection, - resolved_inputs: &'a HashMap<&'static str, PathBuf>, - content_dir: &'a Path, -} - -/// Compile one file with the given world, recording success/failure in `results`. -/// -/// `get_output_filename` errors propagate; `plugin.compile()` errors are recorded -/// as failures rather than propagated (so other files in the batch still compile). -fn compile_one_file( - world: &mut RheoWorld, - typ_file: &Path, - pfc: &PerFileCtx<'_>, - results: &mut CompilationResults, -) -> Result<()> { - let filename = get_output_filename(typ_file)?; - let output_path = pfc - .plugin_output_dir - .join(&filename) - .with_extension(pfc.plugin.output_extension()); - let options = RheoCompileOptions::new(&output_path, pfc.content_dir, world); - let ctx = PluginContext { - project: pfc.project, - output_config: pfc.output_config, - options, - spine: pfc.spine.clone(), - config: pfc.plugin_section.clone(), - inputs: pfc.resolved_inputs.clone(), - }; - match pfc.plugin.compile(ctx) { - Ok(_) => results.record_success(pfc.plugin.name()), - Err(e) => { - error!(file = %typ_file.display(), error = %e, "{} compilation failed", pfc.plugin.name()); - results.record_failure(pfc.plugin.name()); - } - } - Ok(()) -} - /// Bundle compilation: generate bundle entry, inject into world, compile once. -/// Used by plugins that use the bundle API (HTML, PDF non-merge). +/// Used by all plugins (HTML, PDF, EPUB) with the bundle API. #[allow(clippy::too_many_arguments)] -fn compile_bundle( +fn compile_with_bundle( plugin: &dyn FormatPlugin, - plugin_output_dir: &Path, + output: &Path, project: &ProjectConfig, output_config: &OutputConfig, spine: &TracedSpine, @@ -334,7 +280,7 @@ fn compile_bundle( ); bundle_world.inject_bundle_entry(bundle_entry_source); - let options = RheoCompileOptions::new(plugin_output_dir, &project.root, &mut bundle_world); + let options = RheoCompileOptions::new(output, compilation_root, &mut bundle_world); let ctx = PluginContext { project, @@ -357,115 +303,10 @@ fn compile_bundle( Ok(()) } -/// Merged compilation: single output from all spine documents. -/// Used by PDF merge mode and EPUB. -#[allow(clippy::too_many_arguments)] -fn compile_merged( - plugin: &dyn FormatPlugin, - plugin_output_dir: &Path, - project: &ProjectConfig, - output_config: &OutputConfig, - spine: &TracedSpine, - plugin_section: &PluginSection, - resolved_inputs: HashMap<&'static str, PathBuf>, - results: &mut CompilationResults, - compilation_root: &Path, -) -> Result<()> { - let output_path = plugin_output_dir - .join(&project.name) - .with_extension(plugin.output_extension()); - - let plugin_library = plugin.typst_library().map(|s| s.to_string()); - let mut bundle_world = RheoWorld::new( - compilation_root, - spine - .documents - .first() - .map(|d| d.path.as_path()) - .unwrap_or(compilation_root), - plugin_library, - )?; - - let bundle_entry_source = generate_bundle_entry( - spine, - compilation_root, - plugin.name(), - plugin.typst_library().unwrap_or_default(), - ); - bundle_world.inject_bundle_entry(bundle_entry_source); - - let options = RheoCompileOptions::new(&output_path, compilation_root, &mut bundle_world); - - let ctx = PluginContext { - project, - output_config, - options, - spine: spine.clone(), - config: plugin_section.clone(), - inputs: resolved_inputs, - }; - - match plugin.compile(ctx) { - Ok(_) => { - results.record_success(plugin.name()); - } - Err(e) => { - error!(error = %e, "{} generation failed", plugin.name()); - results.record_failure(plugin.name()); - } - } - Ok(()) -} - -/// Per-file compilation: loop through spine documents, compile each individually. -/// Used by PDF non-merge mode. -#[allow(clippy::too_many_arguments)] -fn compile_per_file( - plugin: &dyn FormatPlugin, - plugin_output_dir: &Path, - project: &ProjectConfig, - output_config: &OutputConfig, - spine: &TracedSpine, - plugin_section: &PluginSection, - resolved_inputs: &HashMap<&'static str, PathBuf>, - content_dir: &Path, - world: &mut Option<&mut RheoWorld>, - results: &mut CompilationResults, -) -> Result<()> { - let files: Vec = spine.documents.iter().map(|d| d.path.clone()).collect(); - - let pfc = PerFileCtx { - plugin, - plugin_output_dir, - project, - output_config, - spine, - plugin_section, - resolved_inputs, - content_dir, - }; - - if let Some(ref mut existing_world) = *world { - for typ_file in &files { - existing_world.set_main(typ_file)?; - existing_world.reset(); - compile_one_file(existing_world, typ_file, &pfc, results)?; - } - } else { - let plugin_library = plugin.typst_library().map(|s| s.to_string()); - for typ_file in &files { - let mut fresh_world = RheoWorld::new(content_dir, typ_file, plugin_library.clone())?; - compile_one_file(&mut fresh_world, typ_file, &pfc, results)?; - } - } - Ok(()) -} - fn perform_compilation( project: &ProjectConfig, output_config: &OutputConfig, plugins: &[Box], - mut world: Option<&mut RheoWorld>, ) -> Result<()> { if project.typ_files.is_empty() { return Err(RheoError::project_config("no .typ files found in project")); @@ -605,59 +446,29 @@ fn perform_compilation( )? }; - // For non-single-file mode, use compilation_root; for single file mode, use file's parent - let content_dir = if project.mode == ProjectMode::SingleFile { - // For single file mode, use the file's parent directory - project.typ_files[0] - .parent() - .unwrap_or(&project.root) - .to_path_buf() - } else { - compilation_root.clone() - }; - // Get full plugin section let plugin_section = project.config.plugin_section(plugin.name()); - // Dispatch to appropriate compilation path - if !spine.merge && plugin.uses_bundle_api() { - compile_bundle( - plugin.as_ref(), - &plugin_output_dir, - project, - output_config, - &spine, - &plugin_section, - resolved_inputs, - &mut results, - &compilation_root, - )?; - } else if spine.merge { - compile_merged( - plugin.as_ref(), - &plugin_output_dir, - project, - output_config, - &spine, - &plugin_section, - resolved_inputs, - &mut results, - &compilation_root, - )?; + // Determine output path based on merge mode + let output = if spine.merge { + plugin_output_dir + .join(&project.name) + .with_extension(plugin.output_extension()) } else { - compile_per_file( - plugin.as_ref(), - &plugin_output_dir, - project, - output_config, - &spine, - &plugin_section, - &resolved_inputs, - &content_dir, - &mut world, - &mut results, - )?; - } + plugin_output_dir.clone() + }; + + compile_with_bundle( + plugin.as_ref(), + &output, + project, + output_config, + &spine, + &plugin_section, + resolved_inputs, + &mut results, + &compilation_root, + )?; } let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect(); @@ -846,7 +657,7 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { )?; // Initial compilation (best-effort; watch continues on failure) - if let Err(e) = perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None) { + if let Err(e) = perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) { warn!(error = %e, "initial compilation failed"); } @@ -873,8 +684,7 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { match event { WatchEvent::FilesChanged => { info!("files changed, recompiling"); - if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None).is_ok() - { + if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins).is_ok() { for handle in &open_handles { if let OpenHandle::Server(server) = handle { server.reload(); @@ -892,7 +702,7 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { ) { Ok(new_ctx) => { ctx = new_ctx; - if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None) + if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) .is_ok() { for handle in &open_handles { @@ -920,7 +730,7 @@ fn run_compile(sub: &ArgMatches) -> Result<()> { let ctx = setup_compilation_context(&path, config.as_deref(), build_dir, enabled)?; - perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None) + perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) } fn run_clean(sub: &ArgMatches) -> Result<()> { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bb5392d..dffcb14 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,6 +16,7 @@ typst-library = { workspace = true } typst-kit = { workspace = true } typst-syntax = { workspace = true } typst-layout = { workspace = true } +typst-bundle = { workspace = true } comemo = { workspace = true } # Error handling and diagnostics diff --git a/crates/core/src/bundle_compile.rs b/crates/core/src/bundle_compile.rs new file mode 100644 index 0000000..9cad9ca --- /dev/null +++ b/crates/core/src/bundle_compile.rs @@ -0,0 +1,42 @@ +use crate::Result; +use crate::RheoError; +use crate::RheoWorld; +use crate::diagnostics::print_diagnostics; +use typst::diag::Warned; + +/// Compile and export a Typst bundle to file bytes. +/// +/// This helper function consolidates the common bundle compilation and export +/// logic used by both HTML and PDF plugins. It compiles the bundle using the +/// world, prints diagnostics, and returns the exported file bytes. +/// +/// # Arguments +/// * `world` - The RheoWorld for compilation context +/// +/// # Returns +/// A vector of (filename, bytes) pairs representing the exported bundle files +/// +/// # Errors +/// Returns `RheoError::project_config` if compilation or export fails +pub fn export_typst_bundle(world: &RheoWorld) -> Result)>> { + let Warned { output, warnings } = typst::compile::(world); + let _ = print_diagnostics(world, &[], &warnings); + let bundle = output.map_err(|errors| { + let _ = print_diagnostics(world, &errors[..], &[]); + let msgs: Vec = errors.iter().map(|e| e.message.to_string()).collect(); + RheoError::project_config(format!( + "bundle compilation had errors: {}", + msgs.join(", ") + )) + })?; + let bundle_options = typst_bundle::BundleOptions { + pixel_per_pt: 144.0, + pdf: typst_pdf::PdfOptions::default(), + }; + let fs = typst_bundle::export(&bundle, &bundle_options) + .map_err(|e| RheoError::project_config(format!("bundle export failed: {:?}", e)))?; + Ok(fs + .into_iter() + .map(|(p, b)| (p.get_without_slash().to_string(), b.to_vec())) + .collect()) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cbb1e69..1157590 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bundle_compile; pub mod compile; pub mod config; pub mod constants; @@ -71,6 +72,9 @@ pub use reticulate::spine::generate_bundle_entry; // PDF utilities pub use pdf_utils::DocumentTitle; +// Bundle compilation helper +pub use bundle_compile::export_typst_bundle; + // Typst types (commonly used by plugins) pub use typst_types::{ EcoString, HeadingElem, HtmlDocument, NativeElement, OutlineNode, StyleChain, eco_format, diff --git a/crates/core/src/plugins/mod.rs b/crates/core/src/plugins/mod.rs index 212e495..a1f6fc7 100644 --- a/crates/core/src/plugins/mod.rs +++ b/crates/core/src/plugins/mod.rs @@ -153,21 +153,6 @@ pub trait FormatPlugin: Send + Sync { /// Whether this plugin uses the Typst bundle API for compilation. /// - /// Override to return `true` for formats that use `typst::compile::()`. - /// When `true`, the CLI generates a bundle entry and injects it into the world - /// before compilation. When `false`, the plugin uses per-file compilation. - /// - /// # Examples - /// - /// ```ignore - /// fn uses_bundle_api(&self) -> bool { - /// true // HTML and PDF use bundle compilation - /// } - /// ``` - fn uses_bundle_api(&self) -> bool { - false - } - /// Set plugin-specific smart defaults when no rheo.toml section exists. /// /// Called by the CLI after loading a project when the plugin's section is not diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs index c12db4f..b4a2189 100644 --- a/crates/html/src/lib.rs +++ b/crates/html/src/lib.rs @@ -8,12 +8,10 @@ pub const DEFAULT_STYLESHEET: &str = include_str!("templates/style.css"); use rheo_core::{ FormatPlugin, OpenHandle, PluginContext, PluginSection, Result, RheoCompileOptions, RheoError, - ServerHandle, diagnostics::print_diagnostics, + ServerHandle, export_typst_bundle, }; use std::path::Path; use tracing::{debug, info, warn}; -use typst::diag::Warned; -use typst_pdf::PdfOptions; /// Reload callback type - called by watch loop after successful compilation. /// Defined here because it's only needed by the HTML plugin's development server. @@ -73,10 +71,6 @@ impl FormatPlugin for HtmlPlugin { "html" } - fn uses_bundle_api(&self) -> bool { - true - } - fn init_templates(&self) -> Vec<(&'static str, &'static str)> { vec![("style.css", include_str!("templates/style.css"))] } @@ -125,38 +119,14 @@ fn compile_html_bundle(options: RheoCompileOptions, config: &PluginSection) -> R info!("compiling HTML bundle"); - // Compile the bundle using the world (which has the synthetic bundle entry as main) - let Warned { output, warnings } = typst::compile::(options.world); - - // Print warnings (ignore errors from diagnostic printing) - let _ = print_diagnostics(options.world, &[], &warnings); - - let bundle = output.map_err(|errors| { - // Print errors to stderr with proper formatting - let _ = print_diagnostics(options.world, &errors, &[]); - // Return error for error handling - let error_messages: Vec = errors.iter().map(|e| e.message.to_string()).collect(); - RheoError::project_config(format!( - "bundle compilation had errors: {}", - error_messages.join(", ") - )) - })?; - - // Export the bundle to get HTML files - let bundle_options = typst_bundle::BundleOptions { - pixel_per_pt: 144.0, - pdf: PdfOptions::default(), - }; - - let fs = typst_bundle::export(&bundle, &bundle_options) - .map_err(|e| RheoError::project_config(format!("bundle export failed: {:?}", e)))?; + // Compile and export the bundle using the core helper + let fs = export_typst_bundle(options.world)?; debug!(file_count = fs.len(), "exported HTML bundle"); // Write each HTML file and web asset to the output directory. // Skip .pdf files: those belong to the PDF plugin's output directory. - for (vpath, bytes) in &fs { - let filename = vpath.get_without_slash(); + for (filename, bytes) in &fs { if filename.ends_with(".pdf") { continue; } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 81657c7..295ae9d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1,13 +1,6 @@ -use rheo_core::{ - FormatPlugin, PluginContext, Result, RheoError, RheoWorld, diagnostics::print_diagnostics, -}; +use rheo_core::{FormatPlugin, PluginContext, Result, RheoError, RheoWorld, export_typst_bundle}; use std::path::Path; use tracing::{debug, info}; -use typst::diag::Warned; -use typst_pdf::PdfOptions; - -/// PDF pixel-per-point ratio: 2x the standard 72 DPI for quality printing. -const PDF_PIXEL_PER_PT: f32 = 144.0; pub struct PdfPlugin; @@ -16,10 +9,6 @@ impl FormatPlugin for PdfPlugin { "pdf" } - fn uses_bundle_api(&self) -> bool { - true - } - fn typst_library(&self) -> Option<&'static str> { // PDF-specific lemma function for numbered lemmas in academic documents Some( @@ -57,37 +46,14 @@ fn compile_pdf_bundle_impl(world: &RheoWorld, output_path: &Path, merge: bool) - fn compile_pdf_merged_bundle(world: &RheoWorld, output_path: &Path) -> Result<()> { info!("compiling merged PDF bundle"); - // Compile the bundle using the world (which has the synthetic bundle entry as main) - let Warned { output, warnings } = typst::compile::(world); - - // Print warnings (ignore errors from diagnostic printing) - let _ = print_diagnostics(world, &[], &warnings); - - let bundle = output.map_err(|errors| { - // Print errors to stderr with proper formatting - let _ = print_diagnostics(world, &errors, &[]); - // Return error for error handling - let error_messages: Vec = errors.iter().map(|e| e.message.to_string()).collect(); - RheoError::project_config(format!( - "bundle compilation had errors: {}", - error_messages.join(", ") - )) - })?; - - // Export the bundle to get PDF files - let bundle_options = typst_bundle::BundleOptions { - pixel_per_pt: PDF_PIXEL_PER_PT, - pdf: PdfOptions::default(), - }; - - let fs = typst_bundle::export(&bundle, &bundle_options) - .map_err(|e| RheoError::project_config(format!("bundle export failed: {:?}", e)))?; + // Compile and export the bundle using the core helper + let fs = export_typst_bundle(world)?; debug!(file_count = fs.len(), "exported PDF bundle"); // For merged PDF, the bundle produces a single PDF file // Export it and write to the output path - let (_vpath, pdf_bytes) = fs + let (_filename, pdf_bytes) = fs .into_iter() .next() .ok_or_else(|| RheoError::invalid_data("bundle produced no output"))?; @@ -106,31 +72,8 @@ fn compile_pdf_merged_bundle(world: &RheoWorld, output_path: &Path) -> Result<() fn compile_pdf_per_file_bundle(world: &RheoWorld, output_dir: &Path) -> Result<()> { info!("compiling per-file PDF bundle"); - // Compile the bundle using the world - let Warned { output, warnings } = typst::compile::(world); - - // Print warnings (ignore errors from diagnostic printing) - let _ = print_diagnostics(world, &[], &warnings); - - let bundle = output.map_err(|errors| { - // Print errors to stderr with proper formatting - let _ = print_diagnostics(world, &errors, &[]); - // Return error for error handling - let error_messages: Vec = errors.iter().map(|e| e.message.to_string()).collect(); - RheoError::project_config(format!( - "bundle compilation had errors: {}", - error_messages.join(", ") - )) - })?; - - // Export the bundle to get PDF files - let bundle_options = typst_bundle::BundleOptions { - pixel_per_pt: PDF_PIXEL_PER_PT, - pdf: PdfOptions::default(), - }; - - let fs = typst_bundle::export(&bundle, &bundle_options) - .map_err(|e| RheoError::project_config(format!("bundle export failed: {:?}", e)))?; + // Compile and export the bundle using the core helper + let fs = export_typst_bundle(world)?; debug!(file_count = fs.len(), "exported PDF bundle"); @@ -138,8 +81,7 @@ fn compile_pdf_per_file_bundle(world: &RheoWorld, output_dir: &Path) -> Result<( // Filter to .pdf files only: bundles that also target HTML will include HTML files // and assets in the export; writing those to the PDF output dir would corrupt it. let mut file_count = 0; - for (vpath, bytes) in fs { - let filename = vpath.get_without_slash(); + for (filename, bytes) in fs { if !filename.ends_with(".pdf") { continue; }