summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorZhuofeng Wang <35804928+wzf03@users.noreply.github.com>2024-12-17 17:43:01 +0800
committerGitHub <noreply@github.com>2024-12-17 09:43:01 +0000
commit54cee16c3128695089d7472451f02646c6d81521 (patch)
tree181b94c4fd37f2740abcc2caa6388245b7a65a59 /crates
parent60f246ece2a088a802759975f68745f29bef8deb (diff)
Add timezone to PDF's default timestamp. (#5564)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/typst-cli/src/compile.rs26
-rw-r--r--crates/typst-pdf/src/catalog.rs47
-rw-r--r--crates/typst-pdf/src/lib.rs89
3 files changed, 144 insertions, 18 deletions
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
index 3aa3aa3b..adeef0f2 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<PageRanges>,
- /// 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<DateTime<Utc>>,
/// 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<chrono::Utc>) -> Option<Datetime> {
+fn convert_datetime<Tz: chrono::TimeZone>(
+ date_time: chrono::DateTime<Tz>,
+) -> Option<Datetime> {
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 753b8cb6..c4b0e2e8 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<pdf_writer::Date> {
+fn pdf_date(datetime: Datetime, tz: Option<Timezone>) -> Option<pdf_writer::Date> {
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_writer::Date> {
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<xmp_writer::DateTime> {
+fn xmp_date(
+ datetime: Datetime,
+ timezone: Option<Timezone>,
+) -> Option<xmp_writer::DateTime> {
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<xmp_writer::DateTime> {
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 c315684d..f45c62bb 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<Datetime>,
+ /// 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>,
@@ -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<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 },
+}
+
/// 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());
+ }
+}