M5 #19: ODT user fields stay user fields

User fields (text:user-field-decl, text:user-field-get: LibreOffice's
report templates, 58 documents of the corpus) were read as custom
document properties and written back as meta:user-defined properties
with text:user-defined fields: the look and values stayed, the kind
didn't. Custom properties now remember being user fields
(CustomProp::odf_user_field) and are written back as
text:user-field-decls with their values and text:user-field-get fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-03 03:54:32 +00:00
1 parent 6601aeaf09
commit 7160f05014
7 files changed
+89 -16

No files matched your search

+4
View File
@@ -46,6 +46,10 @@ pub struct CustomProp {
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
/// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than
/// a document property: written back as one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub odf_user_field: bool,
}
/// Document properties (File › Info).
+14 -2
View File
@@ -25,11 +25,23 @@ pub fn read(root: &El) -> Vec<CustomProp> {
let Some(v) = p.els().next() else { continue };
let kind = v.local();
let prop = if SIMPLE.contains(&kind) {
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
CustomProp {
name: name.to_string(),
kind: kind.to_string(),
value: v.text(),
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
} else {
let mut xml = String::new();
el_xml(v, &mut xml, 0);
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
CustomProp {
name: name.to_string(),
kind: "raw".into(),
value: xml,
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
};
out.push(prop);
}
+2 -2
View File
@@ -245,7 +245,7 @@ pub(super) fn custom_prop(name: &str, t: Option<&str>, v: &str) -> Option<wordcr
}
};
}
Some(wordcraft_doc::CustomProp { name, kind: kind.into(), value, link_target: None })
Some(wordcraft_doc::CustomProp { name, kind: kind.into(), value, link_target: None, odf_user_field: false })
}
/// The custom properties of a package's `meta.xml` (§4.3.2.14 `meta:user-defined`).
@@ -297,7 +297,7 @@ pub(super) fn user_fields(xml: &[u8]) -> Vec<wordcraft_doc::CustomProp> {
if let Some(p) = get(&a, "text:name").and_then(|n| custom_prop(n, t, v))
&& !out.iter().any(|o| o.name == p.name)
{
out.push(p);
out.push(wordcraft_doc::CustomProp { odf_user_field: true, ..p });
}
}
});
+38 -9
View File
@@ -648,20 +648,47 @@ fn meta_xml(m: &Meta, core: &wordcraft_doc::CoreProps, props: &[wordcraft_doc::C
s.push_str(&format!("<meta:creation-date>{}</meta:creation-date>", x(m.created.trim_end_matches('Z'))));
}
// Custom document properties (§4.3.2.14); ones kept as raw XML from a Word file aren't.
for p in props.iter().filter(|p| p.kind != "raw") {
let t = match p.kind.as_str() {
"r4" | "r8" | "i1" | "i2" | "i4" | "i8" | "int" | "ui1" | "ui2" | "ui4" | "ui8" | "uint" | "decimal" | "cy" => "float",
"bool" => "boolean",
"filetime" | "date" => "date",
_ => "string",
};
let value = if t == "boolean" { if matches!(p.value.trim(), "true" | "1") { "true" } else { "false" }.to_string() } else { p.value.clone() };
// (User fields go in the content instead: `user_field_decls`.)
for p in props.iter().filter(|p| p.kind != "raw" && !p.odf_user_field) {
let (t, value) = odf_value(p);
s.push_str(&format!("<meta:user-defined meta:name=\"{}\" meta:value-type=\"{t}\">{}</meta:user-defined>", x(&p.name), x(&value)));
}
s.push_str("</office:meta></office:document-meta>\n");
s
}
/// A custom property's ODF value type and value.
fn odf_value(p: &wordcraft_doc::CustomProp) -> (&'static str, String) {
let t = match p.kind.as_str() {
"r4" | "r8" | "i1" | "i2" | "i4" | "i8" | "int" | "ui1" | "ui2" | "ui4" | "ui8" | "uint" | "decimal" | "cy" => "float",
"bool" => "boolean",
"filetime" | "date" => "date",
_ => "string",
};
let value = if t == "boolean" { if matches!(p.value.trim(), "true" | "1") { "true" } else { "false" }.to_string() } else { p.value.clone() };
(t, value)
}
/// The document's user fields (§7.4.7 `text:user-field-decls`), declared at the start of the text.
fn user_field_decls(props: &[wordcraft_doc::CustomProp]) -> String {
let decls: String = props
.iter()
.filter(|p| p.odf_user_field && p.kind != "raw")
.map(|p| {
let (t, value) = odf_value(p);
let attr = match t {
"float" => "office:value",
"boolean" => "office:boolean-value",
"date" => "office:date-value",
_ => "office:string-value",
};
let value = if t == "date" { value.trim_end_matches(['Z', 'z']).to_string() } else { value };
format!("<text:user-field-decl text:name=\"{}\" office:value-type=\"{t}\" {attr}=\"{}\"/>", x(&p.name), x(&value))
})
.collect();
if decls.is_empty() { String::new() } else { format!("<text:user-field-decls>{decls}</text:user-field-decls>") }
}
/// The sections of a flow: each one's setup and the top-level blocks it holds. A paragraph that
/// carries a section's properties ends it; the document's last section follows the last such
/// paragraph (and is left out when nothing follows it, unless it is the only one).
@@ -738,6 +765,7 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
w.conn_kinds = conn_kinds(&flow.blocks);
w.sources = doc.sources.iter().map(|s| (s.tag.clone(), s.clone())).collect();
w.custom_props = doc.custom_props.iter().filter(|p| p.kind != "raw").map(|p| p.name.clone()).collect();
w.user_fields = doc.custom_props.iter().filter(|p| p.odf_user_field && p.kind != "raw").map(|p| p.name.clone()).collect();
w.variables = variables(doc);
w.forms_allowed = true;
w.ranged = model::ranged_anchors(&flow.blocks);
@@ -813,8 +841,9 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
w.sequences.iter().map(|n| format!("<text:sequence-decl text:display-outline-level=\"0\" text:name=\"{}\"/>", x(n))).collect();
format!("<text:sequence-decls>{d}</text:sequence-decls>")
};
let user_decls = user_field_decls(&doc.custom_props);
let content = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{}<office:body><office:text>\n{}{tracked}{var_decls}{decls}{body}</office:text></office:body></office:document-content>\n",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{}<office:body><office:text>\n{}{tracked}{var_decls}{user_decls}{decls}{body}</office:text></office:body></office:document-content>\n",
w.automatic_styles(),
super::forms::forms_xml(&w.forms, &hidden_controls(doc))
);
+24 -3
View File
@@ -185,13 +185,14 @@ fn custom_properties_and_user_fields_are_docproperty_fields() {
.filter_map(|o| if let wordcraft_doc::para::InlineObject::Field { instr, .. } = o { Some(instr.clone()) } else { None })
.collect();
assert_eq!(instrs, [r#"DOCPROPERTY "Project""#, r#"DOCPROPERTY "Owner""#]);
// Saved: the properties in `meta.xml`, the fields as `text:user-defined`.
// Saved: the properties in `meta.xml` with `text:user-defined` fields, the user field as one.
let saved = crate::odt::export(&d).unwrap();
let m = crate::odt::tests::entry(&saved, "meta.xml");
assert!(m.contains(r#"<meta:user-defined meta:name="Budget" meta:value-type="float">1200.5</meta:user-defined>"#), "{m}");
assert!(m.contains(r#"meta:name="Owner""#), "{m}");
assert!(!m.contains(r#"meta:name="Owner""#), "{m}");
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"<text:user-defined text:name="Owner">Ada</text:user-defined>"#), "{c}");
assert!(c.contains(r#"<text:user-defined text:name="Project">Kiln</text:user-defined>"#), "{c}");
assert!(c.contains(r#"<text:user-field-get text:name="Owner">Ada</text:user-field-get>"#), "{c}");
assert_eq!(crate::odt::import(&saved).unwrap().custom_props.len(), 3);
}
@@ -299,3 +300,23 @@ fn a_date_in_the_locales_order_shows_so() {
let codes: Vec<String> = fields(&d).into_iter().map(|(c, _)| c).collect();
assert_eq!(codes, [r#"DATE \@ "MMMM d, yyyy""#, r#"DATE \@ "M/d/yyyy""#]);
}
#[test]
fn user_fields_stay_user_fields() {
// LibreOffice's report templates: user fields (§7.4.7 `text:user-field-decl`, §7.4.9
// `text:user-field-get`) are written back as such, not as document properties.
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:user-field-decls><text:user-field-decl office:value-type="string" office:string-value="Group one" text:name="GroupField1"/><text:user-field-decl office:value-type="float" office:value="3" text:name="Count"/></text:user-field-decls><text:p>Group: <text:user-field-get text:name="GroupField1">Group one</text:user-field-get>, <text:user-field-get text:name="Count">3</text:user-field-get></text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, None)).unwrap();
assert!(d.custom_props.iter().all(|p| p.odf_user_field), "{:?}", d.custom_props);
let saved = export(&d).unwrap();
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"<text:user-field-decl text:name="GroupField1" office:value-type="string" office:string-value="Group one"/>"#), "{c}");
assert!(c.contains(r#"office:value-type="float" office:value="3""#), "{c}");
assert!(c.contains(r#"<text:user-field-get text:name="GroupField1">Group one</text:user-field-get>"#), "{c}");
let meta = crate::odt::tests::entry(&saved, "meta.xml");
assert!(!meta.contains("GroupField1"), "not a document property too: {meta}");
let back = import(&saved).unwrap();
assert_eq!(back.custom_props, d.custom_props);
}
+6
View File
@@ -186,6 +186,8 @@ pub(super) struct Writer {
/// The document's custom properties' names: `DOCPROPERTY` fields naming one are written as
/// `text:user-defined`.
pub(super) custom_props: std::collections::HashSet<String>,
/// The custom properties declared as user fields (`text:user-field-get`).
pub(super) user_fields: std::collections::HashSet<String>,
/// The names SET fields give values (the document's variables, §7.4.2): REF to one is a
/// `text:variable-get`.
pub(super) variables: std::collections::BTreeSet<String>,
@@ -732,6 +734,10 @@ impl Writer {
if let Some(name) = instr.trim_start().strip_prefix("REF").and_then(|r| r.split_whitespace().next()).filter(|n| self.variables.contains(*n)) {
return Some(format!("<text:variable-get text:name=\"{}\">{}</text:variable-get>", x(name), x(result)));
}
// A user field's value (§7.4.9), or a document property's (§7.5.6).
if let Some(n) = super::fields::docproperty_name(instr).filter(|n| self.user_fields.contains(*n)) {
return Some(format!("<text:user-field-get text:name=\"{}\">{}</text:user-field-get>", x(n), x(result)));
}
if let Some(n) = super::fields::docproperty_name(instr).filter(|n| self.custom_props.contains(*n)) {
return Some(format!("<text:user-defined text:name=\"{}\">{}</text:user-defined>", x(n), x(result)));
}
+1
View File
@@ -285,6 +285,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
| 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) |
| 2026-11-03 | trivial | ODT user fields (§7.4.7 `text:user-field-decl`, §7.4.9 `text:user-field-get`; LibreOffice's report templates) are written back as user fields, with their declared values in the content, instead of as document properties (`CustomProp::odf_user_field`). |
| 2026-11-03 | trivial | ODT, found by a loss check that compares what a file uses with what WordCraft writes back: each font is now declared (`office:font-face-decls`, §16.21: family, generic family, pitch) and named where used (`style:font-name`), so a reader without it picks a like one; who changed the document last, when, and how often it was saved (`dc:creator`, `dc:date`, `meta:editing-cycles`) are read into and written from the document's properties. |
| 2026-11-03 | trivial | ODT, found with Google Docs' and ONLYOFFICE's files (now in the corpus): a heading's look comes from its style and its outline level from its element, so a `text:h` without `text:outline-level` (level 1, §19.850.4) in a style based on Heading 2 looks as Heading 2, as LibreOffice draws it, and stays level 1 (it became Heading 1). Widow and orphan control is stated in the default paragraph style (`fo:widows`/`fo:orphans` 2) when Normal has it, since LibreOffice reads none unsaid as off. Google's 2011 Fundamentals export now round-trips with its 88 pages and text (1.27M → 139k differing pixels). |
| 2026-11-03 | trivial | ODT `settings.xml`: LibreOffice ignored every setting WordCraft wrote, because the set's name (`ooo:configuration-settings`) is a qualified name and the `ooo` namespace wasn't declared (observed); and a set it does read must be complete, since LibreOffice gives any setting it lacks its old pre-OpenOffice value (observed). WordCraft now writes LibreOffice's current layout defaults (observed in a new LibreOffice document), Word's layout rules for documents WordCraft lays out as Word does, and its own tab and paragraph spacing choices. How the space between paragraphs adds up follows a file's `AddParaTableSpacing` (false: the larger of the two, as Word), else the ODF interpretation profile (`sumParagraphSpacing`: WordCraft the larger, LibreOffice the sum). WordCraft's own documents opened in LibreOffice now differ by 80k pixels over 42 documents, from 142k. |