Laurenz 774d7e819e Revert to original PDF standards API
The PR changed the CLI from just a --pdf-standard flag to a --pdf-version and a --pdf-standard flag and also introduced similar breaking changes to typst_pdf's interface.

I'm not totally opposed to this change, but also not yet certain whether I truly prefer it. For this reason, after discussion with @LaurenzV, I'm reverting this for now. We can still make the change later if we want to. There's no need to couple it with the switch to krilla.
2025-03-26 18:01:41 +01:00

268 lines
9.0 KiB
Rust

//! Exporting Typst documents to PDF.
mod convert;
mod embed;
mod image;
mod link;
mod metadata;
mod outline;
mod page;
mod paint;
mod shape;
mod text;
mod util;
use std::fmt::{self, Debug, Formatter};
use ecow::eco_format;
use serde::{Deserialize, Serialize};
use typst_library::diag::{bail, SourceResult, StrResult};
use typst_library::foundations::{Datetime, Smart};
use typst_library::layout::{PageRanges, PagedDocument};
/// Export a document into a PDF file.
///
/// Returns the raw bytes making up the PDF file.
#[typst_macros::time(name = "pdf")]
pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult<Vec<u8>> {
convert::convert(document, options)
}
/// Settings for PDF export.
#[derive(Debug, Default)]
pub struct PdfOptions<'a> {
/// If not `Smart::Auto`, shall be a string that uniquely and stably
/// identifies the document. It should not change between compilations of
/// the same document. **If you cannot provide such a stable identifier,
/// just pass `Smart::Auto` rather than trying to come up with one.** The
/// CLI, for example, does not have a well-defined notion of a long-lived
/// project and as such just passes `Smart::Auto`.
///
/// If an `ident` is given, the hash of it will be used to create a PDF
/// document identifier (the identifier itself is not leaked). If `ident` is
/// `Auto`, a hash of the document's title and author is used instead (which
/// is reasonably unique and stable).
pub ident: Smart<&'a str>,
/// If not `None`, shall be the creation timestamp of the document. It will
/// only be used if `set document(date: ..)` is `auto`.
pub timestamp: Option<Timestamp>,
/// Specifies which ranges of pages should be exported in the PDF. When
/// `None`, all pages should be exported.
pub page_ranges: Option<PageRanges>,
/// A list of PDF standards that Typst will enforce conformance with.
pub standards: PdfStandards,
}
/// Encapsulates a list of compatible PDF standards.
#[derive(Clone)]
pub struct PdfStandards {
pub(crate) config: krilla::configure::Configuration,
}
impl PdfStandards {
/// Validates a list of PDF standards for compatibility and returns their
/// encapsulated representation.
pub fn new(list: &[PdfStandard]) -> StrResult<Self> {
use krilla::configure::{Configuration, PdfVersion, Validator};
let mut version: Option<PdfVersion> = None;
let mut set_version = |v: PdfVersion| -> StrResult<()> {
if let Some(prev) = version {
bail!(
"PDF cannot conform to {} and {} at the same time",
prev.as_str(),
v.as_str()
);
}
version = Some(v);
Ok(())
};
let mut validator = None;
let mut set_validator = |v: Validator| -> StrResult<()> {
if validator.is_some() {
bail!("Typst currently only supports one PDF substandard at a time");
}
validator = Some(v);
Ok(())
};
for standard in list {
match standard {
PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?,
PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?,
PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?,
PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?,
PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?,
PdfStandard::A_1b => set_validator(Validator::A1_B)?,
PdfStandard::A_2b => set_validator(Validator::A2_B)?,
PdfStandard::A_2u => set_validator(Validator::A2_U)?,
PdfStandard::A_3b => set_validator(Validator::A3_B)?,
PdfStandard::A_3u => set_validator(Validator::A3_U)?,
PdfStandard::A_4 => set_validator(Validator::A4)?,
PdfStandard::A_4f => set_validator(Validator::A4F)?,
PdfStandard::A_4e => set_validator(Validator::A4E)?,
}
}
let version = version.unwrap_or(PdfVersion::Pdf17);
let validator = validator.unwrap_or_default();
let config = Configuration::new_with(validator, version).ok_or_else(|| {
eco_format!(
"{} is not compatible with {}",
version.as_str(),
validator.as_str()
)
})?;
Ok(Self { config })
}
}
impl Debug for PdfStandards {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.pad("PdfStandards(..)")
}
}
impl Default for PdfStandards {
fn default() -> Self {
use krilla::configure::{Configuration, PdfVersion};
Self {
config: Configuration::new_with_version(PdfVersion::Pdf17),
}
}
}
/// A PDF standard that Typst can enforce conformance with.
///
/// Support for more standards is planned.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[allow(non_camel_case_types)]
#[non_exhaustive]
pub enum PdfStandard {
/// PDF 1.4.
#[serde(rename = "1.4")]
V_1_4,
/// PDF 1.5.
#[serde(rename = "1.5")]
V_1_5,
/// PDF 1.5.
#[serde(rename = "1.6")]
V_1_6,
/// PDF 1.7.
#[serde(rename = "1.7")]
V_1_7,
/// PDF 2.0.
#[serde(rename = "2.0")]
V_2_0,
/// PDF/A-1b.
#[serde(rename = "a-1b")]
A_1b,
/// PDF/A-2b.
#[serde(rename = "a-2b")]
A_2b,
/// PDF/A-2u.
#[serde(rename = "a-2u")]
A_2u,
/// PDF/A-3u.
#[serde(rename = "a-3b")]
A_3b,
/// PDF/A-3u.
#[serde(rename = "a-3u")]
A_3u,
/// PDF/A-4.
#[serde(rename = "a-4")]
A_4,
/// PDF/A-4f.
#[serde(rename = "a-4f")]
A_4f,
/// PDF/A-4e.
#[serde(rename = "a-4e")]
A_4e,
}
/// A timestamp with timezone information.
#[derive(Debug, Clone, Copy)]
pub struct Timestamp {
/// The datetime of the timestamp.
pub(crate) datetime: Datetime,
/// The timezone of the timestamp.
pub(crate) timezone: Timezone,
}
impl Timestamp {
/// Create a new timestamp with a given datetime and UTC suffix.
pub fn new_utc(datetime: Datetime) -> Self {
Self { datetime, timezone: Timezone::UTC }
}
/// Create a new timestamp with a given datetime, and a local timezone offset.
pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option<Self> {
let hour_offset = (whole_minute_offset / 60).try_into().ok()?;
// Note: the `%` operator in Rust is the remainder operator, not the
// modulo operator. The remainder operator can return negative results.
// We can simply apply `abs` here because we assume the `minute_offset`
// will have the same sign as `hour_offset`.
let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?;
match (hour_offset, minute_offset) {
// Only accept valid timezone offsets with `-23 <= hours <= 23`,
// and `0 <= minutes <= 59`.
(-23..=23, 0..=59) => Some(Self {
datetime,
timezone: Timezone::Local { hour_offset, minute_offset },
}),
_ => None,
}
}
}
/// A timezone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Timezone {
/// The UTC timezone.
UTC,
/// The local timezone offset from UTC. And the `minute_offset` will have
/// same sign as `hour_offset`.
Local { hour_offset: i8, minute_offset: u8 },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timestamp_new_local() {
let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap();
let test = |whole_minute_offset, expect_timezone| {
assert_eq!(
Timestamp::new_local(dummy_datetime, whole_minute_offset)
.unwrap()
.timezone,
expect_timezone
);
};
// Valid timezone offsets
test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 });
test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 });
test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 });
test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 });
test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 });
test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE
// Corner cases
test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 });
test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 });
test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 });
test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 });
// Invalid timezone offsets
assert!(Timestamp::new_local(dummy_datetime, 1440).is_none());
assert!(Timestamp::new_local(dummy_datetime, -1440).is_none());
assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none());
assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none());
}
}