mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
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.
This commit is contained in:
@@ -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<i64> {
|
||||
let s = s.trim();
|
||||
let num = |a: usize, b: usize| -> Option<i64> {
|
||||
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<i64> {
|
||||
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));
|
||||
|
||||
@@ -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!("<?AID 001b?>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!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n <rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n <xmp:CreateDate>{}</xmp:CreateDate>\n <xmp:ModifyDate>{}</xmp:ModifyDate>\n </rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\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
|
||||
}
|
||||
|
||||
+121
-4
@@ -75,12 +75,30 @@ pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>
|
||||
}
|
||||
}
|
||||
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<i64> {
|
||||
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<String, Vec<String>>,
|
||||
index_xrefs: Vec<designcraft_doc::IndexRef>,
|
||||
/// The file's text variables (`None`: it defines none) and their Self → index.
|
||||
text_vars: Option<Vec<designcraft_doc::vars::TextVariable>>,
|
||||
text_var_ids: HashMap<String, usize>,
|
||||
/// Creation and modification times (Unix seconds) from the package metadata.
|
||||
created: Option<i64>,
|
||||
modified: Option<i64>,
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ use designcraft_geom::{Rect, shapes};
|
||||
use super::*;
|
||||
|
||||
mod decorations;
|
||||
mod variables;
|
||||
|
||||
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use designcraft_doc::vars::{Use, VarKind, format_date, now, var_char};
|
||||
|
||||
use super::*;
|
||||
|
||||
const VARIABLES: &str = r#"
|
||||
<TextVariable Self="dTextVariablen<?AID 001b?>TV XRefPageNumber" Name="<?AID 001b?>TV XRefPageNumber" VariableType="XrefPageNumberType" />
|
||||
<TextVariable Self="dTextVariablenCreation Date" Name="Creation Date" VariableType="CreationDateType">
|
||||
<DateVariablePreference TextBefore="made " Format="MM/dd/yy" TextAfter="" />
|
||||
</TextVariable>
|
||||
<TextVariable Self="dTextVariablenImage Name" Name="Image Name" VariableType="LiveCaptionType">
|
||||
<CaptionMetadataVariablePreference TextBefore="" MetadataProviderName="$ID/#LinkInfoNameStr" TextAfter="" />
|
||||
</TextVariable>
|
||||
<TextVariable Self="dTextVariablenOutput Date" Name="Output Date" VariableType="OutputDateType">
|
||||
<DateVariablePreference TextBefore="" Format="MM/dd/yy" TextAfter="" />
|
||||
</TextVariable>
|
||||
<TextVariable Self="dTextVariablenEdition" Name="Edition" VariableType="CustomTextType">
|
||||
<CustomTextVariablePreference Contents="First edition" />
|
||||
</TextVariable>
|
||||
<TextVariable Self="dTextVariablenLast Page Number" Name="Last Page Number" VariableType="LastPageNumberType">
|
||||
<PageNumberVariablePreference TextBefore="of " Format="Current" TextAfter="" Scope="SectionScope" />
|
||||
</TextVariable>
|
||||
<TextVariable Self="dTextVariablenRunning Header" Name="Running Header" VariableType="MatchParagraphStyleType">
|
||||
<MatchParagraphStylePreference TextBefore="" TextAfter="" AppliedParagraphStyle="ParagraphStyle/Text%3aBody" SearchStrategy="LastOnPage" ChangeCase="None" DeleteEndPunctuation="false" />
|
||||
</TextVariable>
|
||||
<idPkg:Story"#;
|
||||
|
||||
const STORY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>output </Content></CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" PageNumberType="TextVariable">
|
||||
<TextVariableInstance Self="u1" Name="Output Date" ResultText="12/16/19" AssociatedTextVariable="dTextVariablenOutput Date" />
|
||||
</CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
|
||||
<Content> / </Content>
|
||||
<TextVariableInstance Self="u2" Name="Edition" ResultText="Old edition" AssociatedTextVariable="dTextVariablenEdition" />
|
||||
<Content> / </Content>
|
||||
<TextVariableInstance Self="u3" Name="Creation Date" ResultText="made 01/01/01" AssociatedTextVariable="dTextVariablenCreation Date" />
|
||||
<Content> / </Content>
|
||||
<TextVariableInstance Self="u4" Name="Image Name" ResultText="photo.jpg" AssociatedTextVariable="dTextVariablenImage Name" />
|
||||
<Content> / </Content>
|
||||
<TextVariableInstance Self="u5" Name="Gone" ResultText="kept" AssociatedTextVariable="dTextVariablenGone" />
|
||||
</CharacterStyleRange>
|
||||
</ParagraphStyleRange>
|
||||
</Story>
|
||||
</idPkg:Story>"#;
|
||||
|
||||
const XMP: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
|
||||
<xmp:CreateDate>2019-12-16T09:48:02+01:00</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2019-12-16T10:23:18+01:00</xmp:ModifyDate>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
/// Glyph ids of the story's first frame, and those of its spaces.
|
||||
fn glyphs(d: &Document) -> (Vec<u32>, Vec<u32>) {
|
||||
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<u32>, Vec<u32>) {
|
||||
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<u32>| 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 <idPkg:Story", VARIABLES, 1);
|
||||
let bytes = zip_files(&[
|
||||
("designmap.xml", &map),
|
||||
("META-INF/metadata.xml", XMP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", STYLES),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", SPREAD),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
]);
|
||||
let d = import_idml_with(&bytes, &|_| None).unwrap();
|
||||
// The file's variables replace the defaults; cross-reference internals are left out.
|
||||
let names: Vec<&str> = 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);
|
||||
}
|
||||
Reference in New Issue
Block a user