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