mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 04:47:10 -06:00
DOCX: hand WordCraft's own TOC control back to the TOC, keep it when a block control covers the heading (#2427)
A TOC whose heading isn't a TOCHeading is written with an empty heading paragraph inside the control. Keeping the control around it on reading left that paragraph non-empty, so each save and Update Table added a blank line (toc_menu_offers_update_field_after_save_and_reopen, #2182). The reader now compares against the TOC the writer would actually wrap (written_toc_span), which also keeps the original control when another block control covers the TOC heading.
This commit is contained in:
+50
-36
@@ -460,8 +460,21 @@ fn toc_control_to_toc(body: &mut Blocks) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
toc_field_to_heading(&mut toc);
|
||||
if crate::write::toc_span(&toc) == Some((s, e)) {
|
||||
let written = |bl: &Blocks| crate::write::written_toc_span(bl) == Some((s, e));
|
||||
if written(&toc) {
|
||||
*body = toc;
|
||||
return;
|
||||
}
|
||||
// A heading that isn't a `TOCHeading` is written empty, its field opening in the first entry.
|
||||
// That TOC is handed over as the range read, which Update Table turns back into the engine's
|
||||
// TOC in that paragraph (`range_toc_to_field`); kept in the control, the paragraph wouldn't be
|
||||
// empty and each save and update would add a blank line above the entries (#2182).
|
||||
if toc.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
|
||||
let mut engine = toc.clone();
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
if written(&engine) {
|
||||
*body = toc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,43 +482,44 @@ fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
|
||||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||||
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
continue;
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: String::new(),
|
||||
locked: *locked,
|
||||
dirty: *dirty,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
}) else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the TOC field that opens paragraph `i` (a complete range over the entries, or an empty
|
||||
/// field) to the end of paragraph `i - 1`, the engine's TOC heading, unless that one has a TOC
|
||||
/// field already.
|
||||
fn field_to_heading(body: &mut Blocks, i: usize) {
|
||||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||||
if i == 0 || body.get(i - 1).and_then(|b| b.as_para()).is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
return;
|
||||
}
|
||||
let Some((field, props, end)) = body.get(i).and_then(|b| b.as_para()).and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, dirty: *dirty, code: false, form: String::new() },
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
|
||||
let end = h.len();
|
||||
let _ = h.insert_object(end, field, &props);
|
||||
}
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
|
||||
let end = h.len();
|
||||
let _ = h.insert_object(end, field, &props);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
mod story;
|
||||
pub(crate) use story::toc_span;
|
||||
pub(crate) use story::written_toc_span;
|
||||
mod watermark;
|
||||
mod wordart;
|
||||
|
||||
|
||||
@@ -35,8 +35,7 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let plan = super::sdt::plan(bl);
|
||||
// A TOC inside a content control already has one (read from a file).
|
||||
let toc = if top { toc_span(bl).filter(|(start, _)| !plan.covers(*start)) } else { None };
|
||||
let toc = if top { written_toc_span(bl) } else { None };
|
||||
// Whether the heading's TOC field was written and still needs its end.
|
||||
let mut toc_field_open = false;
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
@@ -1688,9 +1687,15 @@ fn is_toc(instr: &str) -> bool {
|
||||
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
}
|
||||
|
||||
/// The body TOC the writer wraps in Word's Table of Contents control (`toc_span`): not one whose
|
||||
/// heading is inside a content control already (read from a file).
|
||||
pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
toc_span(bl).filter(|(start, _)| !super::sdt::plan(bl).covers(*start))
|
||||
}
|
||||
|
||||
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
|
||||
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
|
||||
pub(crate) fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
let start = bl.iter().position(|b| {
|
||||
b.as_para().is_some_and(|p| {
|
||||
p.section.is_none()
|
||||
|
||||
@@ -301,3 +301,33 @@ fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rich text control around the heading and the first entry of a TOC in the engine's form,
|
||||
/// inside Word's Table of Contents control: the writer doesn't put a TOC control around a heading
|
||||
/// already in a block control, so the outer one is kept as read rather than lost on save.
|
||||
#[test]
|
||||
fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}",
|
||||
sdt(
|
||||
r#"<w:id w:val="31"/><w:richText/>"#,
|
||||
&format!("{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))))
|
||||
),
|
||||
style("TOC2", &format!("{}{end}", t("Section\t2")))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 2, "pass {pass}: {xml}");
|
||||
let (gallery, rich) =
|
||||
(xml.find(&format!("<w:sdtPr>{pr}")).unwrap_or_else(|| panic!("pass {pass}: {xml}")), xml.find(r#"<w:id w:val="31"/>"#).unwrap());
|
||||
assert!(gallery < rich && rich < xml.find(">Contents<").unwrap(), "pass {pass}: {xml}");
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user