Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte>`, and `ReadOnlyMemory<byte>`).
Expand Down
31 changes: 24 additions & 7 deletions src/typst_core/src/compiler.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand All @@ -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()
}
};
Expand All @@ -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<Vec<u8>> {
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(
Expand Down Expand Up @@ -85,12 +100,14 @@ pub fn export(
document: &PagedDocument,
format: &str,
ppi: f32,
merged_gap: f32,
standards: &[typst_pdf::PdfStandard],
) -> StrResult<Vec<Vec<u8>>> {
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)),
}
}
18 changes: 13 additions & 5 deletions src/typst_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -238,6 +239,7 @@ fn compile_inner(
world: &mut SystemWorld,
format: &str,
ppi: f32,
merged_gap: f32,
standards: &[typst_pdf::PdfStandard],
) -> StrResult<(Vec<Vec<u8>>, Vec<SourceDiagnostic>)> {
world.reset_time();
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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<Buffer> = buffers
.into_iter()
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/typst_core/tests/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/typst_core/tests/input_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
94 changes: 94 additions & 0 deletions src/typst_core/tests/svg_merged.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>, 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("<svg").count(), 1, "{}", svgs[0]);
assert!(svgs[0].contains(r#"viewBox="0 0 100 80""#), "{}", svgs[0]);
}

#[test]
fn the_gap_is_added_between_pages() {
let svgs = compile_to(TWO_PAGES, "svg-merged", 12.5).unwrap();

assert!(svgs[0].contains(r#"viewBox="0 0 100 92.5""#), "{}", svgs[0]);
}

#[test]
fn plain_svg_still_produces_one_image_per_page() {
let svgs = compile_to(TWO_PAGES, "svg", 0.0).unwrap();

assert_eq!(svgs.len(), 2);
}

#[test]
fn a_negative_or_non_finite_gap_is_rejected() {
for gap in [-1.0, f32::NAN, f32::INFINITY] {
let error = compile_to(TWO_PAGES, "svg-merged", gap).unwrap_err();
assert!(error.contains("non-negative"), "gap {gap}: {error}");
}
}
57 changes: 57 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,63 @@ Page 2
await Assert.That(result.PrimaryPage).Contains("<svg");
}

// Two pages of different sizes: the merged SVG is as wide as the wider one and as tall as
// both together, plus the gap.
private const string TwoDifferentPages = """
#set page(width: 100pt, height: 50pt, margin: 0pt)
First
#set page(width: 80pt, height: 30pt)
Second
""";

[Test]
public async Task CompileMergedSvgStacksEveryPageIntoOneImage()
{
using var compiler = TypstCompiler.FromSource(TwoDifferentPages);

var result = compiler.CompileMergedSvg();
await Assert.That(result.Count).IsEqualTo(1);
await Assert.That(result.SinglePage.Split("<svg").Length - 1).IsEqualTo(1);
await Assert.That(result.SinglePage).Contains("viewBox=\"0 0 100 80\"");
}

[Test]
public async Task CompileMergedSvgAddsTheGapBetweenPages()
{
string svg = TypstCompiler.CompileMergedSvg(TwoDifferentPages, gapInPoints: 12.5f);
await Assert.That(svg).Contains("viewBox=\"0 0 100 92.5\"");
}

[Test]
public async Task CompileMergedSvgRejectsAnInvalidGap()
{
using var compiler = TypstCompiler.FromSource(TwoDifferentPages);

await Assert.That(() => compiler.CompileMergedSvg(-1f)).Throws<ArgumentOutOfRangeException>();
await Assert.That(() => compiler.CompileMergedSvg(float.NaN)).Throws<ArgumentOutOfRangeException>();
await Assert.That(() => compiler.CompileMergedSvg(float.PositiveInfinity)).Throws<ArgumentOutOfRangeException>();
}

[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()
{
Expand Down
11 changes: 8 additions & 3 deletions src/typstsharp/Bindings.g.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ internal static unsafe partial class NativeMethods
internal static extern bool set_sys_inputs(Compiler* compiler, byte* sys_inputs);

/// <summary>
/// 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
Expand All @@ -77,7 +81,7 @@ internal static unsafe partial class NativeMethods
/// for the duration of the call.
/// </summary>
[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);

/// <summary>
/// Releases every allocation owned by a [`CompileResult`]: the buffers, the warning messages and
Expand Down Expand Up @@ -112,7 +116,8 @@ internal unsafe partial struct Compiler
}

/// <summary>
/// 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.
Expand Down
Loading