Skip to content
Closed
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
4 changes: 4 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Release Notes

## [Unreleased]
### Fixed
- Fixed `ua-1` (PDF/UA-1, the accessibility standard) being rejected by `pdfStandards`. The accepted names were a hand-written table that omitted it; they 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.
- Fixed an invalid combination of PDF standards 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 fail the compilation with the message and hints from Typst.

### 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() {
eco_format!("{}", err.message())
} 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
148 changes: 116 additions & 32 deletions src/typst_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,45 @@ 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
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()
}

let lowered = name.to_lowercase();

// Typst spells the plain PDF versions as bare numbers such as `1.7`. The `v-1.7`
// spelling is the one this wrapper has always accepted and the one the README
// documents, so it stays valid as an alias.
by_serialised_name(&lowered)
.or_else(|| lowered.strip_prefix("v-").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 +326,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 +422,76 @@ 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);
}

#[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())
);
}
}
123 changes: 123 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,98 @@ public async Task SourceAfterNullByteIsNotTruncated()
await Assert.That(plainText).Contains("After");
}

/// <summary>
/// PDF/UA-1 is what an obligation to publish accessible documents translates to.
/// It could not be requested at all before, because the standard names were a
/// hand-written table that omitted it.
/// </summary>
[Test]
public async Task AccessibilityStandardIsAccepted()
{
using var compiler = TypstCompiler.FromSource(TaggedSource);

var pdf = compiler.CompilePdf(pdfStandards: ["ua-1"]);

await Assert.That(GetXmpMetadata(pdf)).Contains("pdfuaid:part");
}

/// <summary>
/// An archival export has to identify itself as one, or an archive validator will
/// reject it on ingestion.
/// </summary>
[Test]
public async Task ArchivalStandardIsRecordedInTheDocument()
{
using var compiler = TypstCompiler.FromSource(TaggedSource);

var archival = GetXmpMetadata(compiler.CompilePdf(pdfStandards: ["a-2b"]));
var plain = GetXmpMetadata(compiler.CompilePdf());

await Assert.That(archival).Contains("pdfaid:part");
await Assert.That(plain.Contains("pdfaid:part")).IsFalse();
}

/// <summary>
/// Two archival levels at once is not a document anyone can produce. The export
/// used to discard the validation error and return an ordinary PDF that claimed
/// no conformance at all, while reporting success.
/// </summary>
[Test]
public async Task ConflictingArchivalStandardsThrowInsteadOfDowngrading()
{
await Assert.That(() =>
{
using var compiler = TypstCompiler.FromSource("Hello world");
_ = compiler.CompilePdf(pdfStandards: ["a-1b", "a-2b"]);
}).Throws<InvalidOperationException>()
.WithMessageContaining("PDF/A");
}

/// <summary>
/// An archival level also constrains the PDF version, so asking for a version it
/// does not cover is a contradiction rather than a preference. The upstream hints
/// name the versions each standard allows, so they are worth carrying through.
/// </summary>
[Test]
public async Task ArchivalStandardConflictingWithThePdfVersionThrows()
{
var exception = await Assert.That(() =>
{
using var compiler = TypstCompiler.FromSource(TaggedSource);
_ = compiler.CompilePdf(pdfStandards: ["a-1b", "v-2.0"]);
}).Throws<InvalidOperationException>();

await Assert.That(exception!.Message).Contains("not compatible");
await Assert.That(exception!.Message).Contains("hint:");
}

/// <summary>
/// Two PDF versions at once is the other way a combination contradicts itself.
/// </summary>
[Test]
public async Task ConflictingPdfVersionsThrow()
{
await Assert.That(() =>
{
using var compiler = TypstCompiler.FromSource(TaggedSource);
_ = compiler.CompilePdf(pdfStandards: ["v-1.4", "v-2.0"]);
}).Throws<InvalidOperationException>()
.WithMessageContaining("same time");
}

/// <summary>
/// A combination that agrees with itself still has to work.
/// </summary>
[Test]
public async Task CompatibleStandardsAreAcceptedTogether()
{
using var compiler = TypstCompiler.FromSource(TaggedSource);

var pdf = compiler.CompilePdf(pdfStandards: ["a-2b", "v-1.7"]);

await Assert.That(GetXmpMetadata(pdf)).Contains("pdfaid:part");
}

[Test]
public async Task InvalidPdfStandardThrowsException()
{
Expand Down Expand Up @@ -735,6 +827,37 @@ private static string CreateTempDirectory()
return directory;
}

/// <summary>
/// A document carrying the title and language that the archival and accessibility
/// standards require, so a conformance test exercises the standard rather than
/// tripping over unrelated metadata.
/// </summary>
/// A fixed date keeps the output reproducible; PDF/A requires the document to
/// carry one, and the exporter deliberately does not stamp the current time.
private const string TaggedSource = """
#set document(
title: "Conformance test",
date: datetime(year: 2026, month: 1, day: 1),
)
#set text(lang: "en")
= Conformance test
""";

/// <summary>
/// Returns the XMP metadata packet, which is where a PDF records the standards it
/// conforms to. The packet is XML embedded in the file as Latin-1 bytes.
/// </summary>
private static string GetXmpMetadata(byte[] pdf)
{
var content = Encoding.Latin1.GetString(pdf);
var start = content.IndexOf("<x:xmpmeta", StringComparison.Ordinal);
var end = content.IndexOf("</x:xmpmeta>", StringComparison.Ordinal);

return start >= 0 && end > start
? content[start..(end + "</x:xmpmeta>".Length)]
: string.Empty;
}

private static string GetPlainText(byte[] pdf)
{
var sb = new StringBuilder();
Expand Down