Add timezone to PDF's default timestamp. (#5564)

Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
Zhuofeng Wang 2024-12-17 17:43:01 +08:00 committed by GitHub
parent 60f246ece2
commit 54cee16c31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 144 additions and 18 deletions

View File

@ -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()?,

View File

@ -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,
})
}

View File

@ -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());
}
}