Skip to main content

linch_docx_rs/document/
footnotes.rs

1//! Footnotes and endnotes (footnotes.xml / endnotes.xml)
2
3use crate::document::Paragraph;
4use crate::error::Result;
5use crate::xml::{get_attr, RawXmlElement, RawXmlNode};
6use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
7use quick_xml::{Reader, Writer};
8use std::io::Cursor;
9
10/// A single footnote or endnote
11#[derive(Clone, Debug)]
12pub struct Note {
13    /// Note ID
14    pub id: i32,
15    /// Note type (normal, separator, continuationSeparator)
16    pub note_type: Option<String>,
17    /// Paragraphs
18    pub paragraphs: Vec<Paragraph>,
19    /// Unknown children (preserved for round-trip)
20    pub unknown_children: Vec<RawXmlNode>,
21}
22
23impl Note {
24    /// Create a new note with text
25    pub fn new(id: i32, text: impl Into<String>) -> Self {
26        Note {
27            id,
28            note_type: None,
29            paragraphs: vec![Paragraph::new(text)],
30            unknown_children: Vec::new(),
31        }
32    }
33
34    /// Get all text
35    pub fn text(&self) -> String {
36        self.paragraphs
37            .iter()
38            .map(|p| p.text())
39            .collect::<Vec<_>>()
40            .join("\n")
41    }
42
43    /// Is this a regular note (not a separator)
44    pub fn is_regular(&self) -> bool {
45        self.note_type.is_none() || self.note_type.as_deref() == Some("normal")
46    }
47}
48
49/// Collection of footnotes or endnotes
50#[derive(Clone, Debug, Default)]
51pub struct Notes {
52    /// Individual notes
53    pub notes: Vec<Note>,
54    /// Whether these are footnotes (true) or endnotes (false)
55    pub is_footnotes: bool,
56    /// Unknown children (preserved for round-trip)
57    pub unknown_children: Vec<RawXmlNode>,
58}
59
60impl Notes {
61    /// Parse from XML string
62    pub fn from_xml(xml: &str, is_footnotes: bool) -> Result<Self> {
63        let mut reader = Reader::from_str(xml);
64        reader.config_mut().trim_text(true);
65
66        let mut notes = Notes {
67            is_footnotes,
68            ..Default::default()
69        };
70        let mut buf = Vec::new();
71
72        let note_tag = if is_footnotes {
73            b"footnote".as_slice()
74        } else {
75            b"endnote".as_slice()
76        };
77
78        loop {
79            match reader.read_event_into(&mut buf)? {
80                Event::Start(e) => {
81                    let local = e.name().local_name();
82                    if local.as_ref() == note_tag {
83                        notes.notes.push(parse_note(&mut reader, &e)?);
84                    } else if local.as_ref() != b"footnotes" && local.as_ref() != b"endnotes" {
85                        let raw = RawXmlElement::from_reader(&mut reader, &e)?;
86                        notes.unknown_children.push(RawXmlNode::Element(raw));
87                    }
88                }
89                Event::Eof => break,
90                _ => {}
91            }
92            buf.clear();
93        }
94
95        Ok(notes)
96    }
97
98    /// Serialize to XML string
99    pub fn to_xml(&self) -> Result<String> {
100        let mut buffer = Cursor::new(Vec::new());
101        let mut writer = Writer::new(&mut buffer);
102
103        writer.write_event(Event::Decl(BytesDecl::new(
104            "1.0",
105            Some("UTF-8"),
106            Some("yes"),
107        )))?;
108
109        let root_tag = if self.is_footnotes {
110            "w:footnotes"
111        } else {
112            "w:endnotes"
113        };
114        let note_tag = if self.is_footnotes {
115            "w:footnote"
116        } else {
117            "w:endnote"
118        };
119
120        let mut start = BytesStart::new(root_tag);
121        start.push_attribute(("xmlns:w", crate::xml::W));
122        start.push_attribute(("xmlns:r", crate::xml::R));
123        writer.write_event(Event::Start(start))?;
124
125        for note in &self.notes {
126            let mut note_start = BytesStart::new(note_tag);
127            note_start.push_attribute(("w:id", note.id.to_string().as_str()));
128            if let Some(ref nt) = note.note_type {
129                note_start.push_attribute(("w:type", nt.as_str()));
130            }
131
132            writer.write_event(Event::Start(note_start))?;
133
134            for para in &note.paragraphs {
135                para.write_to(&mut writer)?;
136            }
137
138            for child in &note.unknown_children {
139                child.write_to(&mut writer)?;
140            }
141
142            writer.write_event(Event::End(BytesEnd::new(note_tag)))?;
143        }
144
145        for child in &self.unknown_children {
146            child.write_to(&mut writer)?;
147        }
148
149        writer.write_event(Event::End(BytesEnd::new(root_tag)))?;
150
151        let xml_bytes = buffer.into_inner();
152        String::from_utf8(xml_bytes)
153            .map_err(|e| crate::error::Error::InvalidDocument(e.to_string()))
154    }
155
156    /// Get a note by ID
157    pub fn get(&self, id: i32) -> Option<&Note> {
158        self.notes.iter().find(|n| n.id == id)
159    }
160
161    /// Get regular notes (excluding separators)
162    pub fn regular_notes(&self) -> impl Iterator<Item = &Note> {
163        self.notes.iter().filter(|n| n.is_regular())
164    }
165
166    /// Next available ID
167    pub fn next_id(&self) -> i32 {
168        self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1
169    }
170
171    /// Add a note, returns the assigned ID
172    pub fn add(&mut self, text: impl Into<String>) -> i32 {
173        let id = self.next_id();
174        self.notes.push(Note::new(id, text));
175        id
176    }
177}
178
179fn parse_note<R: std::io::BufRead>(reader: &mut Reader<R>, start: &BytesStart) -> Result<Note> {
180    let id = get_attr(start, "w:id")
181        .or_else(|| get_attr(start, "id"))
182        .and_then(|v| v.parse().ok())
183        .unwrap_or(0);
184    let note_type = get_attr(start, "w:type").or_else(|| get_attr(start, "type"));
185
186    let mut note = Note {
187        id,
188        note_type,
189        paragraphs: Vec::new(),
190        unknown_children: Vec::new(),
191    };
192
193    let mut buf = Vec::new();
194    loop {
195        match reader.read_event_into(&mut buf)? {
196            Event::Start(e) => {
197                let local = e.name().local_name();
198                if local.as_ref() == b"p" {
199                    note.paragraphs.push(Paragraph::from_reader(reader, &e)?);
200                } else {
201                    let raw = RawXmlElement::from_reader(reader, &e)?;
202                    note.unknown_children.push(RawXmlNode::Element(raw));
203                }
204            }
205            Event::Empty(e) => {
206                let local = e.name().local_name();
207                if local.as_ref() == b"p" {
208                    note.paragraphs.push(Paragraph::from_empty(&e)?);
209                }
210            }
211            Event::End(e) => {
212                let local = e.name().local_name();
213                if local.as_ref() == b"footnote" || local.as_ref() == b"endnote" {
214                    break;
215                }
216            }
217            Event::Eof => break,
218            _ => {}
219        }
220        buf.clear();
221    }
222
223    Ok(note)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_parse_footnotes() {
232        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
233<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
234  <w:footnote w:type="separator" w:id="-1">
235    <w:p><w:r><w:separator/></w:r></w:p>
236  </w:footnote>
237  <w:footnote w:id="1">
238    <w:p>
239      <w:r><w:t>This is footnote 1</w:t></w:r>
240    </w:p>
241  </w:footnote>
242</w:footnotes>"#;
243
244        let notes = Notes::from_xml(xml, true).unwrap();
245        assert_eq!(notes.notes.len(), 2);
246        assert!(!notes.notes[0].is_regular());
247        assert!(notes.notes[1].is_regular());
248        assert_eq!(notes.get(1).unwrap().text(), "This is footnote 1");
249        assert_eq!(notes.regular_notes().count(), 1);
250    }
251
252    #[test]
253    fn test_notes_roundtrip() {
254        let mut notes = Notes {
255            is_footnotes: true,
256            ..Default::default()
257        };
258        let id = notes.add("Test footnote");
259        assert_eq!(id, 1);
260
261        let xml = notes.to_xml().unwrap();
262        let notes2 = Notes::from_xml(&xml, true).unwrap();
263        assert_eq!(notes2.notes.len(), 1);
264        assert_eq!(notes2.get(1).unwrap().text(), "Test footnote");
265    }
266
267    #[test]
268    fn test_endnotes() {
269        let mut notes = Notes {
270            is_footnotes: false,
271            ..Default::default()
272        };
273        notes.add("Endnote content");
274
275        let xml = notes.to_xml().unwrap();
276        assert!(xml.contains("w:endnotes"));
277        assert!(xml.contains("w:endnote"));
278    }
279}