From 2b8dc2bdeb3b5e1336ec9bb9ed4d3494b83ed978 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 07:55:33 +0000 Subject: [PATCH] IDML: text variables keep their text A TextVariableInstance becomes the document's variable character instead of being dropped. The file's TextVariable definitions replace the default set: custom text, dates, file name, last page number, chapter number and running headers are computed; other types (live captions) keep their settings and show the result recorded in the file. Creation and modification dates come from the package's XMP metadata. Export writes the definitions, each instance with its current result, and the XMP dates. --- crates/doc/src/vars.rs | 85 +++++++++++++++++++ crates/idml/src/export.rs | 85 +++++++++++++++++++ crates/idml/src/import.rs | 125 +++++++++++++++++++++++++++- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/variables.rs | 129 +++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 crates/idml/src/tests/variables.rs 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); +}