From fcc8352f5f112d318b6eeee1358e700a912be326 Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Fri, 4 Sep 2026 13:11:54 +0200 Subject: [PATCH] fix: accept every PDF standard and fail on invalid combinations The names accepted by `pdfStandards` were a hand-written table. It omitted `ua-1`, so PDF/UA-1 could not be requested at all even though typst-pdf supports it, and every future standard would need the table updated. `PdfStandard` is `#[non_exhaustive]` and derives serde, so the names are now read from the enum itself. The `v-` prefix on plain PDF versions stays valid as an alias because the README documents it, and applies only to a version number so that it does not become a second spelling of every standard. Separately, `export_pdf` called `PdfStandards::new(..).unwrap_or_default()`, which discarded the validation error and exported an ordinary PDF while reporting success. An archival pipeline could believe it was storing PDF/A documents that carried no conformance at all, and nothing would reveal it until a validator rejected them. The error is now propagated with the hints Typst attaches, which name the PDF versions each standard allows. This is a breaking change for a caller who passes a contradictory combination today. The README now lists the accepted names and the document metadata that archival and accessibility export require. --- README.md | 27 +++++- RELEASENOTES.md | 7 ++ src/typst_core/src/compiler.rs | 22 ++++- src/typst_core/src/lib.rs | 167 ++++++++++++++++++++++++++------- src/typstsharp.tests/Tests.cs | 125 ++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 99a9bd5..3bac4b5 100644 --- a/README.md +++ b/README.md @@ -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"); @@ -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: diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3da7d77..a3e7126 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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`: diff --git a/src/typst_core/src/compiler.rs b/src/typst_core/src/compiler.rs index 16490bd..225c43a 100644 --- a/src/typst_core/src/compiler.rs +++ b/src/typst_core/src/compiler.rs @@ -48,12 +48,32 @@ pub fn export_pdf( document: &PagedDocument, standards: &[typst_pdf::PdfStandard], ) -> StrResult> { + // 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::>() + .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() }, ) diff --git a/src/typst_core/src/lib.rs b/src/typst_core/src/lib.rs index 5b10f20..f2fa36c 100644 --- a/src/typst_core/src/lib.rs +++ b/src/typst_core/src/lib.rs @@ -265,6 +265,51 @@ fn make_error_result(msg: impl Into) -> 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 { + fn by_serialised_name(name: &str) -> Option { + 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, 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, @@ -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)) => { @@ -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()) + ); + } +} diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs index 475e46c..1243f61 100644 --- a/src/typstsharp.tests/Tests.cs +++ b/src/typstsharp.tests/Tests.cs @@ -254,6 +254,98 @@ public async Task SourceAfterNullByteIsNotTruncated() await Assert.That(plainText).Contains("After"); } + /// + /// PDF/UA-1 is what an obligation to publish accessible documents translates to, + /// so it has to be requestable and has to reach the output. + /// + [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"); + } + + /// + /// An archival export has to identify itself as one, or an archive validator will + /// reject it on ingestion. + /// + [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"); + // Without the guard the negative assertion would also hold for an empty string. + await Assert.That(plain).Contains(" + /// A document cannot conform to two archival levels at once, so the combination + /// has to fail rather than produce a PDF that claims neither. + /// + [Test] + public async Task ConflictingArchivalStandardsThrow() + { + await Assert.That(() => + { + using var compiler = TypstCompiler.FromSource("Hello world"); + _ = compiler.CompilePdf(pdfStandards: ["a-1b", "a-2b"]); + }).Throws() + .WithMessageContaining("PDF/A"); + } + + /// + /// 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. + /// + [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(); + + await Assert.That(exception!.Message).Contains("not compatible"); + await Assert.That(exception!.Message).Contains("hint:"); + } + + /// + /// Two PDF versions at once is the other way a combination contradicts itself. + /// + [Test] + public async Task ConflictingPdfVersionsThrow() + { + await Assert.That(() => + { + using var compiler = TypstCompiler.FromSource(TaggedSource); + _ = compiler.CompilePdf(pdfStandards: ["v-1.4", "v-2.0"]); + }).Throws() + .WithMessageContaining("same time"); + } + + /// + /// A combination that agrees with itself still has to work. + /// + [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() { @@ -735,6 +827,39 @@ private static string CreateTempDirectory() return directory; } + /// + /// 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. + /// + /// + /// 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 + """; + + /// + /// 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. + /// + private static string GetXmpMetadata(byte[] pdf) + { + var content = Encoding.Latin1.GetString(pdf); + var start = content.IndexOf("", StringComparison.Ordinal); + + return start >= 0 && end > start + ? content[start..(end + "".Length)] + : string.Empty; + } + private static string GetPlainText(byte[] pdf) { var sb = new StringBuilder();