diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 124edc2ff..fb7f5ff49 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, Validator}; +use typst_pdf::{PdfOptions, Timestamp, Validator}; use crate::args::{ CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat, @@ -261,11 +261,24 @@ 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(), pdf_version: config.pdf_version.map(|v| match v { PdfVersion::V_1_4 => typst_pdf::PdfVersion::Pdf14, @@ -294,7 +307,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/krilla.rs b/crates/typst-pdf/src/krilla.rs index 53e0cdae6..c03749e66 100644 --- a/crates/typst-pdf/src/krilla.rs +++ b/crates/typst-pdf/src/krilla.rs @@ -4,7 +4,6 @@ use crate::page::PageLabelExt; use crate::util::{display_font, AbsExt, PointExt, SizeExt, TransformExt}; use crate::{paint, PdfOptions}; use bytemuck::TransparentWrapper; -use ecow::EcoString; use krilla::action::{Action, LinkAction}; use krilla::annotation::{LinkAnnotation, Target}; use krilla::destination::{NamedDestination, XyzDestination}; @@ -21,7 +20,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::ops::Range; use std::sync::Arc; use typst_library::diag::{bail, SourceResult}; -use typst_library::foundations::{Datetime, NativeElement}; +use typst_library::foundations::NativeElement; use typst_library::introspection::Location; use typst_library::layout::{ Abs, Frame, FrameItem, GroupItem, PagedDocument, Point, Size, Transform, diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index da1cbce34..c5b0b3b27 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -38,9 +38,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, @@ -49,3 +49,86 @@ pub struct PdfOptions<'a> { /// A standard the PDF should conform to. pub validator: Validator, } + +/// 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 }, +} + +#[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()); + } +} diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs index c0793d1f5..9e9931b96 100644 --- a/crates/typst-pdf/src/metadata.rs +++ b/crates/typst-pdf/src/metadata.rs @@ -1,8 +1,9 @@ use ecow::EcoString; use krilla::metadata::Metadata; -use typst_library::foundations::Datetime; +use typst_library::foundations::{Datetime, Smart}; use crate::krilla::GlobalContext; +use crate::Timezone; pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { let creator = format!("Typst {}", env!("CARGO_PKG_VERSION")); @@ -30,22 +31,29 @@ pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata { metadata = metadata.subject(ident.to_string()); } - let tz = gc.document.info.date.is_auto(); - if let Some(date) = gc - .document - .info - .date - .unwrap_or(gc.options.timestamp) - .and_then(|d| convert_date(d, tz)) - { + // (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 (gc.document.info.date, gc.options.timestamp) { + (Smart::Custom(date), _) => (date, None), + (Smart::Auto, Some(timestamp)) => { + (Some(timestamp.datetime), Some(timestamp.timezone)) + } + _ => (None, None), + }; + + if let Some(date) = date.and_then(|d| convert_date(d, tz)) { metadata = metadata.modification_date(date).creation_date(date); } metadata } -// TODO: Sync with recent PR -fn convert_date(datetime: Datetime, tz: bool) -> Option { +fn convert_date( + datetime: Datetime, + tz: Option, +) -> Option { let year = datetime.year().filter(|&y| y >= 0)? as u16; let mut krilla_date = krilla::metadata::DateTime::new(year); @@ -70,8 +78,16 @@ fn convert_date(datetime: Datetime, tz: bool) -> Option { + krilla_date = krilla_date.utc_offset_hour(0).utc_offset_minute(0) + } + Some(Timezone::Local { hour_offset, minute_offset }) => { + krilla_date = krilla_date + .utc_offset_hour(hour_offset) + .utc_offset_minute(minute_offset) + } + None => {} } Some(krilla_date) diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs index 849091816..5d5de9b73 100644 --- a/crates/typst-pdf/src/paint.rs +++ b/crates/typst-pdf/src/paint.rs @@ -8,8 +8,8 @@ use krilla::surface::Surface; use typst_library::diag::SourceResult; use typst_library::layout::{Abs, Angle, Quadrant, Ratio, Transform}; use typst_library::visualize::{ - Color, ColorSpace, DashPattern, FillRule, FixedStroke, Gradient, Paint, Pattern, - RatioOrAngle, RelativeTo, WeightedColor, + Color, ColorSpace, DashPattern, FillRule, FixedStroke, Gradient, Paint, RatioOrAngle, + RelativeTo, Tiling, WeightedColor, }; use typst_utils::Numeric; @@ -94,13 +94,13 @@ fn paint( Ok((p, alpha)) } Paint::Gradient(g) => Ok(convert_gradient(g, on_text, transforms)), - Paint::Pattern(p) => convert_pattern(gc, p, on_text, surface, transforms), + Paint::Tiling(p) => convert_pattern(gc, p, on_text, surface, transforms), } } pub(crate) fn convert_pattern( gc: &mut GlobalContext, - pattern: &Pattern, + pattern: &Tiling, on_text: bool, surface: &mut Surface, mut transforms: Transforms,