diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 119b1dbd0..131a88488 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -65,6 +65,19 @@ pub enum VarKind { #[serde(default)] character: bool, }, + /// A variable type that isn't computed here (an image caption, say): it shows the result + /// recorded in the imported file, which already includes any text before and after. + Imported { + /// The file's name for the type, e.g. `LiveCaptionType`. + variable_type: String, + #[serde(default)] + result: String, + /// The type's settings element as read (name and attributes), written back on export. + #[serde(default, skip_serializing_if = "String::is_empty")] + settings_element: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + settings: Vec<(String, String)>, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -126,6 +139,65 @@ fn civil(days: i64) -> (i64, u32, u32) { (yoe + era * 400 + i64::from(m <= 2), m, d) } +/// Days since 1970-01-01 of a civil date (inverse of [`civil`]). +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let mp = i64::from(if m > 2 { m - 3 } else { m + 9 }); + let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Unix seconds of an ISO 8601 timestamp such as `2019-12-16T09:48:02+01:00` (seconds, the +/// time and the zone are optional; no zone means UTC). +pub fn parse_iso_date(s: &str) -> Option { + let s = s.trim(); + let num = |a: usize, b: usize| -> Option { + let t = s.get(a..b)?; + t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten() + }; + let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + if s.get(4..5) != Some("-") || s.get(7..8) != Some("-") || !(1..=12).contains(&mo) || !(1..=31).contains(&d) { + return None; + } + let mut secs = days_from_civil(y, mo as u32, d as u32) * 86_400; + let rest = s.get(10..).unwrap_or(""); + let Some(time) = rest.strip_prefix('T') else { return rest.is_empty().then_some(secs) }; + let tnum = |a: usize, b: usize| -> Option { + let t = time.get(a..b)?; + t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten() + }; + let (h, mi) = (tnum(0, 2)?, tnum(3, 5)?); + let mut i = 5; + let mut sec = 0; + if time.get(5..6) == Some(":") { + sec = tnum(6, 8)?; + i = 8; + // Fractional seconds are ignored. + if time.get(8..9) == Some(".") { + i = 9 + time.get(9..)?.bytes().take_while(u8::is_ascii_digit).count(); + } + } + if h > 23 || mi > 59 || sec > 60 { + return None; + } + secs += h * 3600 + mi * 60 + sec; + let zone = time.get(i..)?; + match zone.chars().next() { + None | Some('Z') => Some(secs), + Some(sign @ ('+' | '-')) => { + let z = zone.get(1..)?; + let zh: i64 = z.get(0..2)?.parse().ok()?; + let zm: i64 = z.get(2..).map(|r| r.trim_start_matches(':')).filter(|r| !r.is_empty()).map_or(Some(0), |r| r.parse().ok())?; + let off = zh * 3600 + zm * 60; + Some(if sign == '+' { secs - off } else { secs + off }) + } + _ => None, + } +} + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; /// Format Unix seconds (UTC) with an InDesign-style pattern. @@ -198,6 +270,7 @@ impl Document { VarKind::ModificationDate { format } => format_date(if self.modified > 0 { self.modified } else { self.created }, format), VarKind::OutputDate { format } => format_date(now(), format), VarKind::RunningHeader { .. } => return None, + VarKind::Imported { result, .. } => return Some(result.clone()), }; Some(format!("{}{}{}", v.before, body, v.after)) } @@ -221,6 +294,18 @@ mod tests { assert_eq!(format_date(951_782_400, "yyyy-MM-dd"), "2000-02-29"); } + #[test] + fn iso_dates() { + assert_eq!(parse_iso_date("2026-10-01T14:05:00Z"), Some(1_790_863_500)); + assert_eq!(parse_iso_date("2026-10-01T16:05:00+02:00"), Some(1_790_863_500)); + assert_eq!(parse_iso_date("2026-10-01T09:05-05:00"), Some(1_790_863_500)); + assert_eq!(parse_iso_date("2000-02-29"), Some(951_782_400)); + assert_eq!(parse_iso_date("2019-12-16T09:48:02.5+01:00").map(|t| format_date(t, "MM/dd/yy HH:mm")), Some("12/16/19 08:48".into())); + for bad in ["", "2019", "2019-13-01", "2019-12-16T25:00", "2019-12-16T09:48+0x", "2019-12-16"] { + assert_eq!(parse_iso_date(bad), None, "{bad}"); + } + } + #[test] fn chars() { assert_eq!(var_index(var_char(3).unwrap()), Some(3)); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 663678493..69abcb74d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -228,6 +228,7 @@ impl<'a> Ex<'a> { let designmap = self.designmap(&masters, &spreads, &story_parts); parts.push(("META-INF/container.xml".to_string(), CONTAINER.as_bytes().to_vec())); + parts.push(("META-INF/metadata.xml".to_string(), metadata_xmp(d).into_bytes())); parts.push(("designmap.xml".to_string(), designmap)); parts.push(("Resources/Graphic.xml".to_string(), graphic)); parts.push(("Resources/Fonts.xml".to_string(), fonts)); @@ -346,6 +347,9 @@ impl<'a> Ex<'a> { let n = format!("TV {name}"); root.push(El::new("TextVariable").attr("Self", format!("dTextVariablen{n}")).attr("Name", &n).attr("VariableType", ty)); } + for v in &d.text_variables { + root.push(text_variable_el(v)); + } // IDML lists layers back to front. for l in d.layers.iter().rev() { let color = match names::layer_color_out(l.color) { @@ -2049,6 +2053,19 @@ impl<'a> Ex<'a> { csrs.push(src); self.xref_sources.push((me, x.target)); } + } else if let Some(vi) = designcraft_doc::vars::var_index(ch) { + flush_text(&mut cur, &mut pending); + flush_content(&mut pending, &mut out); + if let Some(v) = self.d.text_variables.get(vi) { + let result = self.d.variable_value(vi, None).unwrap_or_else(|| format!("{}{}", v.before, v.after)); + out.push(Node::El( + El::new("TextVariableInstance") + .attr("Self", self.fresh()) + .attr("Name", &v.name) + .attr("ResultText", result) + .attr("AssociatedTextVariable", format!("dTextVariablen{}", v.name)), + )); + } } else if ch == designcraft_doc::NOTE_MARK { // Editorial notes aren't written to IDML yet. } else if ch == designcraft_doc::ENDNOTE_REF { @@ -2489,3 +2506,71 @@ fn topic_self(path: &[String]) -> String { } s } + +/// Package metadata: the creation and modification times that date variables show. +fn metadata_xmp(d: &Document) -> String { + use designcraft_doc::vars::format_date; + let iso = |t: i64| format!("{}:{:02}Z", format_date(t, "yyyy-MM-ddTHH:mm"), t.rem_euclid(60)); + let modified = if d.modified > 0 { d.modified } else { d.created }; + format!( + "\n\n \n \n {}\n {}\n \n \n\n", + iso(d.created), + iso(modified) + ) +} + +/// A text variable definition with its type's settings element. +fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El { + use designcraft_doc::vars::{Use, VarKind}; + let pref = |name: &str| El::new(name).attr("TextBefore", &v.before); + let (ty, settings) = match &v.kind { + VarKind::Custom { text } => ("CustomTextType", Some(pref("CustomTextVariablePreference").attr("Contents", text))), + VarKind::LastPageNumber { section } => ( + "LastPageNumberType", + Some( + pref("PageNumberVariablePreference").attr("Format", "Current").attr("Scope", if *section { "SectionScope" } else { "DocumentScope" }), + ), + ), + VarKind::ChapterNumber => ("ChapterNumberType", Some(pref("ChapterNumberVariablePreference").attr("Format", "Current"))), + VarKind::FileName { extension } => { + ("FileNameType", Some(pref("FileNameVariablePreference").attr("IncludePath", "false").attr("IncludeExtension", bool_s(*extension)))) + } + VarKind::CreationDate { format } => ("CreationDateType", Some(pref("DateVariablePreference").attr("Format", format))), + VarKind::ModificationDate { format } => ("ModificationDateType", Some(pref("DateVariablePreference").attr("Format", format))), + VarKind::OutputDate { format } => ("OutputDateType", Some(pref("DateVariablePreference").attr("Format", format))), + VarKind::RunningHeader { style, use_, character } => { + let (ty, el, key, style_ref) = if *character { + ( + "MatchCharacterStyleType", + "MatchCharacterStylePreference", + "AppliedCharacterStyle", + names::style_self("CharacterStyle", CHAR_BUILTINS, style), + ) + } else { + ( + "MatchParagraphStyleType", + "MatchParagraphStylePreference", + "AppliedParagraphStyle", + names::style_self("ParagraphStyle", PARA_BUILTINS, style), + ) + }; + let strategy = if *use_ == Use::LastOnPage { "LastOnPage" } else { "FirstOnPage" }; + ( + ty, + Some(pref(el).attr(key, style_ref).attr("SearchStrategy", strategy).attr("ChangeCase", "None").attr("DeleteEndPunctuation", "false")), + ) + } + VarKind::Imported { variable_type, settings_element, settings, .. } => { + let el = (!settings_element.is_empty()).then(|| El { name: settings_element.clone(), attrs: settings.clone(), children: vec![] }); + (variable_type.as_str(), el) + } + }; + let mut e = El::new("TextVariable").attr("Self", format!("dTextVariablen{}", v.name)).attr("Name", &v.name).attr("VariableType", ty); + if let Some(mut s) = settings { + if !matches!(v.kind, VarKind::Imported { .. }) { + s.set("TextAfter", &v.after); + } + e.push(s); + } + e +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2b5f3fef7..e3c62c4a3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -75,12 +75,30 @@ pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option } } let mut im = Importer::new(read_link); + if let Some(m) = files.get("META-INF/metadata.xml") { + let m = String::from_utf8_lossy(m); + im.created = xmp_date(&m, "xmp:CreateDate"); + im.modified = xmp_date(&m, "xmp:ModifyDate"); + } im.run(&root, &top)?; let d = im.finish(&root)?; d.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; Ok(d) } +/// An XMP date property, written as an element or an attribute. +fn xmp_date(xmp: &str, key: &str) -> Option { + let value = if let Some(i) = xmp.find(&format!("<{key}>")) { + let rest = xmp.get(i + key.len() + 2..)?; + rest.get(..rest.find('<')?)? + } else { + let i = xmp.find(&format!("{key}=\""))?; + let rest = xmp.get(i + key.len() + 2..)?; + rest.get(..rest.find('"')?)? + }; + designcraft_doc::vars::parse_iso_date(value) +} + struct ItemCtx { /// Self → (story self, prev self, next self) threads: Vec<(ItemId, String, String, String)>, @@ -134,6 +152,12 @@ struct Importer<'r> { /// Index topic Self → topic path; topic See / See also cross-references. index_topics: HashMap>, index_xrefs: Vec, + /// The file's text variables (`None`: it defines none) and their Self → index. + text_vars: Option>, + text_var_ids: HashMap, + /// Creation and modification times (Unix seconds) from the package metadata. + created: Option, + modified: Option, } fn lab_to_color(l: f32, a: f32, b: f32) -> Color { @@ -213,6 +237,10 @@ impl<'r> Importer<'r> { xref_formats: designcraft_doc::xref::default_formats(), index_topics: HashMap::new(), index_xrefs: Vec::new(), + text_vars: None, + text_var_ids: HashMap::new(), + created: None, + modified: None, } } @@ -377,6 +405,9 @@ impl<'r> Importer<'r> { if let Some(ix) = top.iter().find(|e| e.local() == "Index") { self.index_topics_of(ix, &[]); } + for e in top.iter().filter(|e| e.local() == "TextVariable") { + self.text_variable(e); + } // Stories (ids first so frames can reference them). for e in top.iter().filter(|e| e.local() == "Story") { let id = StoryId(self.alloc()); @@ -971,6 +1002,88 @@ impl<'r> Importer<'r> { } /// `CrossReferenceFormat`: building blocks back to a format definition. + /// A `TextVariable` definition. Types that aren't computed here keep their settings and show + /// the result of their first instance. + fn text_variable(&mut self, e: &El) { + use designcraft_doc::vars::{TextVariable, Use, VAR_MAX, VarKind}; + let ty = e.get("VariableType").unwrap_or(""); + // Cross-reference internals; export writes its own. + if ty.is_empty() || ty.starts_with("Xref") { + return; + } + let vars = self.text_vars.get_or_insert_with(Vec::new); + if vars.len() >= VAR_MAX { + return; + } + let pref = e.elements().find(|c| c.local().ends_with("Preference")); + let attr = |k: &str| pref.and_then(|p| p.get(k)).unwrap_or(""); + let date = || Some(attr("Format")).filter(|f| !f.is_empty()).unwrap_or("MM/dd/yy").to_string(); + let kind = match ty { + "CustomTextType" => Some(VarKind::Custom { text: attr("Contents").into() }), + "LastPageNumberType" => Some(VarKind::LastPageNumber { section: attr("Scope") == "SectionScope" }), + "ChapterNumberType" => Some(VarKind::ChapterNumber), + "FileNameType" if attr("IncludePath") != "true" => Some(VarKind::FileName { extension: attr("IncludeExtension") == "true" }), + "CreationDateType" => Some(VarKind::CreationDate { format: date() }), + "ModificationDateType" => Some(VarKind::ModificationDate { format: date() }), + "OutputDateType" => Some(VarKind::OutputDate { format: date() }), + "MatchParagraphStyleType" | "MatchCharacterStyleType" => { + let character = ty == "MatchCharacterStyleType"; + let r = attr(if character { "AppliedCharacterStyle" } else { "AppliedParagraphStyle" }); + let use_ = if attr("SearchStrategy") == "LastOnPage" { Use::LastOnPage } else { Use::FirstOnPage }; + // Change Case and Delete End Punctuation aren't modelled: the header shows the text as set. + (!r.is_empty()).then(|| VarKind::RunningHeader { + style: if character { self.char_style_ref(r) } else { self.para_style_ref(r) }, + use_, + character, + }) + } + _ => None, + }; + let name = e.get("Name").unwrap_or("").to_string(); + let v = match kind { + Some(kind) => { + let mut v = TextVariable::new(&name, kind); + v.before = attr("TextBefore").into(); + v.after = attr("TextAfter").into(); + v + } + None => TextVariable::new( + &name, + VarKind::Imported { + variable_type: ty.into(), + result: String::new(), + settings_element: pref.map(|p| p.name.clone()).unwrap_or_default(), + settings: pref.map(|p| p.attrs.clone()).unwrap_or_default(), + }, + ), + }; + let vars = self.text_vars.get_or_insert_with(Vec::new); + if let Some(s) = e.get("Self") { + self.text_var_ids.insert(s.to_string(), vars.len()); + } + vars.push(v); + } + + /// A `TextVariableInstance`: the variable's character, or its recorded text when the file + /// doesn't define the variable. + fn text_variable_instance(&mut self, e: &El) -> String { + use designcraft_doc::vars::{VarKind, var_char}; + let result = e.get("ResultText").unwrap_or(""); + let found = e.get("AssociatedTextVariable").and_then(|r| self.text_var_ids.get(r)).copied(); + let var = found.and_then(|i| Some((i, self.text_vars.as_mut()?.get_mut(i)?))); + match var.and_then(|(i, v)| Some((var_char(i)?, v))) { + Some((c, v)) => { + if let VarKind::Imported { result: r, .. } = &mut v.kind + && r.is_empty() + { + *r = result.to_string(); + } + c.to_string() + } + None => result.to_string(), + } + } + fn xref_format(&mut self, e: &El) { let name = e.get("Name").unwrap_or("Format").to_string(); let mut def = String::new(); @@ -1731,7 +1844,11 @@ impl<'r> Importer<'r> { b.push(&designcraft_doc::OBJECT_MARK.to_string(), cf); } } - "Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {} + "TextVariableInstance" => { + let t = self.text_variable_instance(c); + b.push(&t, cf); + } + "Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" => {} _ => self.walk_story(c, b, pf, pchars, cf, brk), }, Node::Pi(t, v) if t == "ACE" => { @@ -2253,12 +2370,12 @@ impl<'r> Importer<'r> { user_words: vec![], hyphenation_exceptions: vec![], toc: None, - text_variables: designcraft_doc::vars::defaults(), + text_variables: self.text_vars.take().unwrap_or_else(designcraft_doc::vars::defaults), footnote_options: std::mem::take(&mut self.footnote_options), xref_formats: std::mem::take(&mut self.xref_formats), index: None, - created: designcraft_doc::vars::now(), - modified: 0, + created: self.created.unwrap_or_else(designcraft_doc::vars::now), + modified: self.modified.unwrap_or(0), next_id: self.next_id, font_scope: 0, }; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 86f287314..7ec48bcfc 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -8,6 +8,7 @@ use designcraft_geom::{Rect, shapes}; use super::*; mod decorations; +mod variables; fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; diff --git a/crates/idml/src/tests/variables.rs b/crates/idml/src/tests/variables.rs new file mode 100644 index 000000000..73e51be8f --- /dev/null +++ b/crates/idml/src/tests/variables.rs @@ -0,0 +1,129 @@ +use designcraft_doc::vars::{Use, VarKind, format_date, now, var_char}; + +use super::*; + +const VARIABLES: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + + output + + + + + / + + / + + / + + / + + + + +"#; + +const XMP: &str = r#" + + 2019-12-16T09:48:02+01:00 + 2019-12-16T10:23:18+01:00 + "#; + +/// Glyph ids of the story's first frame, and those of its spaces. +fn glyphs(d: &Document) -> (Vec, Vec) { + let sid = *d.stories.keys().next().unwrap(); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + let text = &d.stories[&sid].text; + let all: Vec<_> = cs.frames[0].lines.iter().flat_map(|l| l.glyphs.iter()).collect(); + (all.iter().map(|g| g.gid).collect(), all.iter().filter(|g| text[g.byte..].starts_with(' ')).map(|g| g.gid).collect()) +} + +/// The story's glyphs and those of `text` set as plain text in its place, without spaces (a +/// variable doesn't break across lines, so line-end spaces fall differently). +fn shown(d: &Document, text: &str) -> (Vec, Vec) { + let mut plain = d.clone(); + let sid = *plain.stories.keys().next().unwrap(); + let st = plain.story_mut(sid).unwrap(); + let n = st.text.len(); + st.replace(0..n, text); + let (want, spaces) = glyphs(&plain); + let got = glyphs(d).0; + let drop = |v: Vec| v.into_iter().filter(|g| !spaces.contains(g)).collect(); + (drop(got), drop(want)) +} + +#[test] +fn text_variables_show_their_values_and_round_trip() { + let map = DESIGNMAP.replacen("\n = d.text_variables.iter().map(|v| v.name.as_str()).collect(); + assert_eq!(names, ["Creation Date", "Image Name", "Output Date", "Edition", "Last Page Number", "Running Header"]); + assert_eq!(d.text_variables[4].kind, VarKind::LastPageNumber { section: true }); + assert_eq!(d.text_variables[4].before, "of "); + assert_eq!(d.text_variables[5].kind, VarKind::RunningHeader { style: "Text/Body".into(), use_: Use::LastOnPage, character: false }); + let v = |n: &str| var_char(d.variable(n).unwrap()).unwrap(); + let story = d.stories.values().next().unwrap(); + assert_eq!(story.text, format!("output {} / {} / {} / {} / kept", v("Output Date"), v("Edition"), v("Creation Date"), v("Image Name"))); + assert!(matches!(&d.text_variables[1].kind, VarKind::Imported { result, .. } if result == "photo.jpg")); + + // Output date is today; creation date comes from the package metadata; the caption shows + // its recorded result; a custom variable shows its contents. + let today = format_date(now(), "MM/dd/yy"); + let expected = format!("output {today} / First edition / made 12/16/19 / photo.jpg / kept"); + let (got, want) = shown(&d, &expected); + if today == format_date(now(), "MM/dd/yy") { + assert_eq!(got, want); + } + + let back = import_idml(&export_idml(&d)).unwrap(); + back.check().unwrap(); + assert_eq!(back.text_variables, d.text_variables); + assert_eq!(back.stories.values().next().unwrap().text, story.text); + assert_eq!((back.created, back.modified), (d.created, d.modified)); + assert_eq!(glyphs(&back).0, glyphs(&d).0); +} + +#[test] +fn predefined_variables_round_trip() { + let mut d = Document::new(&NewDocument::default()); + d.text_variables.push(designcraft_doc::vars::TextVariable::new( + "Chapter Head", + VarKind::RunningHeader { style: "Strong".into(), use_: Use::FirstOnPage, character: true }, + )); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.text_variables, d.text_variables); +}