Files
vectorcraft/crates/doc/src/metadata.rs
T
Taha-Mahmoodi 99be3dca57 Fix #3626: the imported title goes into File Info only; the display name is unchanged
An SVG's root <title> and a PDF's, .ai's or .ait's XMP dc:title (else Info Title) are now
DocMetadata::title, the File Info title, not Document::title. Document::title stays what it was
on main: the file's name with its extension (a native file keeps its own), so the tab, the window
title, Save As, the default export names and Save for Web are unchanged.

- Document::info_title() is metadata.title else the document's title. file.info reports and
  compares it, document.inspect and the document.open reply report it as fileInfoTitle, and the
  writers of a title (PDF Title and XMP dc:title, SVG <title> and Dublin Core, PNG text, EPS, .ai
  and EMF) and has_own_title (DisplayDocTitle) use it. The PDF page-information mark and the
  default file names still use the document's name.
- Changing the title in File Info sets the document's title as it always did and drops the one the
  file gave; the unchanged title the dialog sends back is no edit.
- metadata.title is saved in the native format only when set (older versions ignore it) and is read
  back from the editing data of an SVG, PDF, .ai or EPS; a template opens untitled.
- Reverts the file-stem title, the stem_title cleaning and the svgz branch that only existed to make
  Document::title a stem, and the five assertions that change had made.
2026-11-04 10:54:36 +04:30

267 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it
//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and
//! [`Document::info_title`] is the title File Info shows and exports write. Every field is
//! defaulted, so documents saved before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
use crate::Document;
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CopyrightStatus {
#[default]
Unknown,
Copyrighted,
PublicDomain,
}
impl CopyrightStatus {
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
pub fn id(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "unknown",
CopyrightStatus::Copyrighted => "copyrighted",
CopyrightStatus::PublicDomain => "publicDomain",
}
}
pub fn label(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "Unknown",
CopyrightStatus::Copyrighted => "Copyrighted",
CopyrightStatus::PublicDomain => "Public Domain",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s) || c.label().eq_ignore_ascii_case(s))
}
}
/// Highest File Info rating (stars).
pub const MAX_RATING: u8 = 5;
/// File → File Info.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
/// The title the file this document was opened from gives itself (an SVG's root `<title>`, a
/// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of
/// [`Document::title`], the document's display name. `None`: the file gave none, so the
/// display name is the title. Left out of a saved document when unset, so other documents
/// save as they did.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub author: String,
/// The author's position or job title.
pub author_title: String,
pub description: String,
/// Keywords, each once, in the order they were added.
pub keywords: Vec<String>,
/// 0 (unrated) to [`MAX_RATING`] stars.
pub rating: u8,
pub copyright_status: CopyrightStatus,
pub copyright_notice: String,
/// Where to read more about the copyright.
pub copyright_url: String,
/// When the document was created, as Unix seconds (UTC).
pub created: Option<i64>,
/// When the document was last saved, as Unix seconds (UTC).
pub modified: Option<i64>,
}
impl DocMetadata {
/// The PNG text entries (registered keywords where one fits) for a document titled `title`:
/// only the non-empty ones.
pub fn png_text<'a>(&'a self, title: &'a str) -> Vec<(&'static str, std::borrow::Cow<'a, str>)> {
let mut out: Vec<(&'static str, std::borrow::Cow<'a, str>)> = vec![];
let mut push = |k: &'static str, v: std::borrow::Cow<'a, str>| {
if !v.trim().is_empty() {
out.push((k, v));
}
};
push("Title", title.into());
push("Author", self.author.as_str().into());
push("Description", self.description.as_str().into());
push("Keywords", self.keywords.join(", ").into());
push("Copyright", self.copyright_notice.as_str().into());
push("Copyright URL", self.copyright_url.as_str().into());
push("Creation Time", self.created.map(iso8601).unwrap_or_default().into());
out
}
}
impl Document {
/// The title File Info shows and the files an export writes carry (PDF `Title` and XMP
/// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from
/// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the
/// default file name) is always [`Document::title`].
pub fn info_title(&self) -> &str {
self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title)
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
/// bytes twice (#2880).
///
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
/// encryption: neither may be pinned by the environment.
pub fn output_time() -> Option<i64> {
pinned_or(|n| std::env::var(n).ok(), now_unix)
}
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
/// variable is preferred and that the clock answers whenever it does not.
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
stamp_from_env(get).or_else(clock)
}
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
/// the clock decides — an export is never refused over the environment.
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
let value = get("SOURCE_DATE_EPOCH")?;
let value = value.trim();
// `i64::from_str` also takes a leading `+`, which `date +%s` never prints.
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
value.parse::<i64>().ok()
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok())
}
#[cfg(target_arch = "wasm32")]
pub fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years
/// clamped to 0–9999).
pub fn civil(t: i64) -> [i64; 6] {
// Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31),
// exactly as the old code did. The clamped instant is always inside chrono's range, so the
// conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp.
let t = t.clamp(-62_167_219_200, 253_402_300_799);
let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(tm)) => {
let year = i128::from(d.year()) + i128::from(cycles) * 400;
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
[year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())]
}
_ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date
}
}
/// Unix seconds → `2026-10-05T14:03:00Z`.
pub fn iso8601(t: i64) -> String {
let [y, mo, d, h, mi, s] = civil(t);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_and_statuses() {
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
// The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999,
// exactly as the old code did: inputs far past chrono's range land on the clamp bounds,
// never on the epoch.
assert_eq!(civil(i64::MIN)[0], 0);
assert_eq!(civil(i64::MAX)[0], 9999);
assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z");
assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z");
assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z");
assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z");
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("maybe"), None);
}
#[test]
fn png_text_skips_empty_entries() {
let m = DocMetadata { author: "Ada".into(), keywords: vec!["a".into(), "b".into()], created: Some(0), ..Default::default() };
let t: Vec<(&str, String)> = m.png_text("Poster").into_iter().map(|(k, v)| (k, v.into_owned())).collect();
assert_eq!(
t,
[
("Title", "Poster".to_string()),
("Author", "Ada".into()),
("Keywords", "a, b".into()),
("Creation Time", "1970-01-01T00:00:00Z".into())
]
);
assert!(DocMetadata::default().png_text("").is_empty());
}
#[test]
fn the_info_title_is_the_files_own_title_else_the_display_name() {
let mut d = Document::new(10.0, 10.0);
d.title = "box.svg".into();
assert_eq!(d.info_title(), "box.svg");
d.metadata.title = Some("Access paths".into());
assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths"));
d.metadata.title = Some(" ".into());
assert_eq!(d.info_title(), "box.svg");
// Saved only when set: other documents' metadata is as before, and the field comes back.
d.metadata.title = None;
assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title"));
d.metadata.title = Some("Access paths".into());
let json = serde_json::to_string(&d.metadata).unwrap();
assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths"));
assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None);
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "+1791208980", "+0", "99999999999999999999"] {
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
}
assert_eq!(stamp_from_env(env(None)), None, "unset");
// Another variable never stands in for it.
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
}
/// What a file records: the variable when it is usable, the clock whenever it is not. The
/// environment and the clock are passed in, so no test changes this process's environment --
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
#[test]
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
let clock = || Some(1_000_000);
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
for ignored in [None, Some(""), Some("-1"), Some("later")] {
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
}
// A platform with no clock and no variable has no date to record.
assert_eq!(pinned_or(env(None), || None), None);
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
}
}