Gap 19: ODT custom document properties and user fields as DOCPROPERTY

ODT custom document properties (`meta:user-defined` in `meta.xml`) were
dropped, and the fields showing them or a user field were read as
plain text.
- Properties: `meta:user-defined` values (string, number, true/false,
  date) are now WordCraft's custom document properties, and so are
  user field declarations (`text:user-field-decl`).
- Fields: `text:user-defined` and `text:user-field-get` are Word's
  `DOCPROPERTY` fields, which WordCraft updates from those properties.
- Writing: custom properties go back into `meta.xml`, and a
  `DOCPROPERTY` naming one is written as `text:user-defined`.

A LibreOffice document with a custom property and a user field
round-trips pixel-identical, valid against the schema. Its user field
comes back as a document property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude
2026-11-01 20:30:48 +00:00
parent 1e9c11fb1c
commit 0b6b88f414
8 changed files with 193 additions and 6 deletions
+15
View File
@@ -82,3 +82,18 @@ fn a_flat_document_opens_and_saves_by_its_extension() {
assert!(saved.starts_with(b"<?xml"));
assert_eq!(text(&Session::new(io::open_bytes("plain.fodt", &saved).unwrap())), ["Flat words."]);
}
#[test]
fn a_user_field_updates_from_its_property() {
// An ODT user field opens as `DOCPROPERTY`: updating fields shows the property's value.
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:user-field-decls><text:user-field-decl text:name="Owner" office:value-type="string" office:string-value="Ada"/></text:user-field-decls><text:p>By <text:user-field-get text:name="Owner">stale</text:user-field-get>.</text:p></office:text></office:body></office:document-content>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
let mut s = Session::new(io::open_bytes("user.odt", &z.finish().unwrap().into_inner()).unwrap());
s.run("references.updateFields", &json!({"all": true})).unwrap();
assert_eq!(results(&s), ["Ada"]);
}
+101
View File
@@ -46,6 +46,13 @@ pub(super) fn instr_of(local: &str, a: &Attrs, picture: impl Fn(&str) -> Option<
.filter(|n| !n.is_empty() && n.len() <= MAX_REF_NAME && !n.contains(|c: char| c.is_whitespace() || c == '"' || c == '\\'))
};
match local {
// A document property's value (§7.5.6 `text:user-defined`, a `meta:user-defined`) or a
// user field's (§7.4.9 `text:user-field-get`, declared once for the document): Word's
// `DOCPROPERTY`, the property kept as a custom document property.
"user-defined" | "user-field-get" => {
let n = clean_prop_name(get(a, "text:name")?)?;
Some(format!("DOCPROPERTY \"{n}\""))
}
// An index entry at a point (§8.1.10): Word's `XE`, its keys the levels above it.
"alphabetical-index-mark" => xe_instr(get(a, "text:key1"), get(a, "text:key2"), get(a, "text:string-value")?),
// Numbering figures, tables and the like (§7.4.13): Word's `SEQ` and its number format.
@@ -177,6 +184,100 @@ pub(super) fn element(instr: &str, result: &str, data_style: Option<&str>) -> Op
}
}
/// A custom property's name as a field code can quote it: no quotes or backslashes, no control
/// characters, at most 255 characters. `None` when nothing is left.
pub(super) fn clean_prop_name(n: &str) -> Option<String> {
let n: String = n.chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(255).collect();
let n = n.trim();
(!n.is_empty()).then(|| n.to_string())
}
/// The property a `DOCPROPERTY` field code names, if it is one.
pub(super) fn docproperty_name(instr: &str) -> Option<&str> {
let rest = instr.trim_start().strip_prefix("DOCPROPERTY")?.trim_start();
match rest.strip_prefix('"') {
Some(q) => q.split('"').next(),
None => rest.split_whitespace().next(),
}
.map(str::trim)
.filter(|n| !n.is_empty() && !n.starts_with('\\'))
}
/// Most custom properties read from a document.
pub(super) const MAX_PROPS: usize = 1000;
/// Longest custom property value read, characters.
const MAX_PROP_VALUE: usize = 4096;
/// A custom property of ODF value type `t` (`office:value-type`, `meta:value-type`) and value
/// `v`: as a document's custom property (OOXML variant type, value).
pub(super) fn custom_prop(name: &str, t: Option<&str>, v: &str) -> Option<wordcraft_doc::CustomProp> {
let name = clean_prop_name(name)?;
let kind = match t.map(str::trim) {
Some("float" | "percentage" | "currency") => "r8",
Some("boolean") => "bool",
Some("date") => "filetime",
_ => "lpwstr",
};
let value: String = v.chars().filter(|c| !c.is_control() || *c == '\n').take(MAX_PROP_VALUE).collect();
Some(wordcraft_doc::CustomProp { name, kind: kind.into(), value, link_target: None })
}
/// The custom properties of a package's `meta.xml` (§4.3.2.14 `meta:user-defined`).
pub(super) fn meta_props(xml: &[u8]) -> Vec<wordcraft_doc::CustomProp> {
let mut out: Vec<wordcraft_doc::CustomProp> = Vec::new();
let mut open: Option<(String, Option<String>, String)> = None;
super::events(xml, |ev| match ev {
super::Ev::Start(q, a) if q == "meta:user-defined" => {
open = get(&a, "meta:name").map(|n| (n.to_string(), get(&a, "meta:value-type").map(String::from), String::new()));
}
super::Ev::Text(t) => {
if let Some((_, _, v)) = &mut open
&& v.len() < MAX_PROP_VALUE * 4
{
v.push_str(&t);
}
}
super::Ev::End(q) if q == "meta:user-defined" => {
if let Some((n, t, v)) = open.take()
&& out.len() < MAX_PROPS
&& let Some(p) = custom_prop(&n, t.as_deref(), &v)
&& !out.iter().any(|o| o.name == p.name)
{
out.push(p);
}
}
_ => {}
});
out
}
/// The user fields a package's `content.xml` declares (§7.4.7 `text:user-field-decl`), as custom
/// properties.
pub(super) fn user_fields(xml: &[u8]) -> Vec<wordcraft_doc::CustomProp> {
let mut out: Vec<wordcraft_doc::CustomProp> = Vec::new();
super::events(xml, |ev| {
if let super::Ev::Start(q, a) = ev
&& q == "text:user-field-decl"
&& out.len() < MAX_PROPS
{
let t = get(&a, "office:value-type");
let v = match t.map(str::trim) {
Some("float" | "percentage" | "currency") => get(&a, "office:value"),
Some("boolean") => get(&a, "office:boolean-value"),
Some("date") => get(&a, "office:date-value"),
_ => get(&a, "office:string-value"),
}
.unwrap_or("");
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
}
/// Most characters of an index entry's level.
const MAX_ENTRY_LEVEL: usize = 255;
+14 -1
View File
@@ -404,6 +404,8 @@ struct Parsed {
parts: page_parts::Parts,
/// Some paragraph hyphenates: the document hyphenates (see [`hyphenation`]).
hyphenate: bool,
/// Custom document properties (`meta:user-defined`) and user fields.
custom: Vec<wordcraft_doc::CustomProp>,
}
/// Hyphenation is a paragraph's in ODF (`fo:hyphenate`) and the document's in WordCraft (as in
@@ -444,7 +446,15 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
styles.read(&content, true);
let settings = read_entry(&mut z, "settings.xml");
styles.tabs_from_indent = settings.as_deref().and_then(|s| setting(s, "TabsRelativeToIndent")).unwrap_or(true);
let meta = read_entry(&mut z, "meta.xml").map(|m| read_meta(&m)).unwrap_or_default();
let meta_xml = read_entry(&mut z, "meta.xml");
let meta = meta_xml.as_deref().map(read_meta).unwrap_or_default();
// Custom document properties, then the user fields not among them.
let mut custom = meta_xml.as_deref().map(super::fields::meta_props).unwrap_or_default();
for f in super::fields::user_fields(&content) {
if custom.len() < super::fields::MAX_PROPS && !custom.iter().any(|c| c.name == f.name) {
custom.push(f);
}
}
let entries: Vec<String> = z.file_names().take(100_000).map(str::to_string).collect();
let manifest = read_entry(&mut z, "META-INF/manifest.xml").map(|m| super::objects::manifest(&m)).unwrap_or_default();
let mut pictures = Pictures::new(MAX_ENTRY);
@@ -505,6 +515,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
page_doc: pages.doc.clone(),
parts,
hyphenate,
custom,
})
}
@@ -531,8 +542,10 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
page_doc,
parts,
hyphenate,
custom,
} = parse_tracked(bytes)?;
let mut doc = model::to_doc(&flow);
doc.custom_props = custom;
doc.settings.auto_hyphenation |= hyphenate;
if font.is_some() {
doc.styles.default_chr.font = font;
+14 -2
View File
@@ -456,7 +456,7 @@ fn settings_xml(doc: &Document) -> String {
s
}
fn meta_xml(m: &Meta) -> String {
fn meta_xml(m: &Meta, props: &[wordcraft_doc::CustomProp]) -> String {
let mut s =
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-meta {NS}><office:meta><meta:generator>WordCraft</meta:generator>");
if !m.title.is_empty() {
@@ -477,6 +477,17 @@ fn meta_xml(m: &Meta) -> String {
if !m.created.is_empty() {
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() };
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
}
@@ -540,6 +551,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
let mut w = Writer::default();
w.accents.clone_from(&accents);
w.conn_kinds = conn_kinds(&flow.blocks);
w.custom_props = doc.custom_props.iter().filter(|p| p.kind != "raw").map(|p| p.name.clone()).collect();
w.forms_allowed = true;
w.ranged = model::ranged_anchors(&flow.blocks);
let mut body = String::new();
@@ -629,7 +641,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
w.pictures.append(&mut hw.pictures);
w.objects.append(&mut hw.objects);
w.object_dirs.append(&mut hw.object_dirs);
let meta = meta_xml(&flow.meta);
let meta = meta_xml(&flow.meta, &doc.custom_props);
let mut manifest = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.3\">\n<manifest:file-entry manifest:full-path=\"/\" manifest:version=\"1.3\" manifest:media-type=\"application/vnd.oasis.opendocument.text\"/>\n<manifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"styles.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"meta.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"settings.xml\" manifest:media-type=\"text/xml\"/>\n",
);
@@ -152,3 +152,41 @@ fn a_paragraph_ending_inside_a_field_still_ends() {
let texts: Vec<String> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "#")).collect();
assert_eq!(texts, ["a#", "b"]);
}
#[test]
fn custom_properties_and_user_fields_are_docproperty_fields() {
// A custom property (`meta:user-defined`) shown by `text:user-defined`, and a user field
// (`text:user-field-decl`) shown by `text:user-field-get`: Word's custom document properties
// and `DOCPROPERTY` fields.
let meta = r#"<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"><office:meta><meta:user-defined meta:name="Project" meta:value-type="string">Kiln</meta:user-defined><meta:user-defined meta:name="Budget" meta:value-type="float">1200.5</meta:user-defined></office:meta></office:document-meta>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:user-field-decls><text:user-field-decl text:name="Owner" office:value-type="string" office:string-value="Ada"/></text:user-field-decls><text:p>For <text:user-defined text:name="Project">Kiln</text:user-defined> by <text:user-field-get text:name="Owner">Ada</text:user-field-get>.</text:p></office:text></office:body></office:document-content>"#
);
let pkg = crate::odt::tests::zip_of(&[("content.xml", content.as_bytes()), ("meta.xml", meta.as_bytes())]);
let d = crate::odt::import(&pkg).unwrap();
let props: Vec<(String, String, String)> = d.custom_props.iter().map(|p| (p.name.clone(), p.kind.clone(), p.value.clone())).collect();
assert_eq!(
props,
[
("Project".into(), "lpwstr".into(), "Kiln".into()),
("Budget".into(), "r8".into(), "1200.5".into()),
("Owner".into(), "lpwstr".into(), "Ada".into())
]
);
let instrs: Vec<String> = d
.body
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.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`.
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}");
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"<text:user-defined text:name="Owner">Ada</text:user-defined>"#), "{c}");
assert_eq!(crate::odt::import(&saved).unwrap().custom_props.len(), 3);
}
+7
View File
@@ -159,6 +159,9 @@ pub(super) struct Writer {
/// kept objects' folders with their media types, for the manifest.
pub(super) object_names: usize,
pub(super) object_dirs: Vec<(String, String)>,
/// 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 kinds of the shapes with drawing ids, which connectors' glue points depend on.
pub(super) conn_kinds: HashMap<u32, wordcraft_doc::para::ShapeKind>,
/// Ruby was written: its automatic ruby style is.
@@ -645,6 +648,10 @@ impl Writer {
/// The ODF element for field `instr` showing `result`, with the date style and sequence
/// declaration it needs; `None` when ODF has none.
fn field_element(&mut self, instr: &str, result: &str) -> Option<String> {
// A custom document property's value (§7.5.6).
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)));
}
let ds = self.data_style(instr);
if let Some(seq) = super::fields::sequence_name(instr) {
self.sequences.insert(seq.to_string());
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~82%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: emphasis marks, rotated text, conditional and variable fields as fields, control sizes, drawing embedded spreadsheets' contents; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~82%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: emphasis marks, rotated text, conditional and variable fields as fields (user fields and custom properties done), control sizes, drawing embedded spreadsheets' contents; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |