Skip to main content

docspec_docx_reader/
lib.rs

1#![forbid(unsafe_code)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3//! DOCX to `DocSpec` event stream reader.
4//!
5//! This crate provides a [`DocxReader`] that implements [`EventSource`] to convert
6//! DOCX documents into the `DocSpec` event stream format. It uses `quick-xml` for
7//! streaming XML parsing and `zip` for archive extraction.
8//!
9//! # Scope
10//!
11//! **In scope**: Paragraphs (`<w:p>`), direct text (`<w:t>` inside `<w:r>`),
12//! line breaks (`<w:br>` — including `w:type="page"` and `w:type="column"`, all
13//! emitted as `LineBreak`), tabs (`<w:tab>`, emitted as a `Text` event whose
14//! content is the single character `"\t"`), tables (`<w:tbl>`, `<w:tr>`,
15//! `<w:tc>`), lists (`<w:p>` with `<w:numPr>` — ordered and unordered),
16//! hyperlinks (`<w:hyperlink>` — resolved via `word/_rels/document.xml.rels`
17//! and emitted as `StartLink`/`EndLink` events around inline content),
18//! structured document tags (`<w:sdt>` — content emitted normally;
19//! `<w:sdtPr>`/`<w:sdtEndPr>` dropped), tracked insertions and moves
20//! (`<w:ins>`, `<w:moveTo>` — accept-changes semantics), `DrawingML` images
21//! (`<w:drawing>` — emitted as `Image` events; see the crate README for
22//! `ImageSource` variants and `AssetHandle` usage), and VML images
23//! (`<w:pict>` containing `<v:imagedata>`) — emitted as `Event::Image`.
24//! Alt text precedence: `o:title` attribute on `<v:imagedata>` (non-empty) falls back to
25//! `alt` attribute on the parent `<v:shape>`. Multiple `<v:imagedata>` elements in one
26//! `<w:pict>` emit multiple `Image` events in document order. `<v:group>` wrappers are
27//! transparent.
28//! Emits: `StartDocument`, `StartParagraph`, `StartTextStyle`, `Text`,
29//! `EndTextStyle`, `LineBreak`, `EndParagraph`, `StartTable`, `StartTableRow`,
30//! `StartTableCell`, `StartTableHeader`, `EndTableHeader`, `EndTableCell`,
31//! `EndTableRow`, `EndTable`, `StartLink`, `EndLink`, `StartOrderedListItem`,
32//! `EndOrderedListItem`, `StartUnorderedListItem`, `EndUnorderedListItem`,
33//! `Image`, `EndDocument`.
34//!
35//! The elements listed under "Out of scope" are the reader's denylist — their
36//! entire subtree is silently dropped. Every other element (known or unknown)
37//! is parsed normally; the reader continues into its children.
38//!
39//! **Out of scope (subtree silently dropped)**:
40//! - Run styling not listed in the crate README
41//! - Headings (any `<w:pStyle>` value — every paragraph is `StartParagraph`)
42//! - Vertical cell merging (`<w:vMerge>`) — every cell emits with
43//!   `rowspan: None`
44//! - Header rows in nested tables — only the outermost table honors
45//!   `<w:tblHeader>`
46//! - Table-level property exceptions (`<w:tblPrEx>`) — silently ignored
47//! - Table, row, and cell visual properties (`<w:tblPr>`, `<w:trPr>` visual
48//!   fields, `<w:tcPr>` visual fields, `<w:tblGrid>`)
49//! - `<mc:Fallback>` (in `<mc:AlternateContent>`) — subtree silently dropped to prevent
50//!   duplicate `Image` events when both a modern `<w:drawing>` (in `<mc:Choice>`) and a
51//!   legacy `<w:pict>` (in `<mc:Fallback>`) reference the same image.
52//! - VML positioning attributes (`style="position:..."`, `w10:wrap`, `w10:anchorlock`,
53//!   `<v:textbox>` text content) — silently dropped (consistent with `<w:drawing>`
54//!   positioning loss for `<wp:anchor>`).
55//! - VML image-bytes via `<w:binData>` (referenced from `<v:imagedata src="wordml://..."/>`)
56//!   — not parsed; the `<v:imagedata>` degrades to "no rId found" and emits no event.
57//! - Comments, footnotes, headers, footers
58//! - Document metadata
59//! - Tracked deletions (`<w:del>`, `<w:moveFrom>`) — accept-changes semantics
60//! - Structured document tag properties (`<w:sdtPr>`, `<w:sdtEndPr>`)
61//! - Field-code hyperlinks (`<w:fldChar>` + `<w:instrText>HYPERLINK ...`):
62//!   legacy form not currently supported; only the modern `<w:hyperlink>`
63//!   element is recognized.
64//!
65//! # Lists
66//!
67//! See the crate README for V1 list semantics and limitations.
68//!
69//! # Streaming Guarantee
70//!
71//! Memory use depends on which constructor you call:
72//!
73//! - **`from_path`**: streams `word/document.xml` in constant memory — O(1)
74//!   regardless of document size. This is the recommended path for large files.
75//! - **`from_reader`**: buffers `word/document.xml` into memory — O(N) in
76//!   document size. Use `from_path` when constant memory is required.
77//!
78//! In both cases, `_rels/.rels` and `word/_rels/document.xml.rels` are fully
79//! read into memory at package-open time (typical combined size < 10 KB even
80//! for large documents). The internal event queue remains bounded regardless
81//! of document size or hyperlink count.
82//!
83//! # Quick Start
84//!
85//! ```no_run
86//! use docspec_docx_reader::{DocxReader, EventSource};
87//!
88//! let mut reader = DocxReader::from_path("document.docx")?;
89//! while let Some(event) = reader.next_event()? {
90//!     println!("{event:?}");
91//! }
92//! # Ok::<(), docspec_core::Error>(())
93//! ```
94
95extern crate alloc;
96
97mod asset_provider;
98mod content_types;
99mod document;
100mod numbering;
101mod package;
102mod properties;
103mod rels;
104mod streaming_archive;
105mod styles;
106mod symbol_fonts;
107
108use std::io::{BufReader, Read, Seek};
109use std::path::Path;
110
111pub use docspec_core::EventSource;
112
113use docspec_core::Result;
114
115const _: for<'a> fn(&'a content_types::ContentTypes, &str) -> Option<&'a str> =
116    content_types::ContentTypes::lookup;
117fn _image_rel_fields(r: &rels::ImageRel) -> (&str, bool) {
118    (&r.target, r.is_external)
119}
120const _: for<'a> fn(&'a rels::ImageRel) -> (&'a str, bool) = _image_rel_fields;
121
122/// A streaming DOCX reader that implements [`EventSource`].
123///
124/// `DocxReader` parses a DOCX archive and emits `DocSpec` events one at a time.
125/// `<w:p>` paragraphs, `<w:t>` text, `<w:br>` line breaks, `<w:tab>` tabs,
126/// table elements (`<w:tbl>`, `<w:tr>`, `<w:tc>`), and `DrawingML` images
127/// (`<w:drawing>`) are recognized; all other elements are silently ignored.
128///
129/// # Streaming
130///
131/// Memory use depends on which constructor you call. `from_path` streams
132/// `word/document.xml` in constant memory (O(1)). `from_reader` buffers
133/// `word/document.xml` into memory (O(N) in document size). In both cases,
134/// `_rels/.rels` and `word/_rels/document.xml.rels` are fully read into memory
135/// at package-open time (typical combined size < 10 KB). The internal event
136/// queue remains bounded regardless of document size or hyperlink count.
137///
138/// # Errors
139///
140/// Returns [`docspec_core::Error::Io`] for I/O failures and [`docspec_core::Error::Parse`] for malformed
141/// archives or XML.
142#[derive(Debug)]
143pub struct DocxReader {
144    inner: document::DocumentReader,
145}
146
147impl DocxReader {
148    /// Creates a `DocxReader` from a file path.
149    ///
150    /// Streams `word/document.xml` in constant memory (O(1) in document size).
151    /// Prefer this over [`from_reader`](Self::from_reader) when memory is a concern.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`docspec_core::Error::Io`] if the file cannot be opened. See [`from_reader`](Self::from_reader)
156    /// for additional error conditions.
157    #[inline]
158    pub fn from_path<P>(path: P) -> Result<Self>
159    where
160        P: AsRef<Path>,
161    {
162        let (style_list, numbering, hyperlink_map, image_map, content_types, archive, stream) =
163            package::open_package_from_path(path.as_ref())?;
164        let xml = quick_xml::Reader::from_reader(BufReader::new(stream));
165        let data = document::DocxData {
166            style_list,
167            hyperlink_map,
168            numbering,
169            image_map,
170        };
171        Ok(Self {
172            inner: document::DocumentReader::from_xml_reader_and_archive(
173                xml,
174                data,
175                archive,
176                content_types,
177            ),
178        })
179    }
180
181    /// Creates a `DocxReader` from any `Read + Seek` source.
182    ///
183    /// The reader must be positioned at the start of a valid DOCX (ZIP) archive.
184    ///
185    /// **Memory note**: this constructor buffers `word/document.xml` into memory
186    /// (O(N) in document size). Use [`from_path`](Self::from_path) for O(1)
187    /// constant-memory streaming.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`docspec_core::Error::Parse`] if the input is not a valid ZIP archive, if
192    /// `_rels/.rels` is missing or malformed, or if the document target entry
193    /// cannot be opened. Returns [`docspec_core::Error::Io`] for I/O failures.
194    #[inline]
195    pub fn from_reader<R>(reader: R) -> Result<Self>
196    where
197        R: Read + Seek + Send + 'static,
198    {
199        let (style_list, numbering, hyperlink_map, image_map, content_types, archive, stream) =
200            package::open_package(reader)?;
201        let xml = quick_xml::Reader::from_reader(BufReader::new(stream));
202        let data = document::DocxData {
203            style_list,
204            hyperlink_map,
205            numbering,
206            image_map,
207        };
208        Ok(Self {
209            inner: document::DocumentReader::from_xml_reader_and_archive(
210                xml,
211                data,
212                archive,
213                content_types,
214            ),
215        })
216    }
217}
218
219impl EventSource for DocxReader {
220    #[inline]
221    fn next_event(&mut self) -> Result<Option<docspec_core::Event>> {
222        self.inner.next_event()
223    }
224}
225
226#[cfg(test)]
227#[cfg(not(coverage))]
228mod tests {
229    #![allow(clippy::unwrap_used, clippy::panic)]
230    use super::*;
231
232    #[test]
233    fn docx_reader_is_send_static() {
234        fn assert_send_static<T>()
235        where
236            T: Send + 'static,
237        {
238        }
239        assert_send_static::<DocxReader>();
240    }
241
242    #[test]
243    fn docx_without_styles_emits_only_paragraphs() {
244        use std::io::{Cursor, Write as _};
245        use zip::ZipWriter;
246
247        let root_rels = r#"<?xml version="1.0" encoding="UTF-8"?>
248<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
249  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
250</Relationships>"#;
251        let document_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
252<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
253  <w:body>
254    <w:p><w:r><w:t>hi</w:t></w:r></w:p>
255  </w:body>
256</w:document>"#;
257
258        let buf = Cursor::new(Vec::new());
259        let mut writer = ZipWriter::new(buf);
260        let options = zip::write::SimpleFileOptions::default()
261            .compression_method(zip::CompressionMethod::Stored);
262        writer.start_file("_rels/.rels", options).unwrap();
263        writer.write_all(root_rels.as_bytes()).unwrap();
264        writer.start_file("word/document.xml", options).unwrap();
265        writer.write_all(document_xml.as_bytes()).unwrap();
266        let zip_bytes = writer.finish().unwrap().into_inner();
267
268        let mut reader = DocxReader::from_reader(Cursor::new(zip_bytes)).unwrap();
269        let mut events = Vec::new();
270        loop {
271            match reader.next_event() {
272                Ok(Some(event)) => events.push(event),
273                Ok(None) => break,
274                Err(err) => panic!("unexpected error: {err:?}"),
275            }
276        }
277
278        assert_eq!(
279            events,
280            vec![
281                docspec_core::Event::StartDocument {
282                    id: None,
283                    language: None,
284                    metadata: None,
285                },
286                docspec_core::Event::StartParagraph {
287                    alignment: None,
288                    id: None,
289                },
290                docspec_core::Event::Text {
291                    content: "hi".to_string(),
292                },
293                docspec_core::Event::EndParagraph,
294                docspec_core::Event::EndDocument,
295            ]
296        );
297    }
298}