mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 22:21:59 +00:00
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.
267 lines
13 KiB
Rust
267 lines
13 KiB
Rust
//! 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");
|
||
}
|
||
}
|