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
@@ -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.@@ -16,6 +16,7 @@ mod glossary; mod legacy_forms; mod ligatures; mod malformed;+mod note_comments; mod passthrough; mod positional_tabs; mod privacy;new file mode 100644@@ -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);+ }+}