diff --git a/Cargo.lock b/Cargo.lock index 1180cf0b1..0b022ecfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,7 +945,6 @@ name = "designcraft" version = "0.11.0" dependencies = [ "arboard", - "chrono", "designcraft-engine", "designcraft-ui-egui", "eframe", diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 81a267aa9..7dc83bc55 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -18,7 +18,6 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } -chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for designcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs index 5fe70a749..04238e853 100644 --- a/apps/designcraft/src/logging.rs +++ b/apps/designcraft/src/logging.rs @@ -97,31 +97,11 @@ pub fn timestamp(t: SystemTime) -> String { // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value // past `i64::MAX` seconds saturates rather than falling back to the epoch. let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); - let (y, t) = utc_parts(secs, since.subsec_nanos()); + let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos()); // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into -/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), -/// so month, day and time of day come from chrono and only the year is carried, which keeps every -/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. -fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { - use chrono::Datelike; - let (days, sod) = (secs.div_euclid(86_400), secs.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, nanos.min(999_999_999))); - match (date, time) { - (Some(d), Some(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) - } - _ => (1970, chrono::NaiveDateTime::default()), // unreachable - } -} - fn numbered(dir: &Path, n: usize) -> PathBuf { if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bf834ae0..5c854ba9f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -35,6 +35,7 @@ pub mod story; pub mod styles; mod swatch_refs; pub mod table; +pub mod time; pub mod vars; pub mod xref; diff --git a/crates/doc/src/time.rs b/crates/doc/src/time.rs new file mode 100644 index 000000000..fb21b4b4b --- /dev/null +++ b/crates/doc/src/time.rs @@ -0,0 +1,74 @@ +//! Unix time → proleptic Gregorian calendar, exact for every `i64`. +//! +//! chrono is the only calendar logic. `NaiveDate` covers about ±262143 years, but clocks and files +//! can hold instants past that, so these extend its range: the Gregorian calendar repeats every +//! 400 years (146_097 days), so the instant is moved into chrono's range by whole cycles, chrono +//! gives the month, day and time of day, and the cycles are added back to the year (at most about +//! 2.5e16, so it fits an `i64`). Callers own only their output format and any year clamp. + +use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime}; + +/// Days since 1970-01-01 → (proleptic Gregorian year, a chrono date with the same month and day; +/// its own year is not the real one). +pub fn date_parts(days: i64) -> (i64, NaiveDate) { + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // 1970-01-01 is CE day 719_163; one cycle on from it is well inside chrono's range. + let d = NaiveDate::from_num_days_from_ce_opt(rest as i32 + 719_163).unwrap_or_default(); + (i64::from(d.year()) + cycles * 400, d) +} + +/// Unix seconds (+ nanos, clamped to 999_999_999) → (proleptic Gregorian year, a chrono date and +/// time with the same month, day and time of day; its own year is not the real one). +pub fn utc_parts(secs: i64, nanos: u32) -> (i64, NaiveDateTime) { + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (y, d) = date_parts(days); + let t = NaiveTime::from_num_seconds_from_midnight_opt(sod as u32, nanos.min(999_999_999)).unwrap_or_default(); + (y, d.and_time(t)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Timelike; + + fn ymdhms(secs: i64) -> (i64, u32, u32, u32, u32, u32) { + let (y, t) = utc_parts(secs, 0); + (y, t.month(), t.day(), t.hour(), t.minute(), t.second()) + } + + #[test] + fn epoch_and_ordinary_dates() { + assert_eq!(ymdhms(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(ymdhms(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + } + + #[test] + fn before_1970() { + assert_eq!(ymdhms(-1), (1969, 12, 31, 23, 59, 59)); + assert_eq!(ymdhms(-86_400), (1969, 12, 31, 0, 0, 0)); + assert_eq!(ymdhms(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20)); + } + + #[test] + fn year_9999_rolls_into_10000() { + assert_eq!(ymdhms(253_402_300_799), (9999, 12, 31, 23, 59, 59)); + assert_eq!(ymdhms(253_402_300_800), (10000, 1, 1, 0, 0, 0)); + } + + #[test] + fn i64_extremes_stay_exact() { + assert_eq!(ymdhms(i64::MAX), (292277026596, 12, 4, 15, 30, 7)); + assert_eq!(ymdhms(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); + // Days (not seconds) at the extremes: whole cycles still fit, so the year can't overflow. + let ((hi, _), (lo, _)) = (date_parts(i64::MAX), date_parts(i64::MIN)); + assert!(hi > 25_000_000_000_000_000 && hi < i64::MAX && lo < -25_000_000_000_000_000 && lo > i64::MIN); + } + + #[test] + fn nanos_past_a_second_are_clamped() { + assert_eq!(utc_parts(0, 999_999_999).1.nanosecond(), 999_999_999); + assert_eq!(utc_parts(0, 1_500_000_000).1.nanosecond(), 999_999_999); + assert_eq!(utc_parts(0, u32::MAX).1.second(), 0); + } +} diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 0a3f5ea46..c53eedb1a 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -126,24 +126,12 @@ pub fn now() -> i64 { } } -/// (year, month 1–12, day 1–31) from days since 1970-01-01. -/// -/// `NaiveDate` only covers about ±262143 years, but the system clock can run past it, so this -/// stays exact for every `days`. The Gregorian calendar repeats every 400 years (146_097 days): -/// chrono converts the day within its cycle and whole cycles go back into the year (an i128 so no -/// `days` overflows), which matches the proleptic Gregorian answer the old hand-rolled code gave. -/// 1970-01-01 is `num_days_from_ce` 719_163. +/// (year, month 1–12, day 1–31) from days since 1970-01-01, exact for every `days` +/// (see [`crate::time::date_parts`]). fn civil(days: i64) -> (i64, u32, u32) { use chrono::Datelike; - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - // Unreachable: `rest + 719_163` is always a valid CE day. - None => (1970, 1, 1), - } + let (y, d) = crate::time::date_parts(days); + (y, d.month(), d.day()) } /// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year. diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index bd4571501..1df759608 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -127,30 +127,11 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { /// instant is moved into range by whole cycles and only the year is carried (matches the old /// `civil_from_days`), rather than resetting to the epoch. fn iso8601(t: i64) -> String { - let (y, dt) = utc_parts(t, 0); + let (y, dt) = designcraft_doc::time::utc_parts(t, 0); // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) } -/// Unix seconds (+ nanos) → (proleptic Gregorian year, the instant moved into chrono's range by -/// whole 400-year cycles). Month, day and time of day come from chrono; only the year is carried, -/// which keeps every `i64` exact. chrono is the only calendar logic. -fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { - use chrono::Datelike; - let (days, sod) = (secs.div_euclid(86_400), secs.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, nanos.min(999_999_999))); - match (date, time) { - (Some(d), Some(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) - } - _ => (1970, chrono::NaiveDateTime::default()), // unreachable - } -} - /// The package's `dcterms:modified`: the given time, else the document's last save or creation. fn modified(doc: &Document, opts: &EpubOptions) -> String { let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); @@ -206,8 +187,6 @@ pub struct FixedPage { /// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let lang = language_tag(&opts.language); let id = identifier(doc, opts, &title); @@ -239,12 +218,41 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions "\n\n\n{}\n\n\n", xml_text(&title) ); - let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", - xml_text(&id), - xml_text(&title) + let rendition = format!( + "\npre-paginated\nauto\n" ); + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); + let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); + write_package(&opf, &nav, entries) +} + +/// The package metadata both EPUB flavours share. +struct PackageHead<'a> { + lang: &'a str, + id: &'a str, + title: &'a str, + author: Option<&'a str>, + modified: &'a str, +} + +/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after +/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. +fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { + let PackageHead { lang, id, title, author, modified } = h; + let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); + format!( + "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", + xml_text(id), + xml_text(title) + ) +} + +/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order +/// as (path, bytes, stored uncompressed). +fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { + let io = |e: std::io::Error| EpubError(e.to_string()); + let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); { @@ -259,9 +267,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } - for (name, data) in &files { - let o = if name.ends_with(".png") { stored } else { deflate }; - z.start_file(name.as_str(), o).map_err(zerr)?; + for (name, data, store) in entries { + z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?; z.write_all(data).map_err(io)?; } z.finish().map_err(zerr)?; @@ -1379,8 +1386,6 @@ fn base64(data: &[u8]) -> String { /// Export the document as a reflowable EPUB 3. pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); @@ -1441,56 +1446,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n", - xml_text(&id), - xml_text(&title) - ); - let container = "\n\n"; - let mut buf = std::io::Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).map_err(zerr)?; - z.write_all(b"application/epub+zip").map_err(io)?; - for (name, data) in [ - ("META-INF/container.xml", container.as_bytes()), - ("OEBPS/content.opf", opf.as_bytes()), - ("OEBPS/nav.xhtml", nav.as_bytes()), - ("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()), - ] { - z.start_file(name, deflate).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (k, c) in chapters.iter().enumerate() { - z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?; - z.write_all(c.as_bytes()).map_err(io)?; - } - for (name, (_, data)) in &images { - z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (k, f) in opts.fonts.iter().enumerate() { - z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?; - z.write_all(&f.data).map_err(io)?; - } - if let Some(c) = &opts.cover { - z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?; - z.write_all(c).map_err(io)?; - let xhtml = format!( - "\n\n\n{}\n\"{}\"/\n\n", - xml_text(&title), - cover_file(c).0, - xml_attr(&title) - ); - z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; - z.write_all(xhtml.as_bytes()).map_err(io)?; - } - z.finish().map_err(zerr)?; + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); + let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); + let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; + entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); + entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); + entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); + let cover_xhtml = opts.cover.as_ref().map(|c| { + format!( + "\n\n\n{}\n\"{}\"/\n\n", + xml_text(&title), + cover_file(c).0, + xml_attr(&title) + ) + }); + if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { + entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); + entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); } - Ok(buf.into_inner()) + write_package(&opf, &nav, entries) } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index df44acbf2..3abcdb521 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -442,19 +442,8 @@ fn date_time(t: i64) -> krilla::metadata::DateTime { /// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp. pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { use chrono::{Datelike, Timelike}; - 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(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); - (y, d.month() as u8, d.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) - } - _ => (1970, 1, 1, 0, 0, 0), // unreachable - } + let (y, t) = designcraft_doc::time::utc_parts(t, 0); + (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) } /// A successful conversion and its name-independent diagnostic. Replayed once for each asset. diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs index 13939aecc..7e9e123e0 100644 --- a/crates/pptx/src/package.rs +++ b/crates/pptx/src/package.rs @@ -296,19 +296,8 @@ fn core(p: &Package) -> String { /// matching the old `civil`), then the year is clamped — never a silent reset to the epoch. fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) { use chrono::{Datelike, Timelike}; - 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(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); - (y.clamp(0, 9999), d.month(), d.day(), t.hour(), t.minute(), t.second()) - } - _ => (1970, 1, 1, 0, 0, 0), // unreachable - } + let (y, t) = designcraft_doc::time::utc_parts(t, 0); + (y.clamp(0, 9999), t.month(), t.day(), t.hour(), t.minute(), t.second()) } /// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.