mod catalog;
mod color;
mod color_font;
mod content;
mod embed;
mod extg;
mod font;
mod gradient;
mod image;
mod named_destination;
mod outline;
mod page;
mod resources;
mod tiling;
use std::collections::{BTreeMap, HashMap};
use std::fmt::{self, Debug, Formatter};
use std::hash::Hash;
use std::ops::{Deref, DerefMut};
use base64::Engine;
use ecow::EcoString;
use pdf_writer::{Chunk, Name, Pdf, Ref, Str, TextStr};
use serde::{Deserialize, Serialize};
use typst_library::diag::{bail, SourceResult, StrResult};
use typst_library::foundations::{Datetime, Smart};
use typst_library::layout::{Abs, Em, PageRanges, PagedDocument, Transform};
use typst_library::text::Font;
use typst_library::visualize::Image;
use typst_syntax::Span;
use typst_utils::Deferred;
use crate::catalog::write_catalog;
use crate::color::{alloc_color_functions_refs, ColorFunctionRefs};
use crate::color_font::{write_color_fonts, ColorFontSlice};
use crate::embed::write_embedded_files;
use crate::extg::{write_graphic_states, ExtGState};
use crate::font::write_fonts;
use crate::gradient::{write_gradients, PdfGradient};
use crate::image::write_images;
use crate::named_destination::{write_named_destinations, NamedDestinations};
use crate::page::{alloc_page_refs, traverse_pages, write_page_tree, EncodedPage};
use crate::resources::{
alloc_resources_refs, write_resource_dictionaries, Resources, ResourcesRefs,
};
use crate::tiling::{write_tilings, PdfTiling};
#[typst_macros::time(name = "pdf")]
pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult<Vec<u8>> {
PdfBuilder::new(document, options)
.phase(|builder| builder.run(traverse_pages))?
.phase(|builder| {
Ok(GlobalRefs {
color_functions: builder.run(alloc_color_functions_refs)?,
pages: builder.run(alloc_page_refs)?,
resources: builder.run(alloc_resources_refs)?,
})
})?
.phase(|builder| {
Ok(References {
named_destinations: builder.run(write_named_destinations)?,
fonts: builder.run(write_fonts)?,
color_fonts: builder.run(write_color_fonts)?,
images: builder.run(write_images)?,
gradients: builder.run(write_gradients)?,
tilings: builder.run(write_tilings)?,
ext_gs: builder.run(write_graphic_states)?,
embedded_files: builder.run(write_embedded_files)?,
})
})?
.phase(|builder| builder.run(write_page_tree))?
.phase(|builder| builder.run(write_resource_dictionaries))?
.export_with(write_catalog)
}
#[derive(Debug, Default)]
pub struct PdfOptions<'a> {
pub ident: Smart<&'a str>,
pub timestamp: Option<Timestamp>,
pub page_ranges: Option<PageRanges>,
pub standards: PdfStandards,
}
#[derive(Debug, Clone, Copy)]
pub struct Timestamp {
pub(crate) datetime: Datetime,
pub(crate) timezone: Timezone,
}
impl Timestamp {
pub fn new_utc(datetime: Datetime) -> Self {
Self { datetime, timezone: Timezone::UTC }
}
pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option<Self> {
let hour_offset = (whole_minute_offset / 60).try_into().ok()?;
let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?;
match (hour_offset, minute_offset) {
(-23..=23, 0..=59) => Some(Self {
datetime,
timezone: Timezone::Local { hour_offset, minute_offset },
}),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Timezone {
UTC,
Local { hour_offset: i8, minute_offset: u8 },
}
#[derive(Clone)]
pub struct PdfStandards {
pub(crate) pdfa: bool,
pub(crate) embedded_files: bool,
pub(crate) pdfa_part: Option<(i32, &'static str)>,
}
impl PdfStandards {
pub fn new(list: &[PdfStandard]) -> StrResult<Self> {
let a2b = list.contains(&PdfStandard::A_2b);
let a3b = list.contains(&PdfStandard::A_3b);
if a2b && a3b {
bail!("PDF cannot conform to A-2B and A-3B at the same time")
}
let pdfa = a2b || a3b;
Ok(Self {
pdfa,
embedded_files: !a2b,
pdfa_part: pdfa.then_some((if a2b { 2 } else { 3 }, "B")),
})
}
}
impl Debug for PdfStandards {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.pad("PdfStandards(..)")
}
}
impl Default for PdfStandards {
fn default() -> Self {
Self { pdfa: false, embedded_files: true, pdfa_part: None }
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[allow(non_camel_case_types)]
#[non_exhaustive]
pub enum PdfStandard {
#[serde(rename = "1.7")]
V_1_7,
#[serde(rename = "a-2b")]
A_2b,
#[serde(rename = "a-3b")]
A_3b,
}
struct PdfBuilder<S> {
state: S,
alloc: Ref,
pdf: Pdf,
}
struct WithDocument<'a> {
document: &'a PagedDocument,
options: &'a PdfOptions<'a>,
}
struct WithResources<'a> {
document: &'a PagedDocument,
options: &'a PdfOptions<'a>,
pages: Vec<Option<EncodedPage>>,
resources: Resources<()>,
}
struct GlobalRefs {
color_functions: ColorFunctionRefs,
pages: Vec<Option<Ref>>,
resources: ResourcesRefs,
}
impl<'a> From<(WithDocument<'a>, (Vec<Option<EncodedPage>>, Resources<()>))>
for WithResources<'a>
{
fn from(
(previous, (pages, resources)): (
WithDocument<'a>,
(Vec<Option<EncodedPage>>, Resources<()>),
),
) -> Self {
Self {
document: previous.document,
options: previous.options,
pages,
resources,
}
}
}
struct WithGlobalRefs<'a> {
document: &'a PagedDocument,
options: &'a PdfOptions<'a>,
pages: Vec<Option<EncodedPage>>,
resources: Resources,
globals: GlobalRefs,
}
impl<'a> From<(WithResources<'a>, GlobalRefs)> for WithGlobalRefs<'a> {
fn from((previous, globals): (WithResources<'a>, GlobalRefs)) -> Self {
Self {
document: previous.document,
options: previous.options,
pages: previous.pages,
resources: previous.resources.with_refs(&globals.resources),
globals,
}
}
}
struct References {
named_destinations: NamedDestinations,
fonts: HashMap<Font, Ref>,
color_fonts: HashMap<ColorFontSlice, Ref>,
images: HashMap<Image, Ref>,
gradients: HashMap<PdfGradient, Ref>,
tilings: HashMap<PdfTiling, Ref>,
ext_gs: HashMap<ExtGState, Ref>,
embedded_files: BTreeMap<EcoString, Ref>,
}
struct WithRefs<'a> {
document: &'a PagedDocument,
options: &'a PdfOptions<'a>,
globals: GlobalRefs,
pages: Vec<Option<EncodedPage>>,
resources: Resources,
references: References,
}
impl<'a> From<(WithGlobalRefs<'a>, References)> for WithRefs<'a> {
fn from((previous, references): (WithGlobalRefs<'a>, References)) -> Self {
Self {
document: previous.document,
options: previous.options,
globals: previous.globals,
pages: previous.pages,
resources: previous.resources,
references,
}
}
}
struct WithEverything<'a> {
document: &'a PagedDocument,
options: &'a PdfOptions<'a>,
globals: GlobalRefs,
pages: Vec<Option<EncodedPage>>,
resources: Resources,
references: References,
page_tree_ref: Ref,
}
impl<'a> From<(WithEverything<'a>, ())> for WithEverything<'a> {
fn from((this, _): (WithEverything<'a>, ())) -> Self {
this
}
}
impl<'a> From<(WithRefs<'a>, Ref)> for WithEverything<'a> {
fn from((previous, page_tree_ref): (WithRefs<'a>, Ref)) -> Self {
Self {
document: previous.document,
options: previous.options,
globals: previous.globals,
resources: previous.resources,
references: previous.references,
pages: previous.pages,
page_tree_ref,
}
}
}
impl<'a> PdfBuilder<WithDocument<'a>> {
fn new(document: &'a PagedDocument, options: &'a PdfOptions<'a>) -> Self {
Self {
alloc: Ref::new(1),
pdf: Pdf::new(),
state: WithDocument { document, options },
}
}
}
impl<S> PdfBuilder<S> {
fn phase<NS, B, O>(mut self, builder: B) -> SourceResult<PdfBuilder<NS>>
where
NS: From<(S, O)>,
B: Fn(&mut Self) -> SourceResult<O>,
{
let output = builder(&mut self)?;
Ok(PdfBuilder {
state: NS::from((self.state, output)),
alloc: self.alloc,
pdf: self.pdf,
})
}
fn run<P, O>(&mut self, process: P) -> SourceResult<O>
where
P: Fn(&S) -> SourceResult<(PdfChunk, O)>,
O: Renumber,
{
let (chunk, mut output) = process(&self.state)?;
let allocated = chunk.alloc.get() - TEMPORARY_REFS_START;
let offset = TEMPORARY_REFS_START - self.alloc.get();
chunk.renumber_into(&mut self.pdf, |mut r| {
r.renumber(offset);
r
});
output.renumber(offset);
self.alloc = Ref::new(self.alloc.get() + allocated);
Ok(output)
}
fn export_with<P>(mut self, process: P) -> SourceResult<Vec<u8>>
where
P: Fn(S, &mut Pdf, &mut Ref) -> SourceResult<()>,
{
process(self.state, &mut self.pdf, &mut self.alloc)?;
Ok(self.pdf.finish())
}
}
trait Renumber {
fn renumber(&mut self, offset: i32);
}
impl Renumber for () {
fn renumber(&mut self, _offset: i32) {}
}
impl Renumber for Ref {
fn renumber(&mut self, offset: i32) {
if self.get() >= TEMPORARY_REFS_START {
*self = Ref::new(self.get() - offset);
}
}
}
impl<R: Renumber> Renumber for Vec<R> {
fn renumber(&mut self, offset: i32) {
for item in self {
item.renumber(offset);
}
}
}
impl<T: Eq + Hash, R: Renumber> Renumber for HashMap<T, R> {
fn renumber(&mut self, offset: i32) {
for v in self.values_mut() {
v.renumber(offset);
}
}
}
impl<T: Ord, R: Renumber> Renumber for BTreeMap<T, R> {
fn renumber(&mut self, offset: i32) {
for v in self.values_mut() {
v.renumber(offset);
}
}
}
impl<R: Renumber> Renumber for Option<R> {
fn renumber(&mut self, offset: i32) {
if let Some(r) = self {
r.renumber(offset)
}
}
}
impl<T, R: Renumber> Renumber for (T, R) {
fn renumber(&mut self, offset: i32) {
self.1.renumber(offset)
}
}
struct PdfChunk {
chunk: Chunk,
alloc: Ref,
}
const TEMPORARY_REFS_START: i32 = 1_000_000_000;
impl PdfChunk {
fn new() -> Self {
PdfChunk {
chunk: Chunk::new(),
alloc: Ref::new(TEMPORARY_REFS_START),
}
}
fn alloc(&mut self) -> Ref {
self.alloc.bump()
}
}
impl Deref for PdfChunk {
type Target = Chunk;
fn deref(&self) -> &Self::Target {
&self.chunk
}
}
impl DerefMut for PdfChunk {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.chunk
}
}
fn deflate(data: &[u8]) -> Vec<u8> {
const COMPRESSION_LEVEL: u8 = 6;
miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL)
}
#[comemo::memoize]
fn deflate_deferred(content: Vec<u8>) -> Deferred<Vec<u8>> {
Deferred::new(move || deflate(&content))
}
fn hash_base64<T: Hash>(value: &T) -> String {
base64::engine::general_purpose::STANDARD
.encode(typst_utils::hash128(value).to_be_bytes())
}
trait AbsExt {
fn to_f32(self) -> f32;
}
impl AbsExt for Abs {
fn to_f32(self) -> f32 {
self.to_pt() as f32
}
}
trait EmExt {
fn to_font_units(self) -> f32;
}
impl EmExt for Em {
fn to_font_units(self) -> f32 {
1000.0 * self.get() as f32
}
}
trait NameExt<'a> {
const PDFA_LIMIT: usize = 127;
}
impl<'a> NameExt<'a> for Name<'a> {}
trait StrExt<'a>: Sized {
const PDFA_LIMIT: usize = 32767;
#[allow(unused)]
fn trimmed(string: &'a [u8]) -> Self;
}
impl<'a> StrExt<'a> for Str<'a> {
fn trimmed(string: &'a [u8]) -> Self {
Self(&string[..string.len().min(Self::PDFA_LIMIT)])
}
}
trait TextStrExt<'a>: Sized {
const PDFA_LIMIT: usize = Str::PDFA_LIMIT;
fn trimmed(string: &'a str) -> Self;
}
impl<'a> TextStrExt<'a> for TextStr<'a> {
fn trimmed(string: &'a str) -> Self {
Self(&string[..string.len().min(Self::PDFA_LIMIT)])
}
}
trait ContentExt {
fn save_state_checked(&mut self) -> SourceResult<()>;
}
impl ContentExt for pdf_writer::Content {
fn save_state_checked(&mut self) -> SourceResult<()> {
self.save_state();
if self.state_nesting_depth() > 28 {
bail!(
Span::detached(),
"maximum PDF grouping depth exceeding";
hint: "try to avoid excessive nesting of layout containers",
);
}
Ok(())
}
}
fn transform_to_array(ts: Transform) -> [f32; 6] {
[
ts.sx.get() as f32,
ts.ky.get() as f32,
ts.kx.get() as f32,
ts.sy.get() as f32,
ts.tx.to_f32(),
ts.ty.to_f32(),
]
}
#[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
);
};
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 });
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 });
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());
}
}