From 54cee16c3128695089d7472451f02646c6d81521 Mon Sep 17 00:00:00 2001 From: Zhuofeng Wang <35804928+wzf03@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:43:01 +0800 Subject: [PATCH] Add timezone to PDF's default timestamp. (#5564) Co-authored-by: Laurenz --- crates/typst-cli/src/compile.rs | 26 +++++++--- crates/typst-pdf/src/catalog.rs | 47 +++++++++++++---- crates/typst-pdf/src/lib.rs | 89 +++++++++++++++++++++++++++++++-- 3 files changed, 144 insertions(+), 18 deletions(-) diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 3aa3aa3b9..adeef0f2d 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -17,7 +17,7 @@ use typst::html::HtmlDocument; use typst::layout::{Frame, Page, PageRanges, PagedDocument}; use typst::syntax::{FileId, Source, Span}; use typst::WorldExt; -use typst_pdf::{PdfOptions, PdfStandards}; +use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; use crate::args::{ CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat, @@ -55,7 +55,7 @@ pub struct CompileConfig { pub output_format: OutputFormat, /// Which pages to export. pub pages: Option, - /// The document's creation date formatted as a UNIX timestamp. + /// The document's creation date formatted as a UNIX timestamp, with UTC suffix. pub creation_timestamp: Option>, /// The format to emit diagnostics in. pub diagnostic_format: DiagnosticFormat, @@ -271,11 +271,23 @@ fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResul /// Export to a PDF. fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { + // If the timestamp is provided through the CLI, use UTC suffix, + // else, use the current local time and timezone. + let timestamp = match config.creation_timestamp { + Some(timestamp) => convert_datetime(timestamp).map(Timestamp::new_utc), + None => { + let local_datetime = chrono::Local::now(); + convert_datetime(local_datetime).and_then(|datetime| { + Timestamp::new_local( + datetime, + local_datetime.offset().local_minus_utc() / 60, + ) + }) + } + }; let options = PdfOptions { ident: Smart::Auto, - timestamp: convert_datetime( - config.creation_timestamp.unwrap_or_else(chrono::Utc::now), - ), + timestamp, page_ranges: config.pages.clone(), standards: config.pdf_standards.clone(), }; @@ -289,7 +301,9 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< } /// Convert [`chrono::DateTime`] to [`Datetime`] -fn convert_datetime(date_time: chrono::DateTime) -> Option { +fn convert_datetime( + date_time: chrono::DateTime, +) -> Option { Datetime::from_ymd_hms( date_time.year(), date_time.month().try_into().ok()?, diff --git a/crates/typst-pdf/src/catalog.rs b/crates/typst-pdf/src/catalog.rs index 753b8cb6b..c4b0e2e83 100644 --- a/crates/typst-pdf/src/catalog.rs +++ b/crates/typst-pdf/src/catalog.rs @@ -9,10 +9,10 @@ use typst_library::foundations::{Datetime, Smart}; use typst_library::layout::Dir; use typst_library::text::Lang; use typst_syntax::Span; -use xmp_writer::{DateTime, LangId, RenditionClass, Timezone, XmpWriter}; +use xmp_writer::{DateTime, LangId, RenditionClass, XmpWriter}; use crate::page::PdfPageLabel; -use crate::{hash_base64, outline, TextStrExt, WithEverything}; +use crate::{hash_base64, outline, TextStrExt, Timezone, WithEverything}; /// Write the document catalog. pub fn write_catalog( @@ -87,8 +87,17 @@ pub fn write_catalog( xmp.pdf_keywords(&joined); } - let date = ctx.document.info.date.unwrap_or(ctx.options.timestamp); - let tz = ctx.document.info.date.is_auto(); + // (1) If the `document.date` is set to specific `datetime` or `none`, use it. + // (2) If the `document.date` is set to `auto` or not set, try to use the + // date from the options. + // (3) Otherwise, we don't write date metadata. + let (date, tz) = match (ctx.document.info.date, ctx.options.timestamp) { + (Smart::Custom(date), _) => (date, None), + (Smart::Auto, Some(timestamp)) => { + (Some(timestamp.datetime), Some(timestamp.timezone)) + } + _ => (None, None), + }; if let Some(date) = date { if let Some(pdf_date) = pdf_date(date, tz) { info.creation_date(pdf_date); @@ -281,7 +290,7 @@ pub(crate) fn write_page_labels( } /// Converts a datetime to a pdf-writer date. -fn pdf_date(datetime: Datetime, tz: bool) -> Option { +fn pdf_date(datetime: Datetime, tz: Option) -> Option { let year = datetime.year().filter(|&y| y >= 0)? as u16; let mut pdf_date = pdf_writer::Date::new(year); @@ -306,16 +315,36 @@ fn pdf_date(datetime: Datetime, tz: bool) -> Option { pdf_date = pdf_date.second(s); } - if tz { - pdf_date = pdf_date.utc_offset_hour(0).utc_offset_minute(0); + match tz { + Some(Timezone::UTC) => { + pdf_date = pdf_date.utc_offset_hour(0).utc_offset_minute(0) + } + Some(Timezone::Local { hour_offset, minute_offset }) => { + pdf_date = + pdf_date.utc_offset_hour(hour_offset).utc_offset_minute(minute_offset) + } + None => {} } Some(pdf_date) } /// Converts a datetime to an xmp-writer datetime. -fn xmp_date(datetime: Datetime, tz: bool) -> Option { +fn xmp_date( + datetime: Datetime, + timezone: Option, +) -> Option { let year = datetime.year().filter(|&y| y >= 0)? as u16; + let timezone = timezone.map(|tz| match tz { + Timezone::UTC => xmp_writer::Timezone::Utc, + Timezone::Local { hour_offset, minute_offset } => { + // The xmp-writer use signed integers for the minute offset, which + // can be buggy if the minute offset is negative. And because our + // minute_offset is ensured to be `0 <= minute_offset < 60`, we can + // safely cast it to a signed integer. + xmp_writer::Timezone::Local { hour: hour_offset, minute: minute_offset as i8 } + } + }); Some(DateTime { year, month: datetime.month(), @@ -323,6 +352,6 @@ fn xmp_date(datetime: Datetime, tz: bool) -> Option { hour: datetime.hour(), minute: datetime.minute(), second: datetime.second(), - timezone: if tz { Some(Timezone::Utc) } else { None }, + timezone, }) } diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index c315684de..f45c62bb5 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -89,9 +89,9 @@ pub struct PdfOptions<'a> { /// `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 date of the document as a UTC - /// datetime. It will only be used if `set document(date: ..)` is `auto`. - pub timestamp: Option, + /// 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, /// Specifies which ranges of pages should be exported in the PDF. When /// `None`, all pages should be exported. pub page_ranges: Option, @@ -99,6 +99,51 @@ pub struct PdfOptions<'a> { pub standards: PdfStandards, } +/// 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 { + 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 }, +} + /// Encapsulates a list of compatible PDF standards. #[derive(Clone)] pub struct PdfStandards { @@ -612,3 +657,41 @@ fn transform_to_array(ts: Transform) -> [f32; 6] { 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 + ); + }; + + // 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()); + } +}