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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("output
```

### PDF Standards (Typst 0.15+)
You can export documents using specific PDF standards (like PDF/A or PDF/X) by passing them to `CompilePdf()`. You can even specify multiple standards at once:
You can export documents using specific PDF standards by passing them to `CompilePdf()`. You can
specify several at once, as long as they agree with each other:

```csharp
using var compiler = TypstCompiler.FromSource("= Archival Document");
Expand All @@ -87,6 +88,30 @@ var pdf = compiler.CompilePdf(pdfStandards: new[] { "a-2b", "v-1.7" });
await pdf.SaveAsync("archival.pdf");
```

The names are the ones Typst uses: the PDF versions `1.4` to `2.0` (also spelled `v-1.4` to
`v-2.0`), the archival standards `a-1b`, `a-1a`, `a-2b`, `a-2u`, `a-2a`, `a-3b`, `a-3u`, `a-3a`,
`a-4`, `a-4f` and `a-4e`, and the accessibility standard `ua-1`. A combination that contradicts
itself, such as two archival levels or an archival level that does not allow the requested PDF
version, throws rather than falling back to an ordinary PDF.

Archival and accessibility export place requirements on the document itself. The exporter writes
no timestamp, so the document has to carry its own date, and PDF/UA additionally needs a title and
a language:

```csharp
var source = """
#set document(title: "Statement", date: datetime(year: 2026, month: 1, day: 1))
#set text(lang: "en")
= Statement
""";

using var compiler = TypstCompiler.FromSource(source);
var pdf = compiler.CompilePdf(pdfStandards: new[] { "a-2b", "ua-1" });
```

Conformance is not validated by this library. If you depend on it, check the output with a
validator such as veraPDF as part of your own tests.

### Exporting SVG and PNG Images

You can compile documents directly to SVG (vector) or PNG (raster) images:
Expand Down
7 changes: 7 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Release Notes

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

### 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.
- Added easier PDF compilation APIs on `TypstCompiler`:
Expand Down
22 changes: 21 additions & 1 deletion src/typst_core/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,32 @@ pub fn export_pdf(
document: &PagedDocument,
standards: &[typst_pdf::PdfStandard],
) -> StrResult<Vec<u8>> {
// An invalid combination, such as two PDF/A levels or a PDF/A level that
// contradicts the requested PDF version, has to fail the export. Falling back to
// the default would hand back an ordinary PDF while reporting success, and the
// missing conformance would surface only when an archive validator rejects the
// document, long after it was produced.
let standards = typst_pdf::PdfStandards::new(standards).map_err(|err| {
let hints = err
.hints()
.iter()
.map(|hint| hint.as_str())
.collect::<Vec<_>>()
.join("; ");

if hints.is_empty() {
err.message().clone()
} else {
eco_format!("{} (hint: {})", err.message(), hints)
}
})?;

let buffer = typst_pdf::pdf(
document,
&typst_pdf::PdfOptions {
ident: typst::foundations::Smart::Auto,
timestamp: None, // For reproducible builds
standards: typst_pdf::PdfStandards::new(standards).unwrap_or_default(),
standards,
..Default::default()
},
)
Expand Down
167 changes: 135 additions & 32 deletions src/typst_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,51 @@ fn make_error_result(msg: impl Into<String>) -> CompileResult {
}
}

/// Parses one PDF standard name.
///
/// The accepted names are the ones `typst_pdf::PdfStandard` itself serialises to,
/// rather than a table restated here, so a standard added by a later Typst release is
/// accepted without a change to this function. `PdfStandard` is `#[non_exhaustive]`,
/// which makes matching on it exhaustively impossible from outside the crate anyway.
///
/// Input: "a-2b" Output: PdfStandard::A_2b
/// Input: "UA-1" Output: PdfStandard::Ua_1
/// Input: "v-1.7" Output: PdfStandard::V_1_7
/// Input: "a-9z" Output: None
/// Input: "v-a-2b" Output: None
fn parse_pdf_standard(name: &str) -> Option<typst_pdf::PdfStandard> {
fn by_serialised_name(name: &str) -> Option<typst_pdf::PdfStandard> {
serde_json::from_value(serde_json::Value::String(name.to_owned())).ok()
}

// Standard names are ASCII, so full Unicode case folding would buy nothing.
let lowered = name.to_ascii_lowercase();

// Typst spells the plain PDF versions as bare numbers such as `1.7`, while the
// README documents them as `v-1.7`, so the prefix is accepted as an alias. It
// only applies to a version number; `v-a-2b` is not a spelling of anything.
by_serialised_name(&lowered).or_else(|| {
lowered
.strip_prefix("v-")
.filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
.and_then(by_serialised_name)
})
}

/// Parses the comma-separated list of PDF standards that arrives over the boundary.
/// Blank entries are ignored, so a trailing comma is not an error.
///
/// Input: " a-2b , ua-1 " Output: [PdfStandard::A_2b, PdfStandard::Ua_1]
fn parse_pdf_standards(list: &str) -> Result<Vec<typst_pdf::PdfStandard>, String> {
list.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| {
parse_pdf_standard(name).ok_or_else(|| format!("Invalid PDF standard: {name}"))
})
.collect()
}

fn compile_internal(
compiler: *mut Compiler,
format_ptr: *const std::os::raw::c_char,
Expand All @@ -287,38 +332,10 @@ fn compile_internal(
unsafe { std::ffi::CStr::from_ptr(pdf_standards).to_str().unwrap_or("") }
};

let mut standards = Vec::new();
if !standards_str.is_empty() {
for s in standards_str.split(',') {
let s = s.trim();
if !s.is_empty() {
let parsed = match s.to_lowercase().as_str() {
"1.4" | "v-1.4" => Some(typst_pdf::PdfStandard::V_1_4),
"1.5" | "v-1.5" => Some(typst_pdf::PdfStandard::V_1_5),
"1.6" | "v-1.6" => Some(typst_pdf::PdfStandard::V_1_6),
"1.7" | "v-1.7" => Some(typst_pdf::PdfStandard::V_1_7),
"2.0" | "v-2.0" => Some(typst_pdf::PdfStandard::V_2_0),
"a-1b" => Some(typst_pdf::PdfStandard::A_1b),
"a-1a" => Some(typst_pdf::PdfStandard::A_1a),
"a-2b" => Some(typst_pdf::PdfStandard::A_2b),
"a-2u" => Some(typst_pdf::PdfStandard::A_2u),
"a-2a" => Some(typst_pdf::PdfStandard::A_2a),
"a-3b" => Some(typst_pdf::PdfStandard::A_3b),
"a-3u" => Some(typst_pdf::PdfStandard::A_3u),
"a-3a" => Some(typst_pdf::PdfStandard::A_3a),
"a-4" => Some(typst_pdf::PdfStandard::A_4),
"a-4f" => Some(typst_pdf::PdfStandard::A_4f),
"a-4e" => Some(typst_pdf::PdfStandard::A_4e),
_ => None,
};
if let Some(std) = parsed {
standards.push(std);
} else {
return make_error_result(format!("Invalid PDF standard: {}", s));
}
}
}
}
let standards = match parse_pdf_standards(standards_str) {
Ok(standards) => standards,
Err(message) => return make_error_result(message),
};

match compile_inner(&mut compiler.0, format_str, ppi, &standards) {
Ok((buffers, warnings)) => {
Expand Down Expand Up @@ -411,3 +428,89 @@ pub extern "C" fn free_compile_result(result: CompileResult) {
pub extern "C" fn reset_world() {
comemo::evict(10);
}

#[cfg(test)]
mod tests {
use super::{parse_pdf_standard, parse_pdf_standards};
use typst_pdf::PdfStandard;

#[test]
fn pdf_versions_are_accepted_bare_and_with_the_v_prefix() {
assert_eq!(parse_pdf_standard("1.4"), Some(PdfStandard::V_1_4));
assert_eq!(parse_pdf_standard("v-1.4"), Some(PdfStandard::V_1_4));
assert_eq!(parse_pdf_standard("1.7"), Some(PdfStandard::V_1_7));
assert_eq!(parse_pdf_standard("v-1.7"), Some(PdfStandard::V_1_7));
assert_eq!(parse_pdf_standard("2.0"), Some(PdfStandard::V_2_0));
assert_eq!(parse_pdf_standard("v-2.0"), Some(PdfStandard::V_2_0));
}

#[test]
fn archival_standards_are_accepted() {
assert_eq!(parse_pdf_standard("a-1b"), Some(PdfStandard::A_1b));
assert_eq!(parse_pdf_standard("a-2b"), Some(PdfStandard::A_2b));
assert_eq!(parse_pdf_standard("a-3a"), Some(PdfStandard::A_3a));
assert_eq!(parse_pdf_standard("a-4"), Some(PdfStandard::A_4));
assert_eq!(parse_pdf_standard("a-4e"), Some(PdfStandard::A_4e));
}

/// The accessibility standard is what an obligation to publish accessible
/// documents translates to, and the previous hand-written table left it out.
#[test]
fn the_accessibility_standard_is_accepted() {
assert_eq!(parse_pdf_standard("ua-1"), Some(PdfStandard::Ua_1));
}

#[test]
fn names_are_case_insensitive() {
assert_eq!(parse_pdf_standard("A-2B"), Some(PdfStandard::A_2b));
assert_eq!(parse_pdf_standard("UA-1"), Some(PdfStandard::Ua_1));
assert_eq!(parse_pdf_standard("V-1.7"), Some(PdfStandard::V_1_7));
}

#[test]
fn unknown_names_are_rejected() {
assert_eq!(parse_pdf_standard("nonexistent-standard"), None);
assert_eq!(parse_pdf_standard(""), None);
// Neither half of the version alias is a standard on its own.
assert_eq!(parse_pdf_standard("v-"), None);
assert_eq!(parse_pdf_standard("a-9z"), None);
}

/// The `v-` prefix introduces a version number and nothing else, so it must not
/// become a second spelling of every standard.
#[test]
fn the_version_prefix_applies_only_to_version_numbers() {
assert_eq!(parse_pdf_standard("v-a-2b"), None);
assert_eq!(parse_pdf_standard("v-ua-1"), None);
assert_eq!(parse_pdf_standard("v-v-1.7"), None);
}

#[test]
fn a_list_is_split_and_trimmed() {
assert_eq!(
parse_pdf_standards(" a-2b , ua-1 "),
Ok(vec![PdfStandard::A_2b, PdfStandard::Ua_1])
);
}

/// An empty list is how "no standard requested" arrives, and a stray comma is
/// not worth failing a compilation over.
#[test]
fn blank_entries_are_ignored() {
assert_eq!(parse_pdf_standards(""), Ok(vec![]));
assert_eq!(parse_pdf_standards(" "), Ok(vec![]));
assert_eq!(parse_pdf_standards("a-2b,"), Ok(vec![PdfStandard::A_2b]));
}

#[test]
fn the_first_unknown_name_is_reported() {
assert_eq!(
parse_pdf_standards("a-2b,nonexistent-standard"),
Err("Invalid PDF standard: nonexistent-standard".to_owned())
);
assert_eq!(
parse_pdf_standards("nonexistent-standard,a-2b"),
Err("Invalid PDF standard: nonexistent-standard".to_owned())
);
}
}
Loading