summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Haug <mhaug@live.de>2023-10-27 18:33:23 +0200
committerGitHub <noreply@github.com>2023-10-27 18:33:23 +0200
commitf78a8f5d4863260d05cbabe2711324489be47242 (patch)
tree7cfa0bdd805ae3c385812ef4f74aa14e867fc7fc
parent4163b2eabc1df29741228838fdce9d428b662500 (diff)
Add IDs and creation date to PDFs (#2374)
-rw-r--r--crates/typst-cli/src/compile.rs27
-rw-r--r--crates/typst-cli/src/world.rs8
-rw-r--r--crates/typst-library/src/meta/document.rs10
-rw-r--r--crates/typst/src/doc.rs5
-rw-r--r--crates/typst/src/export/pdf/mod.rs118
-rw-r--r--tests/src/tests.rs6
-rw-r--r--tests/typ/meta/document.typ2
7 files changed, 147 insertions, 29 deletions
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
index 6a5ca21e..b5cf0e54 100644
--- a/crates/typst-cli/src/compile.rs
+++ b/crates/typst-cli/src/compile.rs
@@ -1,12 +1,13 @@
use std::fs;
use std::path::{Path, PathBuf};
+use chrono::{Datelike, Timelike};
use codespan_reporting::diagnostic::{Diagnostic, Label};
use codespan_reporting::term::{self, termcolor};
use termcolor::{ColorChoice, StandardStream};
use typst::diag::{bail, Severity, SourceDiagnostic, StrResult};
use typst::doc::Document;
-use typst::eval::{eco_format, Tracer};
+use typst::eval::{eco_format, Datetime, Tracer};
use typst::geom::Color;
use typst::syntax::{FileId, Source, Span};
use typst::{World, WorldExt};
@@ -141,19 +142,37 @@ fn export(
OutputFormat::Svg => {
export_image(world, document, command, watching, ImageExportFormat::Svg)
}
- OutputFormat::Pdf => export_pdf(document, command),
+ OutputFormat::Pdf => export_pdf(document, command, world),
}
}
/// Export to a PDF.
-fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> {
+fn export_pdf(
+ document: &Document,
+ command: &CompileCommand,
+ world: &SystemWorld,
+) -> StrResult<()> {
+ let ident = world.input().to_string_lossy();
+ let buffer = typst::export::pdf(document, Some(&ident), now());
let output = command.output();
- let buffer = typst::export::pdf(document);
fs::write(output, buffer)
.map_err(|err| eco_format!("failed to write PDF file ({err})"))?;
Ok(())
}
+/// Get the current date and time in UTC.
+fn now() -> Option<Datetime> {
+ let now = chrono::Local::now().naive_utc();
+ Datetime::from_ymd_hms(
+ now.year(),
+ now.month().try_into().ok()?,
+ now.day().try_into().ok()?,
+ now.hour().try_into().ok()?,
+ now.minute().try_into().ok()?,
+ now.second().try_into().ok()?,
+ )
+}
+
/// An image format to export in.
enum ImageExportFormat {
Png,
diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs
index 500b64e5..81480e62 100644
--- a/crates/typst-cli/src/world.rs
+++ b/crates/typst-cli/src/world.rs
@@ -25,6 +25,8 @@ use crate::package::prepare_package;
pub struct SystemWorld {
/// The working directory.
workdir: Option<PathBuf>,
+ /// The canonical path to the input file.
+ input: PathBuf,
/// The root relative to which absolute paths are resolved.
root: PathBuf,
/// The input path.
@@ -78,6 +80,7 @@ impl SystemWorld {
Ok(Self {
workdir: std::env::current_dir().ok(),
+ input,
root,
main: FileId::new(None, main_path),
library: Prehashed::new(typst_library::build()),
@@ -123,6 +126,11 @@ impl SystemWorld {
self.now.take();
}
+ /// Return the canonical path to the input file.
+ pub fn input(&self) -> &PathBuf {
+ &self.input
+ }
+
/// Lookup a source file by id.
#[track_caller]
pub fn lookup(&self, id: FileId) -> Source {
diff --git a/crates/typst-library/src/meta/document.rs b/crates/typst-library/src/meta/document.rs
index 57ce064c..55c365ac 100644
--- a/crates/typst-library/src/meta/document.rs
+++ b/crates/typst-library/src/meta/document.rs
@@ -34,9 +34,13 @@ pub struct DocumentElem {
/// The document's creation date.
///
- /// The year component must be at least zero in order to be embedded into
- /// a PDF.
- pub date: Option<Datetime>,
+ /// If this is `{auto}` (default), Typst uses the current date and time.
+ /// Setting it to `{none}` prevents Typst from embedding any creation date
+ /// into the PDF metadata.
+ ///
+ /// The year component must be at least zero in order to be embedded into a
+ /// PDF.
+ pub date: Smart<Option<Datetime>>,
/// The page runs.
#[internal]
diff --git a/crates/typst/src/doc.rs b/crates/typst/src/doc.rs
index 890a94a5..1c23fd75 100644
--- a/crates/typst/src/doc.rs
+++ b/crates/typst/src/doc.rs
@@ -13,7 +13,8 @@ use crate::export::PdfPageLabel;
use crate::font::Font;
use crate::geom::{
self, styled_rect, Abs, Axes, Color, Corners, Dir, Em, FixedAlign, FixedStroke,
- Geometry, Length, Numeric, Paint, Path, Point, Rel, Shape, Sides, Size, Transform,
+ Geometry, Length, Numeric, Paint, Path, Point, Rel, Shape, Sides, Size, Smart,
+ Transform,
};
use crate::image::Image;
use crate::model::{Content, Location, MetaElem, StyleChain};
@@ -31,7 +32,7 @@ pub struct Document {
/// The document's keywords.
pub keywords: Vec<EcoString>,
/// The document's creation date.
- pub date: Option<Datetime>,
+ pub date: Smart<Option<Datetime>>,
}
/// A finished layout with items at fixed positions.
diff --git a/crates/typst/src/export/pdf/mod.rs b/crates/typst/src/export/pdf/mod.rs
index c64b95b0..d18023cf 100644
--- a/crates/typst/src/export/pdf/mod.rs
+++ b/crates/typst/src/export/pdf/mod.rs
@@ -16,15 +16,17 @@ use std::collections::{BTreeMap, HashMap};
use std::hash::Hash;
use std::num::NonZeroUsize;
+use base64::Engine;
use ecow::{eco_format, EcoString};
use pdf_writer::types::Direction;
use pdf_writer::writers::PageLabel;
use pdf_writer::{Finish, Name, Pdf, Ref, TextStr};
-use xmp_writer::{LangId, RenditionClass, XmpWriter};
+use xmp_writer::{DateTime, LangId, RenditionClass, Timezone, XmpWriter};
use self::gradient::PdfGradient;
use self::page::Page;
use crate::doc::{Document, Lang};
+use crate::eval::Datetime;
use crate::font::Font;
use crate::geom::{Abs, Dir, Em};
use crate::image::Image;
@@ -35,8 +37,22 @@ use extg::ExtGState;
/// Export a document into a PDF file.
///
/// Returns the raw bytes making up the PDF file.
+///
+/// The `ident` parameter shall be a string that uniquely and stably identifies
+/// the document. It is used to write a PDF file identifier. It should not
+/// change between compilations of the same document. If it is `None`, a hash of
+/// the document is used instead (which means that it _will_ change across
+/// compilations).
+///
+/// The `timestamp`, if given, is expected to be the creation date of the
+/// document as a UTC datetime. It will be used as the PDFs creation date unless
+/// another date is given through `set document(date: ..)`.
#[tracing::instrument(skip_all)]
-pub fn pdf(document: &Document) -> Vec<u8> {
+pub fn pdf(
+ document: &Document,
+ ident: Option<&str>,
+ timestamp: Option<Datetime>,
+) -> Vec<u8> {
let mut ctx = PdfContext::new(document);
page::construct_pages(&mut ctx, &document.pages);
font::write_fonts(&mut ctx);
@@ -44,7 +60,7 @@ pub fn pdf(document: &Document) -> Vec<u8> {
gradient::write_gradients(&mut ctx);
extg::write_external_graphics_states(&mut ctx);
page::write_page_tree(&mut ctx);
- write_catalog(&mut ctx);
+ write_catalog(&mut ctx, ident, timestamp);
ctx.pdf.finish()
}
@@ -127,7 +143,7 @@ impl<'a> PdfContext<'a> {
/// Write the document catalog.
#[tracing::instrument(skip_all)]
-fn write_catalog(ctx: &mut PdfContext) {
+fn write_catalog(ctx: &mut PdfContext, ident: Option<&str>, timestamp: Option<Datetime>) {
let lang = ctx
.languages
.iter()
@@ -171,21 +187,15 @@ fn write_catalog(ctx: &mut PdfContext) {
xmp.pdf_keywords(&joined);
}
- if let Some(date) = ctx.document.date {
- if let Some(year) = date.year().filter(|&y| y >= 0) {
- let mut pdf_date = pdf_writer::Date::new(year as u16);
- if let Some(month) = date.month() {
- pdf_date = pdf_date.month(month);
- }
- if let Some(day) = date.day() {
- pdf_date = pdf_date.day(day);
- }
+ if let Some(date) = ctx.document.date.unwrap_or(timestamp) {
+ let tz = ctx.document.date.is_auto();
+ if let Some(pdf_date) = pdf_date(date, tz) {
info.creation_date(pdf_date);
-
- let mut xmp_date = xmp_writer::DateTime::year(year as u16);
- xmp_date.month = date.month();
- xmp_date.day = date.day();
+ info.modified_date(pdf_date);
+ }
+ if let Some(xmp_date) = xmp_date(date, tz) {
xmp.create_date(xmp_date);
+ xmp.modify_date(xmp_date);
}
}
@@ -193,6 +203,25 @@ fn write_catalog(ctx: &mut PdfContext) {
xmp.num_pages(ctx.document.pages.len() as u32);
xmp.format("application/pdf");
xmp.language(ctx.languages.keys().map(|lang| LangId(lang.as_str())));
+
+ // A unique ID for this instance of the document. Changes if anything
+ // changes in the frames.
+ let instance_id =
+ hash_base64(&(&ctx.document, ctx.document.date.unwrap_or(timestamp)));
+
+ if let Some(ident) = ident {
+ // A unique ID for the document that stays stable across compilations.
+ let doc_id = hash_base64(&("PDF-1.7", ident));
+ xmp.document_id(&doc_id);
+ xmp.instance_id(&instance_id);
+ ctx.pdf
+ .set_file_id((doc_id.clone().into_bytes(), instance_id.into_bytes()));
+ } else {
+ // This is not spec-compliant, but some PDF readers really want an ID.
+ let bytes = instance_id.into_bytes();
+ ctx.pdf.set_file_id((bytes.clone(), bytes));
+ }
+
xmp.rendition_class(RenditionClass::Proof);
xmp.pdf_version("1.7");
@@ -207,7 +236,7 @@ fn write_catalog(ctx: &mut PdfContext) {
let mut catalog = ctx.pdf.catalog(ctx.alloc.bump());
catalog.pages(ctx.page_tree_ref);
catalog.viewer_preferences().direction(dir);
- catalog.pair(Name(b"Metadata"), meta_ref);
+ catalog.metadata(meta_ref);
// Insert the page labels.
if !page_labels.is_empty() {
@@ -283,6 +312,59 @@ fn deflate(data: &[u8]) -> Vec<u8> {
miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL)
}
+/// Create a base64-encoded hash of the value.
+fn hash_base64<T: Hash>(value: &T) -> String {
+ base64::engine::general_purpose::STANDARD
+ .encode(crate::util::hash128(value).to_be_bytes())
+}
+
+/// Converts a datetime to a pdf-writer date.
+fn pdf_date(datetime: Datetime, tz: bool) -> Option<pdf_writer::Date> {
+ let year = datetime.year().filter(|&y| y >= 0)? as u16;
+
+ let mut pdf_date = pdf_writer::Date::new(year);
+
+ if let Some(month) = datetime.month() {
+ pdf_date = pdf_date.month(month);
+ }
+
+ if let Some(day) = datetime.day() {
+ pdf_date = pdf_date.day(day);
+ }
+
+ if let Some(h) = datetime.hour() {
+ pdf_date = pdf_date.hour(h);
+ }
+
+ if let Some(m) = datetime.minute() {
+ pdf_date = pdf_date.minute(m);
+ }
+
+ if let Some(s) = datetime.second() {
+ pdf_date = pdf_date.second(s);
+ }
+
+ if tz {
+ pdf_date = pdf_date.utc_offset_hour(0).utc_offset_minute(0);
+ }
+
+ Some(pdf_date)
+}
+
+/// Converts a datetime to an xmp-writer datetime.
+fn xmp_date(datetime: Datetime, tz: bool) -> Option<xmp_writer::DateTime> {
+ let year = datetime.year().filter(|&y| y >= 0)? as u16;
+ Some(DateTime {
+ year,
+ month: datetime.month(),
+ day: datetime.day(),
+ hour: datetime.hour(),
+ minute: datetime.minute(),
+ second: datetime.second(),
+ timezone: if tz { Some(Timezone::Utc) } else { None },
+ })
+}
+
/// Assigns new, consecutive PDF-internal indices to items.
struct Remapper<T> {
/// Forwards from the items to the pdf indices.
diff --git a/tests/src/tests.rs b/tests/src/tests.rs
index 1aed32d7..d974b731 100644
--- a/tests/src/tests.rs
+++ b/tests/src/tests.rs
@@ -420,7 +420,11 @@ fn test(
let document = Document { pages: frames, ..Default::default() };
if compare_ever {
if let Some(pdf_path) = pdf_path {
- let pdf_data = typst::export::pdf(&document);
+ let pdf_data = typst::export::pdf(
+ &document,
+ Some(&format!("typst-test: {}", name.display())),
+ world.today(Some(0)),
+ );
fs::create_dir_all(pdf_path.parent().unwrap()).unwrap();
fs::write(pdf_path, pdf_data).unwrap();
}
diff --git a/tests/typ/meta/document.typ b/tests/typ/meta/document.typ
index 814d547d..43e4ca39 100644
--- a/tests/typ/meta/document.typ
+++ b/tests/typ/meta/document.typ
@@ -11,7 +11,7 @@ What's up?
#set document(author: ("A", "B"), date: datetime.today())
---
-// Error: 21-28 expected datetime or none, found string
+// Error: 21-28 expected datetime, none, or auto, found string
#set document(date: "today")
---