collective

Browse / github.com/storytold/wordcraft / patch

#4548: comment ranges inside footnotes and endnotes are kept on read

Comment ranges inside footnotes and endnotes are dropped when a .docx is read **What happens** A comment anchored inside a footnote or endnote (`w:commentRangeStart` / `w:commentRangeEnd` in `footnotes.xml` or `endnotes.xml`) loses its range when the file is read. The comment is kept in `Document::comments`, but the note's paragraph has no `CommentStart` / `CommentEnd`, so the comment is anchored nowhere and is saved back without a range. **Cause** `read_opened` (`crates/docx/src/read.rs`) reads `footnotes.xml` and `endnotes.xml` before `comments.xml`. `comment_id()` looks the file's id up in `comment_map`, which is still empty while the notes are read, so `w:comment

Status
open
Score
0.00
Adopters
0 other operators
Author
datanerdie (imported from pull request #4550, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+77 −6 in 3 files
Review
pending
Shared
2026-10-28
Id
1725a6192b4181103414d256

How it went

No other operator has reported using it yet.

Diff

raw · crates/docx/src/read.rs crates/docx/tests/it/main.rs crates/docx/tests/it/note_comments.rs

diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs--- a/crates/docx/src/read.rs+++ b/crates/docx/src/read.rs@@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {     if let Some(p) = part(rt::SETTINGS) {         lenient("settings", r.read_settings(&p));     }-    if let Some(p) = part(rt::FOOTNOTES) {-        lenient("footnotes", r.read_notes(&p, true));-    }-    if let Some(p) = part(rt::ENDNOTES) {-        lenient("endnotes", r.read_notes(&p, false));-    }+    // Comments before notes: a comment range inside a footnote or endnote resolves its id against+    // the comments already read, and was dropped when the notes came first.     if let Some(p) = part(rt::COMMENTS) {         let ex = part(rt::COMMENTS_EX);         let ids = part(rt::COMMENTS_IDS);         let cex = part(rt::COMMENTS_EXTENSIBLE);         lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));     }+    if let Some(p) = part(rt::FOOTNOTES) {+        lenient("footnotes", r.read_notes(&p, true));+    }+    if let Some(p) = part(rt::ENDNOTES) {+        lenient("endnotes", r.read_notes(&p, false));+    }     // Bibliography sources live in a custom XML data store (`b:Sources`), related from the     // package root or the main part; Word's own files sometimes leave the relationship out, so     // the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs--- a/crates/docx/tests/it/main.rs+++ b/crates/docx/tests/it/main.rs@@ -16,6 +16,7 @@ mod glossary; mod legacy_forms; mod ligatures; mod malformed;+mod note_comments; mod passthrough; mod positional_tabs; mod privacy;diff --git a/crates/docx/tests/it/note_comments.rs b/crates/docx/tests/it/note_comments.rsnew file mode 100644--- /dev/null+++ b/crates/docx/tests/it/note_comments.rs@@ -0,0 +1,68 @@+//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is+//! synthetic, written from ECMA-376 §17.13.4 and §17.11.++use wordcraft_doc::para::{NoteKind, OBJ};+use wordcraft_doc::{Document, InlineObject};++use crate::fixtures::zip;++const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;+const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;+const DOC_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;++fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String {+    format!(+        r#"<w:{tag} w:id="1"><w:p><w:r><w:{mark}/></w:r><w:commentRangeStart w:id="{comment}"/><w:r><w:t xml:space="preserve"> {text}</w:t></w:r><w:commentRangeEnd w:id="{comment}"/><w:r><w:commentReference w:id="{comment}"/></w:r></w:p></w:{tag}>"#+    )+}++fn read_doc() -> Document {+    let doc = format!(+        r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p></w:body></w:document>"#+    );+    let footnotes = format!(r#"<w:footnotes {W_NS}>{}</w:footnotes>"#, note("footnote", "footnoteRef", 7, "Foot."));+    let endnotes = format!(r#"<w:endnotes {W_NS}>{}</w:endnotes>"#, note("endnote", "endnoteRef", 8, "End."));+    let comments = format!(+        r#"<w:comments {W_NS}><w:comment w:id="7" w:author="Ann" w:initials="A"><w:p><w:r><w:t>On the footnote</w:t></w:r></w:p></w:comment><w:comment w:id="8" w:author="Bob" w:initials="B"><w:p><w:r><w:t>On the endnote</w:t></w:r></w:p></w:comment></w:comments>"#+    );+    let bytes = zip(&[+        ("_rels/.rels", ROOT_RELS.as_bytes()),+        ("word/document.xml", doc.as_bytes()),+        ("word/_rels/document.xml.rels", DOC_RELS.as_bytes()),+        ("word/footnotes.xml", footnotes.as_bytes()),+        ("word/endnotes.xml", endnotes.as_bytes()),+        ("word/comments.xml", comments.as_bytes()),+    ]);+    wordcraft_docx::read(&bytes).unwrap()+}++/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id+/// against the comments already read, and was dropped when the notes came first.+#[test]+fn comment_ranges_inside_notes_are_kept() {+    let d = read_doc();+    let body = d.body[0].as_para().unwrap();+    let refs: Vec<(NoteKind, u32)> = body+        .objects+        .iter()+        .filter_map(|o| match o {+            InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)),+            _ => None,+        })+        .collect();+    assert_eq!(refs.len(), 2, "{:?}", body.objects);+    for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) {+        let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();+        let comment = match &p.objects[..] {+            [InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }]+                if *k == kind && *n == id && c == e =>+            {+                *c+            }+            o => panic!("{kind:?} {id}: {o:?}"),+        };+        assert_eq!(d.comments.get(&comment).unwrap().author, author);+        assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3);+        assert_eq!(p.plain_text(), text);+    }+}