mirror of
https://github.com/typst/typst
synced 2025-05-13 20:46:23 +08:00
Add timezone to PDF's default timestamp. (#5564)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
parent
60f246ece2
commit
54cee16c31
@ -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()?,
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user