M-ODT gap 19: form definitions and indexes written back as read

The file's office:forms keeps its own head and each form:form with what
WordCraft doesn't model; controls are merged with the file's own. An
index whose generated entries nobody edited is written as read, with
its source settings and entry templates; an edited one is written from
the model. The no-edit gate saves nine samples of our own.

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-03 14:57:28 +00:00
parent eb8d195188
commit 67b3bbfdb0
11 changed files with 360 additions and 20 deletions
+41 -1
View File
@@ -37,6 +37,12 @@ pub struct KeptXml {
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
/// and the index's markup. Written as read while those paragraphs are all as read.
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
pub defs: BTreeMap<String, Arc<str>>,
@@ -122,6 +128,21 @@ impl KeptXml {
}
self.wraps.get(&fingerprint(p))
}
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
/// [`run_fingerprint`] when read, its markup.
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
if self.indexes.is_empty() {
return None;
}
self.indexes.get(&fingerprint(p))
}
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_indexes.insert(rev, (n, xml));
}
}
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
@@ -203,6 +224,15 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
h.finish()
}
/// What a run of paragraphs is in the model, together.
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in paras {
fingerprint(p).hash(&mut h);
}
h.finish()
}
/// What table `t` is in the model.
pub fn table_fingerprint(t: &crate::Table) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -248,8 +278,18 @@ impl crate::Document {
/// After reading: the markup kept for paragraphs still as the reader made them is filed
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
pub fn seal_kept(&mut self) {
// The body's tables kept, by their place among its tables.
// The body's tables kept, by their place among its tables; its indexes, by their run of
// top-level paragraphs.
if let Some(k) = self.kept.get_mut() {
let pending = std::mem::take(&mut k.pending_indexes);
for (i, b) in self.body.iter().enumerate() {
let Some(p) = b.as_para() else { continue };
let Some((n, xml)) = pending.get(&p.rev) else { continue };
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
if let Some(run) = run.filter(|r| r.len() == *n) {
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
}
}
let pending = std::mem::take(&mut k.pending_tables);
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
for (n, xml) in pending {
+37 -1
View File
@@ -463,6 +463,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
mark: None,
kept: None,
wrap: None,
index: None,
};
if opts.paras {
out.extra = para_extra(doc, &p.props);
@@ -503,6 +504,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
mark: None,
kept: None,
wrap: None,
index: None,
};
let prev = std::mem::replace(&mut out, next);
if prev.inlines.is_empty() {
@@ -839,9 +841,43 @@ fn plain_control(kind: &wordcraft_doc::control::ControlKind) -> bool {
matches!(kind, K::Text { .. } | K::CheckBox { .. } | K::DropDown { .. } | K::ComboBox { .. } | K::Date { .. })
}
/// The index kept as read starting at block `i` of `bl`, while the paragraphs it takes are all
/// as read: how many blocks, and its markup.
fn kept_index(doc: &Document, bl: &Blocks, i: usize) -> Option<(usize, std::sync::Arc<str>)> {
let k = doc.kept.of("odt")?;
let p = bl.get(i)?.as_para()?;
let (n, run, xml) = k.index(p)?;
let paras: Vec<&Paragraph> = bl.get(i..i + n)?.iter().filter_map(|b| b.as_para()).collect();
(paras.len() == *n && wordcraft_doc::kept::run_fingerprint(paras.into_iter()) == *run).then(|| (*n, xml.clone()))
}
fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: FlowOpts) -> Vec<FBlock> {
let mut out = Vec::new();
for b in bl {
let mut skip = 0usize;
for (i, b) in bl.iter().enumerate() {
if skip > 0 {
skip -= 1;
continue;
}
// An index kept as read, while every paragraph it takes still is what was read: its
// flow paragraphs, the first carrying its markup and how many there are.
if tracked.paras
&& depth == 0
&& let Some(ix) = kept_index(doc, bl, i)
{
let from = out.len();
for b in bl.get(i..i + ix.0).unwrap_or(&[]) {
if let Block::Para(p) = &**b {
out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para));
}
}
let n = out.len() - from;
if let Some(FBlock::Para(first)) = out.get_mut(from) {
first.index = Some(Box::new((ix.1, n)));
}
skip = ix.0.saturating_sub(1);
continue;
}
match &**b {
Block::Para(p) => out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para)),
Block::Table(t) if depth < MAX_DEPTH => out.push(FBlock::Table(flow_table(doc, t, depth, tracked))),
+23 -13
View File
@@ -470,6 +470,28 @@ impl<'a> Builder<'a> {
}
}
/// Keeps a paragraph's markup read from the file (its own, its frame wrap, the index it
/// starts) pending until the document is sealed.
fn keep_pending(&mut self, p: &Para, rev: u64) {
if p.kept.is_none() && p.wrap.is_none() && p.index.is_none() {
return;
}
if self.doc.kept.get().is_none() {
self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default());
}
if let Some(k) = self.doc.kept.get_mut() {
if let Some(xml) = &p.kept {
k.keep_pending(rev, xml.clone());
}
if let Some(w) = &p.wrap {
k.keep_pending_wrap(rev, (**w).clone());
}
if let Some(ix) = &p.index {
k.keep_pending_index(rev, ix.1, ix.0.clone());
}
}
}
fn para(&mut self, p: &Para, list_num: Option<u32>) -> Paragraph {
let mut out = Paragraph::new();
out.props = para_props(p, list_num);
@@ -625,19 +647,7 @@ impl<'a> Builder<'a> {
out.touch();
// Its markup kept for the writer, as long as nothing changes the paragraph from here on
// (see `wordcraft_doc::kept`: the reader seals it once the document is done).
if p.kept.is_some() || p.wrap.is_some() {
if self.doc.kept.get().is_none() {
self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default());
}
if let Some(k) = self.doc.kept.get_mut() {
if let Some(xml) = &p.kept {
k.keep_pending(out.rev, xml.clone());
}
if let Some(w) = &p.wrap {
k.keep_pending_wrap(out.rev, (**w).clone());
}
}
}
self.keep_pending(p, out.rev);
out
}
+3
View File
@@ -673,6 +673,9 @@ pub struct Para {
/// Sections kept as read around paragraphs ([`wordcraft_doc::kept`]): the start tags of
/// those opening before this paragraph (outermost first), and how many close after it.
pub wrap: Option<Box<(Vec<std::sync::Arc<str>>, u32)>>,
/// An index (a table of contents…) kept as read that starts with this paragraph
/// ([`wordcraft_doc::kept`]): its markup, and how many blocks of the flow it takes.
pub index: Option<Box<(std::sync::Arc<str>, usize)>>,
}
impl Para {
+44
View File
@@ -92,6 +92,8 @@ pub(super) struct Body<'a> {
kept_open: Option<KeptOpen>,
/// The body table whose markup is being kept (its `done`: the container's length before it).
kept_table: Option<KeptOpen>,
/// The body index (a table of contents…) whose markup is being kept, likewise.
kept_index: Option<KeptOpen>,
/// Body sections whose wrapper is kept as read: start tag, depth, the body's length before
/// it; `None` for one read as a columned section (kept as a section break).
kept_sections: Vec<Option<(std::sync::Arc<str>, usize, usize)>>,
@@ -220,6 +222,7 @@ impl<'a> Body<'a> {
raw: &[],
kept_open: None,
kept_table: None,
kept_index: None,
kept_sections: Vec::new(),
section_masters: vec![None],
pending_master: None,
@@ -300,6 +303,47 @@ impl<'a> Body<'a> {
/// whole and refers to nothing outside itself keeps its markup (`Para::kept`), so a save
/// that doesn't change it writes it as it was (see `wordcraft_doc::kept`).
pub(super) fn feed_at(&mut self, ev: Ev, at: (usize, usize)) {
// A body index, kept whole: its settings and templates, which WordCraft writes its own.
match &ev {
Ev::Start(q, _) => match &mut self.kept_index {
Some(k) => k.clean &= !(q.starts_with("draw:") || q.starts_with("office:annotation") || q.starts_with("text:change")),
None if q.starts_with("text:")
&& is_index(local(q))
&& !self.raw.is_empty()
&& self.in_text
&& self.skip == 0
&& self.containers.len() == 1
&& self.tables.is_empty()
&& self.frame.is_none()
&& self.note.is_none()
&& self.del.is_none() =>
{
self.end_para();
self.kept_index = Some(KeptOpen { from: at.0, depth: self.depth + 1, clean: true, done: self.container().len() });
}
None => {}
},
Ev::End(q) if q.starts_with("text:") && is_index(local(q)) && self.kept_index.as_ref().is_some_and(|k| k.depth == self.depth) => {
let k = self.kept_index.take();
self.feed(ev);
if let Some(k) = k
&& k.clean
&& self.containers.len() == 1
&& let Some(xml) = self.raw.get(k.from..at.1).and_then(|b| std::str::from_utf8(b).ok())
{
let blocks = self.container();
let n = blocks.len().saturating_sub(k.done);
if n > 0
&& blocks.get(k.done..).is_some_and(|r| r.iter().all(|b| matches!(b, FBlock::Para(_))))
&& let Some(FBlock::Para(p)) = blocks.get_mut(k.done)
{
p.index = Some(Box::new((std::sync::Arc::from(xml), n)));
}
}
return;
}
_ => {}
}
// A body section: its start tag kept, put around its paragraphs at its end.
match &ev {
Ev::Start(q, _) if q == "text:section" && self.kept_sections.len() < 64 => {
+154
View File
@@ -55,6 +55,7 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
(3, "office:font-face-decls") => child_key("font", &q, &a),
(3, "office:master-styles") => child_key("master", &q, &a),
(4, "office:text") if content && q.starts_with("text:") && q.ends_with("-decls") => Some(format!("decls:{q}")),
(4, "office:text") if content && q == "office:forms" => Some("forms".to_string()),
_ => None,
};
if let Some(key) = key {
@@ -345,6 +346,159 @@ pub(super) fn with_kept_meta(meta: String, defs: &BTreeMap<String, Arc<str>>, or
}
}
/// Whether a child of a `form:form` is a form control (else the form's own properties or
/// event listeners).
fn is_control(q: &str) -> bool {
q.starts_with("form:") && !matches!(q, "form:properties" | "form:form")
}
/// The children `depth` deep in `xml` (the root is 1): each one's element, `form:name` and
/// markup.
fn children_at(xml: &str, at_depth: usize) -> Vec<(String, Option<String>, &str)> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut open: Option<(String, Option<String>, usize)> = None;
events_at(xml.as_bytes(), |ev, at| match ev {
Ev::Start(q, a) => {
depth += 1;
if depth == at_depth && open.is_none() {
open = Some((q, get(&a, "form:name").map(str::to_string), at.0));
}
}
Ev::End(_) => {
if depth == at_depth
&& let Some((q, name, from)) = open.take()
&& let Some(x) = xml.get(from..at.1)
{
out.push((q, name, x));
}
depth = depth.saturating_sub(1);
}
Ev::Text(_) => {}
});
out
}
/// Control `ours` (as WordCraft writes it) with what the file's control `theirs` had that
/// WordCraft doesn't model and so can't have changed: its other attributes (not its ids) and its
/// properties and event listeners.
fn with_unmodelled(ours: &str, theirs: &str) -> String {
let attrs_of = |xml: &str| {
let mut out: Vec<(String, String)> = Vec::new();
let mut first = true;
super::xml::events(xml.as_bytes(), |ev| {
if let Ev::Start(_, a) = ev
&& std::mem::take(&mut first)
{
out = a;
}
});
out
};
let have: BTreeSet<String> = attrs_of(ours).into_iter().map(|(k, _)| k).collect();
let extra: String = attrs_of(theirs)
.into_iter()
.filter(|(k, _)| !have.contains(k) && !matches!(k.as_str(), "form:id" | "xml:id" | "form:name") && !k.starts_with("xmlns"))
.map(|(k, v)| format!(" {k}=\"{}\"", super::writer::x(&v)))
.collect();
let kids: String = children_at(theirs, 2)
.into_iter()
.filter(|(q, ..)| matches!(q.as_str(), "form:properties" | "office:event-listeners"))
.filter(|(q, ..)| !ours.contains(&format!("<{q}")))
.map(|(.., x)| x)
.collect();
// Into its start tag, and before its end (an empty one opened for them).
let Some(end) = ours.find('>') else { return ours.to_string() };
let (head, rest) = ours.split_at(end);
let empty = head.ends_with('/');
let head = head.trim_end_matches('/');
let name = head.trim_start_matches('<').split(char::is_whitespace).next().unwrap_or("");
if empty {
if kids.is_empty() { format!("{head}{extra}/>") } else { format!("{head}{extra}>{kids}</{name}>") }
} else {
let close = format!("</{name}>");
let body = rest.get(1..).unwrap_or("");
match body.rfind(&close) {
Some(at) => format!("{head}{extra}>{}{kids}{}", body.get(..at).unwrap_or(""), body.get(at..).unwrap_or("")),
None => format!("{head}{extra}>{body}"),
}
}
}
/// The document's forms (`office:forms`, §13.2) as the file had them (`orig`), with the
/// controls WordCraft writes (`ours`, its own `office:forms`): the file's forms element and each
/// form's own start tag, properties and event listeners as read; the controls the document has
/// in the first form (the file's controls are the document's, read into it and written back);
/// `ours` alone when the file had none, its own form when the file had none.
pub(super) fn merge_forms(orig: Option<&str>, ours: String) -> String {
let Some(orig) = orig else { return ours };
// `ours`: the controls (children of its one form).
let ours_controls = children_at(&ours, 3);
let orig_controls: Vec<(String, Option<String>, &str)> = children_at(orig, 3).into_iter().filter(|(q, ..)| is_control(q)).collect();
// Each of ours with what the file's control by that name (else in that place) had and
// WordCraft doesn't model.
let mut used = vec![false; orig_controls.len()];
let mut controls = String::new();
for (i, (q, name, xml)) in ours_controls.iter().enumerate() {
let same = |(k, (oq, oname, _)): &(usize, &(String, Option<String>, &str))| !used[*k] && oq == q && oname == name;
let found = orig_controls.iter().enumerate().find(|c| same(c)).map(|(k, _)| k).or_else(|| {
let nth = ours_controls.iter().take(i).filter(|(oq, ..)| oq == q).count();
orig_controls.iter().enumerate().filter(|(k, (oq, ..))| !used[*k] && oq == q).nth(nth).map(|(k, _)| k)
});
match found.and_then(|k| orig_controls.get(k).map(|c| (k, c.2))) {
Some((k, theirs)) => {
used[k] = true;
controls.push_str(&with_unmodelled(xml, theirs));
}
None => controls.push_str(xml),
}
}
// `orig`: its forms element, each form's start tag and non-control children.
let mut head: Option<String> = None;
let mut forms: Vec<(String, String)> = Vec::new();
let mut child: Option<(usize, bool)> = None;
let mut depth = 0usize;
events_at(orig.as_bytes(), |ev, at| match ev {
Ev::Start(q, _) => {
depth += 1;
let tag = orig.get(at.0..at.1).unwrap_or("");
let opened = || tag.strip_suffix("/>").map_or_else(|| tag.to_string(), |t| format!("{}>", t.trim_end()));
match depth {
1 => head = Some(opened()),
2 if q == "form:form" => forms.push((opened(), String::new())),
3 if child.is_none() => child = Some((at.0, is_control(&q))),
_ => {}
}
}
Ev::End(_) => {
if depth == 3
&& let Some((from, control)) = child.take()
&& !control
&& let (Some(f), Some(x)) = (forms.last_mut(), orig.get(from..at.1))
{
f.1.push_str(x);
}
depth = depth.saturating_sub(1);
}
Ev::Text(_) => {}
});
let Some(head) = head else { return ours };
let mut out = head;
if forms.is_empty() && !controls.is_empty() {
out.push_str(&format!("<form:form form:name=\"Form\">{controls}</form:form>"));
}
for (i, (start, kids)) in forms.iter().enumerate() {
out.push_str(start);
out.push_str(kids);
if i == 0 {
out.push_str(&controls);
}
out.push_str("</form:form>");
}
out.push_str("</office:forms>");
out
}
/// A list of declarations as the file had it (`orig`, a `text:variable-decls` or the like),
/// with those of WordCraft's (`ours`, the same list) it doesn't have by name; `ours` alone when
/// the file had none.
+2 -1
View File
@@ -997,7 +997,8 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
let content = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content {NS}>{scripts}{}<office:body><office:text>\n{}{tracked}{var_decls}{user_decls}{decls}{other_decls}{body}</office:text></office:body></office:document-content>\n",
w.automatic_styles(),
super::forms::forms_xml(&w.forms, &hidden_controls(doc))
// The file's forms as read, with the document's controls.
super::kept::merge_forms(kept.and_then(|k| k.defs.get("forms")).map(|d| &**d), super::forms::forms_xml(&w.forms, &hidden_controls(doc)))
);
let mut hw = Writer::with_prefix(if kept.is_some() { "wcM" } else { "M" });
hw.accents = accents;
+1 -1
View File
@@ -194,7 +194,7 @@ fn hidden_controls_are_kept() {
r#"<office:document-content {NS_T} {NS_F}><office:body><office:text><office:forms><form:form>{hidden}</form:form></office:forms><text:p>x</text:p></office:text></office:body></office:document-content>"#
);
let c = entry(&export(&import(&package(&only, None)).unwrap()).unwrap(), "content.xml");
assert!(c.contains("<office:forms ") && c.contains("<form:hidden "), "{c}");
assert!(c.contains("<office:forms") && c.contains("<form:hidden "), "{c}");
}
#[test]
+41 -2
View File
@@ -14,7 +14,7 @@ use crate::odt::{export, import};
/// What a save may lose, and why. Matched against the start of a loss (see [`inventory`]).
const ALLOWED: &[(&str, &str)] = &[];
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:wcx="urn:example:wordcraft-test" office:version="1.3""#;
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:wcx="urn:example:wordcraft-test" xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" office:version="1.3""#;
/// A sample package: `content.xml`, `styles.xml` and `meta.xml` bodies (inside their roots).
struct Sample {
@@ -201,8 +201,24 @@ const SECTIONS: Sample = Sample {
meta: "",
};
/// A form with its own properties and event listeners, and a field placed in the text.
const FORMS: Sample = Sample {
name: "forms",
content: r##"<office:body><office:text><office:forms form:automatic-focus="false" form:apply-design-mode="false"><form:form form:name="Standard" form:apply-filter="true" form:command-type="table" office:target-frame="" wcx:origin="sample"><form:properties><form:property form:property-name="GroupBy" office:value-type="string" office:string-value=""/></form:properties><office:event-listeners><wcx:listener wcx:on="submit"/></office:event-listeners><form:text form:name="Reader" form:id="control1" xml:id="control1" form:control-implementation="ooo:com.sun.star.form.component.TextField" form:current-value="Ann"/></form:form></office:forms><text:p>Name: <draw:control draw:control="control1" svg:width="1.5in" svg:height="0.25in" text:anchor-type="as-char"/></text:p></office:text></office:body>"##,
styles: "",
meta: "",
};
/// A table of contents and an alphabetical index with their settings and templates.
const INDEXES: Sample = Sample {
name: "indexes",
content: r##"<office:automatic-styles><style:style style:name="Sect9" style:family="section"><style:section-properties style:editable="false"><style:columns fo:column-count="1" fo:column-gap="0in"/></style:section-properties></style:style></office:automatic-styles><office:body><office:text><text:table-of-content text:style-name="Sect9" text:protected="true" text:name="Contents" wcx:kind="main"><text:table-of-content-source text:outline-level="2" text:use-index-marks="false"><text:index-title-template text:style-name="Contents_20_Heading">Contents</text:index-title-template><text:table-of-content-entry-template text:outline-level="1" text:style-name="Contents_20_1"><text:index-entry-chapter/><text:index-entry-text/><text:index-entry-tab-stop style:type="right" style:leader-char="."/><text:index-entry-page-number/></text:table-of-content-entry-template></text:table-of-content-source><text:index-body><text:index-title text:style-name="Sect9" text:name="Contents_Head"><text:p text:style-name="Contents_20_Heading">Contents</text:p></text:index-title><text:p text:style-name="Contents_20_1">Orchards<text:tab/>1</text:p></text:index-body></text:table-of-content><text:h text:outline-level="1">Orchards</text:h><text:p>Pears and <text:alphabetical-index-mark text:string-value="apples"/>apples.</text:p><text:alphabetical-index text:name="Words" text:protected="true"><text:alphabetical-index-source text:ignore-case="true" text:combine-entries="true" text:combine-entries-with-pp="false" fo:language="en" fo:country="GB"><text:index-title-template>Words</text:index-title-template><text:alphabetical-index-entry-template text:outline-level="1" text:style-name="Index_20_1"><text:index-entry-text/><text:index-entry-tab-stop style:type="right" style:leader-char="."/><text:index-entry-page-number/></text:alphabetical-index-entry-template></text:alphabetical-index-source><text:index-body><text:index-title text:name="Words_Head"><text:p>Words</text:p></text:index-title><text:p text:style-name="Index_20_1">apples<text:tab/>1</text:p></text:index-body></text:alphabetical-index></office:text></office:body>"##,
styles: "",
meta: "",
};
/// Every sample.
const SAMPLES: [Sample; 7] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS];
const SAMPLES: [Sample; 9] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES];
/// Whether `xml` is well-formed: every element closed, in order, once.
fn well_formed(xml: &[u8]) -> bool {
@@ -273,3 +289,26 @@ fn edits_leave_kept_markup_well_formed() {
}
}
}
#[test]
fn an_edited_index_is_written_from_the_model() {
// Unedited, the index keeps its templates; once an entry changes, the edit is what's saved.
use wordcraft_doc::Pos;
let d = import(&package(&INDEXES)).unwrap();
let c = content_of(&export(&d).unwrap());
assert!(c.contains("text:combine-entries-with-pp=\"false\""), "{c}");
let i = d.body.iter().position(|b| b.as_para().is_some_and(|p| p.plain_text().starts_with("Orchards\t"))).unwrap();
let mut e = d.clone();
e.insert_text(&Pos::body(i, 0), "Edited ", &Default::default()).unwrap();
let c = content_of(&export(&e).unwrap());
assert!(c.contains("Edited Orchards"), "{c}");
// The other index, unedited, is still as read.
assert!(c.contains("text:combine-entries-with-pp=\"false\""), "{c}");
}
fn content_of(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut s).unwrap();
s
}
+12
View File
@@ -681,6 +681,18 @@ impl Writer {
// Only the first top-level block of a section starts its page.
if depth == 0 {
self.master = self.pending_start.take();
// An index kept as read (its paragraphs all as read): as it was.
if let Some(FBlock::Para(p)) = blocks.get(i)
&& let Some(ix) = p.index.as_deref()
&& chains.is_none()
&& (self.master.is_none() || self.kept_pages)
&& self.kept_markup(&ix.0, out)
{
self.master = None;
out.push('\n');
i += ix.1.max(1);
continue;
}
// A table of contents or another index, with its entries.
if let Some((n, kind, titled)) = index_at(blocks, i) {
let joined = chains.is_some_and(|c| (i..i + n).any(|k| c.contains_key(&k)));
File diff suppressed because one or more lines are too long