diff --git a/README.md b/README.md index 3bac4b5..64fe2b2 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,23 @@ var pngResult = compiler.CompilePng(ppi: 300); byte[] page1Png = pngResult[0]; ``` +#### Merged SVG +`CompileMergedSvg()` renders every page into a single SVG, stacked top to bottom, like Typst's +`svg_merged` export. The image is as wide as the widest page, and `gapInPoints` sets the distance +between neighbouring pages (default `0`): + +```csharp +var source = "= Page 1\n#pagebreak()\n= Page 2"; + +using var compiler = TypstCompiler.FromSource(source); +string merged = compiler.CompileMergedSvg(gapInPoints: 10); + +// Or in one call, saved to a file +await TypstCompiler.CompileMergedSvg(source, gapInPoints: 10).SaveAsync("document.svg"); +``` + +The generic APIs accept it as the `svg-merged` format, e.g. `compiler.Compile("document.svg", "svg-merged", mergedGap: 10)`. + ### Zero-copy output `CompilePdf()` copies the rendered document onto the managed heap, which puts a multi-megabyte PDF on @@ -168,9 +185,9 @@ The document owns the native memory and must be disposed. Streams from `OpenOutp alive and throw once it is disposed, so passing one to an SDK is safe. Spans from `GetOutputSpan()` do not, so they must not outlive the `using` block. -A document is a list of output buffers rather than pages: PDF export produces one buffer for the -whole document however many pages it has, while PNG and SVG produce one per page. `OutputCount` says -how many there are, and every accessor takes an output index. +A document is a list of output buffers rather than pages: PDF and merged SVG export produce one +buffer for the whole document however many pages it has, while PNG and SVG produce one per page. +`OutputCount` says how many there are, and every accessor takes an output index. ### Packages diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a9d1263..0b49a44 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,6 +12,7 @@ - `compiler.Compile(outputFile, format)` and `compiler.CompileSvg(...)` no longer copy the rendered output onto the managed heap before writing or decoding it. ### Added + - Added merged SVG export, rendering every page into a single SVG stacked top to bottom as Typst's `svg_merged` does. Use `compiler.CompileMergedSvg(gapInPoints)`, the static `TypstCompiler.CompileMergedSvg(...)` / `TypstCompiler.CompileMergedSvgFromFile(...)`, or the `svg-merged` format with the new `mergedGap` parameter on `CompileToDocument` and `Compile(outputFile, format)`. The gap between pages is given in points. - Added `compiler.CompileToDocument(...)`, returning a disposable `TypstDocument` that exposes the rendered output while it is still in the memory the native library allocated. `GetOutputSpan`, `OpenOutputStream`, `CopyOutputTo`, `WriteOutputToFile` and `RentOutput` read it without putting a multi-megabyte PDF on the large object heap; `GetOutputBytes` copies when a `byte[]` is what you need. - Added easier PDF compilation APIs on `TypstCompiler`: - `compiler.CompilePdf(...)` returning a `PdfResult` (with implicit conversion to `byte[]`, `ReadOnlySpan`, and `ReadOnlyMemory`). diff --git a/src/typst_core/src/compiler.rs b/src/typst_core/src/compiler.rs index 225c43a..423c52e 100644 --- a/src/typst_core/src/compiler.rs +++ b/src/typst_core/src/compiler.rs @@ -1,5 +1,6 @@ use ecow::eco_format; use typst::diag::StrResult; +use typst::layout::Abs; use typst_layout::PagedDocument; /// An image format to export in. @@ -8,6 +9,13 @@ pub enum ImageExportFormat { Svg, } +fn svg_options() -> typst_svg::SvgOptions { + typst_svg::SvgOptions { + render_bleed: false, + pretty: false, + } +} + /// Export the frames to PNGs or SVGs. fn export_image( document: &PagedDocument, @@ -27,13 +35,7 @@ fn export_image( .encode_png() .map_err(|err| eco_format!("failed to write PNG file ({err})"))?, ImageExportFormat::Svg => { - let svg = typst_svg::svg( - page, - &typst_svg::SvgOptions { - render_bleed: false, - pretty: false, - }, - ); + let svg = typst_svg::svg(page, &svg_options()); svg.as_bytes().to_vec() } }; @@ -42,6 +44,19 @@ fn export_image( Ok(buffers) } +/// Export every page into one SVG, stacked top to bottom with `gap` points between +/// neighbouring pages. The image is as wide as the widest page. +pub fn export_svg_merged(document: &PagedDocument, gap: f32) -> StrResult> { + if !gap.is_finite() || gap < 0.0 { + return Err(eco_format!( + "the gap between merged SVG pages must be a finite, non-negative length, got {gap}" + )); + } + + let svg = typst_svg::svg_merged(document, &svg_options(), Abs::pt(gap as f64)); + Ok(svg.into_bytes()) +} + /// Export to a PDF. #[inline] pub fn export_pdf( @@ -85,12 +100,14 @@ pub fn export( document: &PagedDocument, format: &str, ppi: f32, + merged_gap: f32, standards: &[typst_pdf::PdfStandard], ) -> StrResult>> { match format { "pdf" => export_pdf(document, standards).map(|pdf| vec![pdf]), "png" => export_image(document, ImageExportFormat::Png, ppi), "svg" => export_image(document, ImageExportFormat::Svg, ppi), + "svg-merged" => export_svg_merged(document, merged_gap).map(|svg| vec![svg]), _ => Err(eco_format!("unknown export format: {}", format)), } } diff --git a/src/typst_core/src/lib.rs b/src/typst_core/src/lib.rs index 9391eac..962c7d3 100644 --- a/src/typst_core/src/lib.rs +++ b/src/typst_core/src/lib.rs @@ -37,7 +37,8 @@ use world::SystemWorld; /// cache can be reused. pub struct Compiler(SystemWorld); -/// One rendered output: the whole document for PDF export, one page for PNG and SVG. +/// One rendered output: the whole document for PDF and merged SVG export, one page for PNG and +/// SVG. /// /// The bytes are owned by the [`CompileResult`] that contains this buffer and are freed by /// [`free_compile_result`]. They are not NUL-terminated; `len` is the only length. @@ -238,6 +239,7 @@ fn compile_inner( world: &mut SystemWorld, format: &str, ppi: f32, + merged_gap: f32, standards: &[typst_pdf::PdfStandard], ) -> StrResult<(Vec>, Vec)> { world.reset_time(); @@ -291,7 +293,7 @@ fn compile_inner( } }; - let buffers = compiler::export(&document, format, ppi, standards)?; + let buffers = compiler::export(&document, format, ppi, merged_gap, standards)?; Ok((buffers, warnings)) } @@ -372,6 +374,7 @@ fn compile_internal( compiler: *mut Compiler, format_ptr: *const std::os::raw::c_char, ppi: f32, + merged_gap: f32, pdf_standards: *const std::os::raw::c_char, ) -> CompileResult { if compiler.is_null() { @@ -395,7 +398,7 @@ fn compile_internal( Err(message) => return make_error_result(message), }; - match compile_inner(&mut compiler.0, format_str, ppi, &standards) { + match compile_inner(&mut compiler.0, format_str, ppi, merged_gap, &standards) { Ok((buffers, warnings)) => { let c_buffers: Vec = buffers .into_iter() @@ -429,7 +432,11 @@ fn compile_internal( } } -/// Compiles the document to `format`, which is one of `pdf`, `png` or `svg`. +/// Compiles the document to `format`, which is one of `pdf`, `png`, `svg` or `svg-merged`. +/// +/// `pdf` and `svg-merged` produce a single buffer for the whole document; `png` and `svg` produce +/// one buffer per page. `ppi` applies to `png` only. `merged_gap` applies to `svg-merged` only and +/// is the distance in points between neighbouring pages, which must be finite and non-negative. /// /// The returned [`CompileResult`] owns its buffers and messages. They do not borrow from /// `compiler`, so they outlive further compilations, [`set_sys_inputs`], [`reset_world`] and even @@ -449,10 +456,11 @@ pub unsafe extern "C" fn compile( compiler: *mut Compiler, format_ptr: *const std::os::raw::c_char, ppi: f32, + merged_gap: f32, pdf_standards: *const std::os::raw::c_char, ) -> CompileResult { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - compile_internal(compiler, format_ptr, ppi, pdf_standards) + compile_internal(compiler, format_ptr, ppi, merged_gap, pdf_standards) })); match result { diff --git a/src/typst_core/tests/compile.rs b/src/typst_core/tests/compile.rs index 2fb69cc..2418dfa 100644 --- a/src/typst_core/tests/compile.rs +++ b/src/typst_core/tests/compile.rs @@ -30,7 +30,7 @@ fn compiler_for(source: &[u8]) -> *mut Compiler { fn compile_expecting_error(source: &[u8]) -> String { let compiler = compiler_for(source); - let result = unsafe { compile(compiler, std::ptr::null(), 96.0, std::ptr::null()) }; + let result = unsafe { compile(compiler, std::ptr::null(), 96.0, 0.0, std::ptr::null()) }; assert!( !result.error_ptr.is_null(), "invalid document compiled without an error" diff --git a/src/typst_core/tests/input_path.rs b/src/typst_core/tests/input_path.rs index 5d12d18..b42a957 100644 --- a/src/typst_core/tests/input_path.rs +++ b/src/typst_core/tests/input_path.rs @@ -62,7 +62,7 @@ fn compiler_for_file(root: &Path, input_path: &str) -> *mut Compiler { /// 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 = compile(compiler, std::ptr::null(), 96.0, 0.0, std::ptr::null()); assert!( result.error_ptr.is_null(), diff --git a/src/typst_core/tests/svg_merged.rs b/src/typst_core/tests/svg_merged.rs new file mode 100644 index 0000000..f3186c2 --- /dev/null +++ b/src/typst_core/tests/svg_merged.rs @@ -0,0 +1,94 @@ +use std::ffi::{c_char, CString}; + +use typst_core::{Compiler, compile, create_compiler, free_compile_result, free_compiler}; + +/// Two pages of different widths, so the merged image's size shows both how the pages are +/// stacked and that the widest one sets the width. +const TWO_PAGES: &[u8] = b"#set page(width: 100pt, height: 50pt, margin: 0pt) +First +#set page(width: 80pt, height: 30pt) +Second"; + +fn compiler_for(source: &[u8]) -> *mut Compiler { + let root = CString::new(".").unwrap(); + let sys_inputs = CString::new("{}").unwrap(); + + let compiler = unsafe { + create_compiler( + root.as_ptr(), + std::ptr::null(), + source.as_ptr(), + 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 +} + +/// Compiles `source` to `format` and returns either every output buffer as a string or the +/// error message. +fn compile_to(source: &[u8], format: &str, merged_gap: f32) -> Result, String> { + let compiler = compiler_for(source); + let format = CString::new(format).unwrap(); + + let result = unsafe { compile(compiler, format.as_ptr(), 96.0, merged_gap, std::ptr::null()) }; + + let outcome = unsafe { + if result.error_ptr.is_null() { + let buffers = std::slice::from_raw_parts(result.buffers, result.buffers_len); + Ok(buffers + .iter() + .map(|b| { + String::from_utf8(std::slice::from_raw_parts(b.ptr, b.len).to_vec()).unwrap() + }) + .collect()) + } else { + let slice = std::slice::from_raw_parts(result.error_ptr, result.error_len); + Err(String::from_utf8_lossy(slice).into_owned()) + } + }; + + unsafe { + free_compile_result(result); + free_compiler(compiler); + } + + outcome +} + +#[test] +fn every_page_is_merged_into_one_svg() { + let svgs = compile_to(TWO_PAGES, "svg-merged", 0.0).unwrap(); + + assert_eq!(svgs.len(), 1); + assert_eq!(svgs[0].matches(" compiler.CompileMergedSvg(-1f)).Throws(); + await Assert.That(() => compiler.CompileMergedSvg(float.NaN)).Throws(); + await Assert.That(() => compiler.CompileMergedSvg(float.PositiveInfinity)).Throws(); + } + + [Test] + public async Task CompileToFileWritesMergedSvgAsOneFile() + { + var directory = Directory.CreateTempSubdirectory("typst-"); + try + { + using var compiler = TypstCompiler.FromSource(TwoDifferentPages); + var output = Path.Combine(directory.FullName, "out.svg"); + + compiler.Compile(output, "svg-merged", mergedGap: 10f); + + await Assert.That(Directory.GetFiles(directory.FullName).Length).IsEqualTo(1); + await Assert.That(await File.ReadAllTextAsync(output)).Contains("viewBox=\"0 0 100 90\""); + } + finally + { + directory.Delete(recursive: true); + } + } + [Test] public async Task TestUnicode() { diff --git a/src/typstsharp/Bindings.g.cs b/src/typstsharp/Bindings.g.cs index 397153a..bdf5475 100644 --- a/src/typstsharp/Bindings.g.cs +++ b/src/typstsharp/Bindings.g.cs @@ -60,7 +60,11 @@ internal static unsafe partial class NativeMethods internal static extern bool set_sys_inputs(Compiler* compiler, byte* sys_inputs); /// - /// Compiles the document to `format`, which is one of `pdf`, `png` or `svg`. + /// Compiles the document to `format`, which is one of `pdf`, `png`, `svg` or `svg-merged`. + /// + /// `pdf` and `svg-merged` produce a single buffer for the whole document; `png` and `svg` produce + /// one buffer per page. `ppi` applies to `png` only. `merged_gap` applies to `svg-merged` only and + /// is the distance in points between neighbouring pages, which must be finite and non-negative. /// /// The returned [`CompileResult`] owns its buffers and messages. They do not borrow from /// `compiler`, so they outlive further compilations, [`set_sys_inputs`], [`reset_world`] and even @@ -77,7 +81,7 @@ internal static unsafe partial class NativeMethods /// for the duration of the call. /// [DllImport(__DllName, EntryPoint = "compile", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern CompileResult compile(Compiler* compiler, byte* format_ptr, float ppi, byte* pdf_standards); + internal static extern CompileResult compile(Compiler* compiler, byte* format_ptr, float ppi, float merged_gap, byte* pdf_standards); /// /// Releases every allocation owned by a [`CompileResult`]: the buffers, the warning messages and @@ -112,7 +116,8 @@ internal unsafe partial struct Compiler } /// - /// One rendered output: the whole document for PDF export, one page for PNG and SVG. + /// One rendered output: the whole document for PDF and merged SVG export, one page for PNG and + /// SVG. /// /// The bytes are owned by the [`CompileResult`] that contains this buffer and are freed by /// [`free_compile_result`]. They are not NUL-terminated; `len` is the only length. diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs index bbf1b22..6c7837f 100644 --- a/src/typstsharp/TypstCompiler.cs +++ b/src/typstsharp/TypstCompiler.cs @@ -173,6 +173,54 @@ public static SvgResult CompileSvgFromFile( return compiler.CompileSvg(ppi); } + /// + /// Compiles Typst source code to a single SVG holding every page, stacked top to bottom. + /// + /// The Typst source code. + /// The distance between neighbouring pages, in points. + /// Font settings. + /// System inputs. + /// Root directory. + /// Directory that packages are resolved from. + /// Whether machine-wide package directories and Typst Universe registry may be used. + /// A containing the merged SVG as its only page, and any warnings. + public static SvgResult CompileMergedSvg( + string source, + float gapInPoints = 0f, + Fonts? fonts = null, + Dictionary? sysInputs = null, + string? root = null, + string? packagePath = null, + bool includeSystemPackages = true) + { + using var compiler = FromSource(source, fonts, sysInputs, root, packagePath, includeSystemPackages); + return compiler.CompileMergedSvg(gapInPoints); + } + + /// + /// Compiles a Typst source file to a single SVG holding every page, stacked top to bottom. + /// + /// The path to the Typst file. + /// The distance between neighbouring pages, in points. + /// Font settings. + /// System inputs. + /// Root directory. + /// Directory that packages are resolved from. + /// Whether machine-wide package directories and Typst Universe registry may be used. + /// A containing the merged SVG as its only page, and any warnings. + public static SvgResult CompileMergedSvgFromFile( + string path, + float gapInPoints = 0f, + Fonts? fonts = null, + Dictionary? sysInputs = null, + string? root = null, + string? packagePath = null, + bool includeSystemPackages = true) + { + using var compiler = FromFile(path, fonts, sysInputs, root, packagePath, includeSystemPackages); + return compiler.CompileMergedSvg(gapInPoints); + } + /// /// Compiles Typst source code directly to PNG format. /// @@ -309,18 +357,26 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font /// the native library allocated, without copying it onto the managed heap. The caller decides /// whether the bytes are ever copied. /// - /// The output format: "pdf", "png" or "svg". + /// + /// The output format: "pdf", "png", "svg", or "svg-merged" for a single SVG holding every page. + /// /// The pixels per inch used for raster output. /// Optional PDF standards (e.g. "a-2b", "v-1.7"). + /// The distance between neighbouring pages in a merged SVG, in points. /// A that must be disposed to release the native memory. + /// Thrown if is negative, infinite or NaN. /// Thrown if the compilation fails, with the error message from Typst. /// /// The returned document is independent of this compiler and stays valid after the compiler has /// been disposed. /// - public unsafe TypstDocument CompileToDocument(string format = "pdf", float ppi = 144.0f, IEnumerable? pdfStandards = null) + public unsafe TypstDocument CompileToDocument(string format = "pdf", float ppi = 144.0f, IEnumerable? pdfStandards = null, float mergedGap = 0f) { EnsureNotDisposed(); + if (!float.IsFinite(mergedGap) || mergedGap < 0f) + { + throw new ArgumentOutOfRangeException(nameof(mergedGap), mergedGap, "The gap between merged SVG pages must be a finite, non-negative number of points."); + } IntPtr formatPtr = Marshal.StringToCoTaskMemUTF8(format); string standardsStr = pdfStandards != null ? string.Join(",", pdfStandards) : ""; @@ -328,7 +384,7 @@ public unsafe TypstDocument CompileToDocument(string format = "pdf", float ppi = try { - var native = CsBindgen.NativeMethods.compile(_compiler, (byte*)formatPtr, ppi, (byte*)standardsPtr); + var native = CsBindgen.NativeMethods.compile(_compiler, (byte*)formatPtr, ppi, mergedGap, (byte*)standardsPtr); // The P/Invoke is a preemptive-mode transition, so a collection can run while Typst is // compiling. `this` is dead from the field load above onwards, so without this the @@ -478,6 +534,20 @@ public SvgResult CompileSvg(float ppi = 144.0f) return new SvgResult(pages, document.Warnings); } + /// + /// Compiles the Typst document to a single SVG holding every page, stacked top to bottom with + /// points between them. The image is as wide as the widest page. + /// + /// The distance between neighbouring pages, in points. + /// A containing the merged SVG as its only page, and any warnings. + /// Thrown if is negative, infinite or NaN. + public SvgResult CompileMergedSvg(float gapInPoints = 0f) + { + using var document = CompileToDocument(format: "svg-merged", mergedGap: gapInPoints); + var svg = System.Text.Encoding.UTF8.GetString(document.GetOutputSpan()); + return new SvgResult([svg], document.Warnings); + } + /// /// Compiles the Typst document to PNG image byte buffers (one per page). /// @@ -518,13 +588,15 @@ public record TypstWarning(string Message); /// Compiles the Typst document and writes the output to one or more files. /// /// The path for the output file. If the document has multiple pages, a page number will be appended to the file name for each page. - /// The output format (e.g., "pdf"). This parameter is currently not used by the underlying engine but is kept for future compatibility. - /// The pixels per inch for the output. This parameter is currently not used by the underlying engine but is kept for future compatibility. - public void Compile(string outputFile, string format, float ppi = 144.0f, IEnumerable? pdfStandards = null) + /// The output format: "pdf", "png", "svg", or "svg-merged" for a single SVG holding every page. + /// The pixels per inch used for raster output. + /// Optional PDF standards (e.g. "a-2b", "v-1.7"). + /// The distance between neighbouring pages in a merged SVG, in points. + public void Compile(string outputFile, string format, float ppi = 144.0f, IEnumerable? pdfStandards = null, float mergedGap = 0f) { ArgumentException.ThrowIfNullOrEmpty(outputFile); - using var document = CompileToDocument(format, ppi, pdfStandards); + using var document = CompileToDocument(format, ppi, pdfStandards, mergedGap); if (document.OutputCount == 1) { document.WriteOutputToFile(outputFile); @@ -671,7 +743,8 @@ public Task SaveAsync(string path, CancellationToken cancellationToken = default } /// -/// Represents the result of compiling a document to SVG format (one SVG string per page). +/// Represents the result of compiling a document to SVG format: one SVG string per page, or a +/// single one holding every page for a merged SVG. /// Supports implicit conversion to (returning the primary page SVG). /// public sealed record SvgResult(IReadOnlyList Pages, IReadOnlyList Warnings) : IReadOnlyList diff --git a/src/typstsharp/TypstDocument.cs b/src/typstsharp/TypstDocument.cs index 5813764..851d346 100644 --- a/src/typstsharp/TypstDocument.cs +++ b/src/typstsharp/TypstDocument.cs @@ -14,9 +14,9 @@ namespace typstsharp; /// PDF off the large object heap, which matters when documents are rendered in a loop or on a server. /// /// -/// A document is a list of output buffers, not of pages. PDF export produces exactly one buffer -/// containing the whole document however many pages it has; PNG and SVG export produce one buffer -/// per page. See . +/// A document is a list of output buffers, not of pages. PDF and merged SVG export produce exactly +/// one buffer containing the whole document however many pages it has; PNG and SVG export produce +/// one buffer per page. See . /// /// /// The native memory is released by . A from @@ -86,8 +86,8 @@ internal unsafe TypstDocument(CsBindgen.CompileResult native) } /// - /// The number of output buffers. This is 1 for PDF export regardless of how many pages the - /// document has, and one per page for PNG and SVG export. + /// The number of output buffers. This is 1 for PDF and merged SVG export regardless of how many + /// pages the document has, and one per page for PNG and SVG export. /// public int OutputCount => _outputCount;