mirror of
https://github.com/typst/typst
synced 2025-05-14 17:15:28 +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::layout::{Frame, Page, PageRanges, PagedDocument};
|
||||||
use typst::syntax::{FileId, Source, Span};
|
use typst::syntax::{FileId, Source, Span};
|
||||||
use typst::WorldExt;
|
use typst::WorldExt;
|
||||||
use typst_pdf::{PdfOptions, PdfStandards};
|
use typst_pdf::{PdfOptions, PdfStandards, Timestamp};
|
||||||
|
|
||||||
use crate::args::{
|
use crate::args::{
|
||||||
CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat,
|
CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat,
|
||||||
@ -55,7 +55,7 @@ pub struct CompileConfig {
|
|||||||
pub output_format: OutputFormat,
|
pub output_format: OutputFormat,
|
||||||
/// Which pages to export.
|
/// Which pages to export.
|
||||||
pub pages: Option<PageRanges>,
|
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>>,
|
pub creation_timestamp: Option<DateTime<Utc>>,
|
||||||
/// The format to emit diagnostics in.
|
/// The format to emit diagnostics in.
|
||||||
pub diagnostic_format: DiagnosticFormat,
|
pub diagnostic_format: DiagnosticFormat,
|
||||||
@ -271,11 +271,23 @@ fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResul
|
|||||||
|
|
||||||
/// Export to a PDF.
|
/// Export to a PDF.
|
||||||
fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> {
|
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 {
|
let options = PdfOptions {
|
||||||
ident: Smart::Auto,
|
ident: Smart::Auto,
|
||||||
timestamp: convert_datetime(
|
timestamp,
|
||||||
config.creation_timestamp.unwrap_or_else(chrono::Utc::now),
|
|
||||||
),
|
|
||||||
page_ranges: config.pages.clone(),
|
page_ranges: config.pages.clone(),
|
||||||
standards: config.pdf_standards.clone(),
|
standards: config.pdf_standards.clone(),
|
||||||
};
|
};
|
||||||
@ -289,7 +301,9 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert [`chrono::DateTime`] to [`Datetime`]
|
/// 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(
|
Datetime::from_ymd_hms(
|
||||||
date_time.year(),
|
date_time.year(),
|
||||||
date_time.month().try_into().ok()?,
|
date_time.month().try_into().ok()?,
|
||||||
|
@ -9,10 +9,10 @@ use typst_library::foundations::{Datetime, Smart};
|
|||||||
use typst_library::layout::Dir;
|
use typst_library::layout::Dir;
|
||||||
use typst_library::text::Lang;
|
use typst_library::text::Lang;
|
||||||
use typst_syntax::Span;
|
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::page::PdfPageLabel;
|
||||||
use crate::{hash_base64, outline, TextStrExt, WithEverything};
|
use crate::{hash_base64, outline, TextStrExt, Timezone, WithEverything};
|
||||||
|
|
||||||
/// Write the document catalog.
|
/// Write the document catalog.
|
||||||
pub fn write_catalog(
|
pub fn write_catalog(
|
||||||
@ -87,8 +87,17 @@ pub fn write_catalog(
|
|||||||
xmp.pdf_keywords(&joined);
|
xmp.pdf_keywords(&joined);
|
||||||
}
|
}
|
||||||
|
|
||||||
let date = ctx.document.info.date.unwrap_or(ctx.options.timestamp);
|
// (1) If the `document.date` is set to specific `datetime` or `none`, use it.
|
||||||
let tz = ctx.document.info.date.is_auto();
|
// (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(date) = date {
|
||||||
if let Some(pdf_date) = pdf_date(date, tz) {
|
if let Some(pdf_date) = pdf_date(date, tz) {
|
||||||
info.creation_date(pdf_date);
|
info.creation_date(pdf_date);
|
||||||
@ -281,7 +290,7 @@ pub(crate) fn write_page_labels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a datetime to a pdf-writer date.
|
/// 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 year = datetime.year().filter(|&y| y >= 0)? as u16;
|
||||||
|
|
||||||
let mut pdf_date = pdf_writer::Date::new(year);
|
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);
|
pdf_date = pdf_date.second(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if tz {
|
match tz {
|
||||||
pdf_date = pdf_date.utc_offset_hour(0).utc_offset_minute(0);
|
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)
|
Some(pdf_date)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a datetime to an xmp-writer datetime.
|
/// 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 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 {
|
Some(DateTime {
|
||||||
year,
|
year,
|
||||||
month: datetime.month(),
|
month: datetime.month(),
|
||||||
@ -323,6 +352,6 @@ fn xmp_date(datetime: Datetime, tz: bool) -> Option<xmp_writer::DateTime> {
|
|||||||
hour: datetime.hour(),
|
hour: datetime.hour(),
|
||||||
minute: datetime.minute(),
|
minute: datetime.minute(),
|
||||||
second: datetime.second(),
|
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
|
/// `Auto`, a hash of the document's title and author is used instead (which
|
||||||
/// is reasonably unique and stable).
|
/// is reasonably unique and stable).
|
||||||
pub ident: Smart<&'a str>,
|
pub ident: Smart<&'a str>,
|
||||||
/// If not `None`, shall be the creation date of the document as a UTC
|
/// If not `None`, shall be the creation timestamp of the document. It will
|
||||||
/// datetime. It will only be used if `set document(date: ..)` is `auto`.
|
/// only be used if `set document(date: ..)` is `auto`.
|
||||||
pub timestamp: Option<Datetime>,
|
pub timestamp: Option<Timestamp>,
|
||||||
/// Specifies which ranges of pages should be exported in the PDF. When
|
/// Specifies which ranges of pages should be exported in the PDF. When
|
||||||
/// `None`, all pages should be exported.
|
/// `None`, all pages should be exported.
|
||||||
pub page_ranges: Option<PageRanges>,
|
pub page_ranges: Option<PageRanges>,
|
||||||
@ -99,6 +99,51 @@ pub struct PdfOptions<'a> {
|
|||||||
pub standards: PdfStandards,
|
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.
|
/// Encapsulates a list of compatible PDF standards.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PdfStandards {
|
pub struct PdfStandards {
|
||||||
@ -612,3 +657,41 @@ fn transform_to_array(ts: Transform) -> [f32; 6] {
|
|||||||
ts.ty.to_f32(),
|
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