Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/pack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ jobs:
with:
package-version: ${{ env.PACKAGE_VERSION }}

# The Rust tests cover the FFI entry points directly. That is the only place a
# panic crossing the boundary shows up as a failing test rather than as a
# crashed test runner, so they have to run in their own right.
- name: Run native tests
shell: bash
run: cargo test --manifest-path src/typst_core/Cargo.toml --release

- name: Run tests
shell: bash
run: dotnet test --solution typstsharp.slnx
Expand Down
6 changes: 3 additions & 3 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

## [Unreleased]
### Fixed
- 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`.

### Changed
- **Breaking:** an invalid combination of PDF standards now fails the compilation instead of silently producing an ordinary PDF. The validation error from `PdfStandards::new` was discarded and export fell back to the default, so a pipeline could believe it was writing PDF/A while it was not. Combinations such as two PDF/A levels, or a PDF/A level that contradicts the requested PDF version, now throw with the message and hints from Typst. Callers passing a contradictory combination today receive a document and will receive an exception after this change.
- Note for PDF/A and PDF/UA: the exporter deliberately writes no timestamp, so the document has to carry its own date (`#set document(date: ...)`) and, for PDF/UA, a title and language.
- `compiler.CompilePdf(Stream)`, `compiler.CompilePdfAsync(Stream)`, `compiler.CompilePdf(string outputFile)` and `compiler.CompilePdfAsync(string outputFile)` now stream the document straight from native memory to the destination and return the compiler warnings, rather than returning a `PdfResult` that had to be materialised on the managed heap first. Use `compiler.CompilePdf()` when you want the bytes.
- `compiler.Compile(outputFile, format)` and `compiler.CompileSvg(...)` no longer copy the rendered output onto the managed heap before writing or decoding it.

### Added
- 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.
Expand All @@ -20,6 +23,3 @@
- Added `includeSystemPackages` to `TypstCompiler`. Setting it to `false` resolves packages from `packagePath` only, so an import that is not vendored there fails instead of being downloaded from Typst Universe and compilation stays off the network.
- Added support for compiling Typst documents with multiple PDF standards simultaneously (e.g. `v-1.7`, `a-2b`, etc.) by exposing a `pdfStandards` parameter in the `TypstCompiler.Compile` API, leveraging the underlying `typst-pdf` crate updates in Typst 0.15.

### Changed
- `compiler.CompilePdf(Stream)`, `compiler.CompilePdfAsync(Stream)`, `compiler.CompilePdf(string outputFile)` and `compiler.CompilePdfAsync(string outputFile)` now stream the document straight from native memory to the destination and return the compiler warnings, rather than returning a `PdfResult` that had to be materialised on the managed heap first. Use `compiler.CompilePdf()` when you want the bytes.
- `compiler.Compile(outputFile, format)` and `compiler.CompileSvg(...)` no longer copy the rendered output onto the managed heap before writing or decoding it.
34 changes: 20 additions & 14 deletions src/typst_core/src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,23 +86,29 @@ impl SystemWorld {
fonts.extend(typst_kit::fonts::scan(path));
}

// Resolve the main file path relative to the root
// If the input path is absolute, try to make it relative to the root.
// If it's already relative, assume it's relative to the root.
// Resolve the main file path relative to the root. A relative input path is
// taken to be relative to the root, so joining it onto the root first leaves
// `virtualize` with a single job: translate a real path into a virtual one and
// check that it stays inside the root.
//
// Going through `Path` rather than handing the string to `VirtualPath::new`
// matters because a virtual path only accepts forward slashes. On Windows the
// separator in `templates\letter.typ` is an ordinary one, and `Path` splits on
// it; `VirtualPath::new` would instead reject the whole string.
//
// Input (Windows): root `C:\app`, path `templates\letter.typ`
// Output: virtual path `/templates/letter.typ`
let main_id = if let Some(path) = input_path {
let relative_path = if path.is_absolute() {
path.strip_prefix(&root).map_err(|_| {
eco_format!("input file must be contained in the project root")
})?
} else {
&path
};
let relative_str = relative_path.to_str().ok_or_else(|| {
eco_format!("input file path must be valid UTF-8")
let absolute = if path.is_absolute() { path } else { root.join(path) };
let virtual_path = VirtualPath::virtualize(&root, &absolute).map_err(|err| {
eco_format!("invalid input file path `{}`: {err}", absolute.display())
})?;
RootedPath::new(VirtualRoot::Project, VirtualPath::new(relative_str).unwrap()).intern()
RootedPath::new(VirtualRoot::Project, virtual_path).intern()
} else {
FileId::unique(RootedPath::new(VirtualRoot::Project, VirtualPath::new("<main>").unwrap()))
FileId::unique(RootedPath::new(
VirtualRoot::Project,
VirtualPath::new("<main>").expect("`<main>` is a valid virtual path"),
))
};

let mut slots = HashMap::new();
Expand Down
186 changes: 186 additions & 0 deletions src/typst_core/tests/input_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! Tests for how an on-disk input path is turned into Typst's virtual main file.
//!
//! A virtual path only accepts forward slashes and may not leave the project root.
//! Both constraints are properties of the path the caller supplies, so they have to
//! be reported as errors rather than taking the process down.

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 directory holding a template, removed when the test ends.
struct Project {
root: PathBuf,
}

impl Project {
/// Creates a project root containing `templates/letter.typ`.
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.join("templates")).unwrap();
std::fs::write(root.join("templates").join("letter.typ"), "= Dear customer").unwrap();
Self { root }
}
}

impl Drop for Project {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}

/// Creates a compiler over a file, the way `TypstCompiler.FromFile` does.
fn compiler_for_file(root: &Path, input_path: &str) -> *mut Compiler {
let root = CString::new(root.to_str().unwrap()).unwrap();
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,
)
}

/// 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());

assert!(
result.error_ptr.is_null(),
"compilation failed: {}",
unsafe {
String::from_utf8_lossy(std::slice::from_raw_parts(
result.error_ptr,
result.error_len,
))
.into_owned()
}
);
assert_eq!(result.buffers_len, 1, "expected exactly one PDF buffer");

let len = unsafe { (*result.buffers).len };
free_compile_result(result);
len
}

/// A template in a subfolder is addressed with the platform's own separator. On
/// Windows that is a backslash, which a virtual path rejects outright, so the path
/// has to be resolved through `Path` rather than handed over as a string.
#[test]
fn nested_relative_input_path_is_compiled() {
let project = Project::new("nested-relative");
let input_path = Path::new("templates")
.join("letter.typ")
.to_str()
.unwrap()
.to_owned();

let compiler = compiler_for_file(&project.root, &input_path);
assert!(
!compiler.is_null(),
"`{input_path}` was rejected as an input path"
);

assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF");
free_compiler(compiler);
}

/// The separator is spelled out rather than taken from `Path`, so this stays a
/// regression test even if the platform-independent one above is ever simplified to a
/// forward-slash literal, which the old code already accepted.
#[cfg(windows)]
#[test]
fn windows_separator_in_the_input_path_is_compiled() {
let project = Project::new("windows-separator");

let compiler = compiler_for_file(&project.root, "templates\\letter.typ");
assert!(
!compiler.is_null(),
"`templates\\letter.typ` was rejected as an input path"
);

assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF");
free_compiler(compiler);
}

/// A path that steps out of a subfolder and back in never leaves the root, so it
/// resolves rather than being refused. This is the boundary of the escape check.
#[test]
fn input_path_leaving_and_reentering_the_root_is_compiled() {
let project = Project::new("reentering");
let input_path = Path::new("templates")
.join("..")
.join("templates")
.join("letter.typ")
.to_str()
.unwrap()
.to_owned();

let compiler = compiler_for_file(&project.root, &input_path);
assert!(!compiler.is_null(), "`{input_path}` was rejected");

assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF");
free_compiler(compiler);
}

/// The same file named absolutely resolves to the same document.
#[test]
fn absolute_input_path_inside_the_root_is_compiled() {
let project = Project::new("absolute-inside");
let input_path = project.root.join("templates").join("letter.typ");

let compiler = compiler_for_file(&project.root, input_path.to_str().unwrap());
assert!(!compiler.is_null(), "absolute input path was rejected");

assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF");
free_compiler(compiler);
}

/// A relative path that climbs out of the root has to be refused. Reaching outside
/// the root is what the root is for, and the refusal must be an error result rather
/// than a panic: this runs inside an `extern "C"` function, where an unwind aborts
/// the host process.
#[test]
fn relative_input_path_escaping_the_root_is_refused() {
let project = Project::new("relative-escape");
let input_path = Path::new("..")
.join("outside.typ")
.to_str()
.unwrap()
.to_owned();

let compiler = compiler_for_file(&project.root, &input_path);
assert!(
compiler.is_null(),
"`{input_path}` escapes the root and must be refused"
);
}

/// An absolute path outside the root is refused for the same reason.
#[test]
fn absolute_input_path_outside_the_root_is_refused() {
let project = Project::new("absolute-outside");
let outside = std::env::temp_dir().join("typst_core-outside.typ");

let compiler = compiler_for_file(&project.root, outside.to_str().unwrap());
assert!(compiler.is_null(), "path outside the root must be refused");
}
Loading