kreuzberg 4.2.11 → 4.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +1 -1
- data/ext/kreuzberg_rb/native/Cargo.toml +1 -1
- data/lib/kreuzberg/version.rb +1 -1
- data/vendor/Cargo.toml +2 -2
- data/vendor/kreuzberg/Cargo.toml +24 -9
- data/vendor/kreuzberg/README.md +1 -1
- data/vendor/kreuzberg/src/core/config/extraction/core.rs +11 -0
- data/vendor/kreuzberg/src/core/extractor/bytes.rs +7 -7
- data/vendor/kreuzberg/src/core/extractor/file.rs +11 -11
- data/vendor/kreuzberg/src/core/mime.rs +47 -2
- data/vendor/kreuzberg/src/extraction/archive/gzip.rs +129 -0
- data/vendor/kreuzberg/src/extraction/archive/mod.rs +147 -31
- data/vendor/kreuzberg/src/extraction/archive/sevenz.rs +44 -4
- data/vendor/kreuzberg/src/extraction/archive/tar.rs +38 -3
- data/vendor/kreuzberg/src/extraction/archive/zip.rs +37 -3
- data/vendor/kreuzberg/src/extraction/{docx.rs → docx/mod.rs} +7 -17
- data/vendor/kreuzberg/src/extraction/docx/parser.rs +686 -0
- data/vendor/kreuzberg/src/extraction/image.rs +405 -18
- data/vendor/kreuzberg/src/extraction/mod.rs +2 -2
- data/vendor/kreuzberg/src/extractors/archive.rs +146 -15
- data/vendor/kreuzberg/src/extractors/bibtex.rs +3 -2
- data/vendor/kreuzberg/src/extractors/citation.rs +563 -0
- data/vendor/kreuzberg/src/extractors/docx.rs +10 -22
- data/vendor/kreuzberg/src/extractors/image.rs +25 -0
- data/vendor/kreuzberg/src/extractors/markdown.rs +10 -1
- data/vendor/kreuzberg/src/extractors/mod.rs +21 -5
- data/vendor/kreuzberg/src/extractors/opml/core.rs +2 -1
- data/vendor/kreuzberg/src/extractors/security.rs +2 -1
- data/vendor/kreuzberg/src/extractors/structured.rs +10 -3
- data/vendor/kreuzberg/src/extractors/text.rs +33 -4
- data/vendor/kreuzberg/src/extractors/xml.rs +12 -2
- data/vendor/kreuzberg/src/ocr/processor/execution.rs +16 -3
- data/vendor/kreuzberg/tests/issue_359_list_whitespace_test.rs +33 -0
- data/vendor/kreuzberg-tesseract/Cargo.toml +2 -2
- metadata +7 -3
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
//! Inline DOCX XML parser.
|
|
2
|
+
//!
|
|
3
|
+
//! Vendored and adapted from [docx-lite](https://github.com/v-lawyer/docx-lite) v0.2.0
|
|
4
|
+
//! (MIT OR Apache-2.0, V-Lawyer Team). See ATTRIBUTIONS.md for details.
|
|
5
|
+
//!
|
|
6
|
+
//! Changes from upstream:
|
|
7
|
+
//! - `Paragraph::to_text()` joins runs with `" "` instead of `""` (fixes #359)
|
|
8
|
+
//! - Adapted to use kreuzberg's existing `quick-xml` and `zip` versions
|
|
9
|
+
//! - Removed file-path based APIs (we only need bytes/reader)
|
|
10
|
+
|
|
11
|
+
use std::collections::HashMap;
|
|
12
|
+
use std::io::{Cursor, Read, Seek};
|
|
13
|
+
|
|
14
|
+
use quick_xml::Reader;
|
|
15
|
+
use quick_xml::events::Event;
|
|
16
|
+
|
|
17
|
+
// --- Types ---
|
|
18
|
+
|
|
19
|
+
#[derive(Debug, Clone, Default)]
|
|
20
|
+
pub struct Document {
|
|
21
|
+
pub paragraphs: Vec<Paragraph>,
|
|
22
|
+
pub tables: Vec<Table>,
|
|
23
|
+
pub lists: Vec<ListItem>,
|
|
24
|
+
pub headers: Vec<HeaderFooter>,
|
|
25
|
+
pub footers: Vec<HeaderFooter>,
|
|
26
|
+
pub footnotes: Vec<Note>,
|
|
27
|
+
pub endnotes: Vec<Note>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Debug, Clone, Default)]
|
|
31
|
+
pub struct Paragraph {
|
|
32
|
+
pub runs: Vec<Run>,
|
|
33
|
+
pub style: Option<String>,
|
|
34
|
+
pub numbering_id: Option<i64>,
|
|
35
|
+
pub numbering_level: Option<i64>,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[derive(Debug, Clone, Default)]
|
|
39
|
+
pub struct Run {
|
|
40
|
+
pub text: String,
|
|
41
|
+
pub bold: bool,
|
|
42
|
+
pub italic: bool,
|
|
43
|
+
pub underline: bool,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[derive(Debug, Clone, Default)]
|
|
47
|
+
pub struct Table {
|
|
48
|
+
pub rows: Vec<TableRow>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[derive(Debug, Clone, Default)]
|
|
52
|
+
pub struct TableRow {
|
|
53
|
+
pub cells: Vec<TableCell>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Debug, Clone, Default)]
|
|
57
|
+
pub struct TableCell {
|
|
58
|
+
pub paragraphs: Vec<Paragraph>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[derive(Debug, Clone)]
|
|
62
|
+
pub struct ListItem {
|
|
63
|
+
pub level: u32,
|
|
64
|
+
pub list_type: ListType,
|
|
65
|
+
pub number: Option<String>,
|
|
66
|
+
pub text: String,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
70
|
+
pub enum ListType {
|
|
71
|
+
Bullet,
|
|
72
|
+
Numbered,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[derive(Debug, Clone, Default)]
|
|
76
|
+
pub struct HeaderFooter {
|
|
77
|
+
pub paragraphs: Vec<Paragraph>,
|
|
78
|
+
pub tables: Vec<Table>,
|
|
79
|
+
pub header_type: HeaderFooterType,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[derive(Debug, Clone, Default, PartialEq)]
|
|
83
|
+
pub enum HeaderFooterType {
|
|
84
|
+
#[default]
|
|
85
|
+
Default,
|
|
86
|
+
First,
|
|
87
|
+
Even,
|
|
88
|
+
Odd,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[derive(Debug, Clone)]
|
|
92
|
+
pub struct Note {
|
|
93
|
+
pub id: String,
|
|
94
|
+
pub note_type: NoteType,
|
|
95
|
+
pub paragraphs: Vec<Paragraph>,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
99
|
+
pub enum NoteType {
|
|
100
|
+
Footnote,
|
|
101
|
+
Endnote,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Impls ---
|
|
105
|
+
|
|
106
|
+
impl Document {
|
|
107
|
+
pub fn new() -> Self {
|
|
108
|
+
Self::default()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pub fn extract_text(&self) -> String {
|
|
112
|
+
let mut text = String::new();
|
|
113
|
+
|
|
114
|
+
let mut list_index = 0;
|
|
115
|
+
for paragraph in &self.paragraphs {
|
|
116
|
+
if let (Some(_num_id), Some(_level)) = (paragraph.numbering_id, paragraph.numbering_level) {
|
|
117
|
+
let para_text = paragraph.to_text();
|
|
118
|
+
if !para_text.is_empty() {
|
|
119
|
+
text.push_str(¶_text);
|
|
120
|
+
text.push('\n');
|
|
121
|
+
}
|
|
122
|
+
list_index += 1;
|
|
123
|
+
let _ = list_index; // suppress unused warning
|
|
124
|
+
} else {
|
|
125
|
+
let para_text = paragraph.to_text();
|
|
126
|
+
if !para_text.is_empty() {
|
|
127
|
+
text.push_str(¶_text);
|
|
128
|
+
text.push('\n');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for table in &self.tables {
|
|
134
|
+
for row in &table.rows {
|
|
135
|
+
for cell in &row.cells {
|
|
136
|
+
for paragraph in &cell.paragraphs {
|
|
137
|
+
let para_text = paragraph.to_text();
|
|
138
|
+
if !para_text.is_empty() {
|
|
139
|
+
text.push_str(¶_text);
|
|
140
|
+
text.push('\t');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
text.push('\n');
|
|
145
|
+
}
|
|
146
|
+
text.push('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
text
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl Paragraph {
|
|
154
|
+
pub fn new() -> Self {
|
|
155
|
+
Self::default()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Concatenate text runs to produce paragraph text.
|
|
159
|
+
///
|
|
160
|
+
/// In DOCX, whitespace between words is stored inside `<w:t>` elements
|
|
161
|
+
/// (e.g. `<w:t>Hello </w:t><w:t>World</w:t>`), so runs are joined
|
|
162
|
+
/// directly without adding extra separators. The parser must use
|
|
163
|
+
/// `trim_text(false)` to preserve this whitespace.
|
|
164
|
+
pub fn to_text(&self) -> String {
|
|
165
|
+
let mut text = String::new();
|
|
166
|
+
for run in &self.runs {
|
|
167
|
+
text.push_str(&run.text);
|
|
168
|
+
}
|
|
169
|
+
text
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn add_run(&mut self, run: Run) {
|
|
173
|
+
self.runs.push(run);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
impl Run {
|
|
178
|
+
pub fn new(text: String) -> Self {
|
|
179
|
+
Self {
|
|
180
|
+
text,
|
|
181
|
+
..Default::default()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
impl Table {
|
|
187
|
+
pub fn new() -> Self {
|
|
188
|
+
Self::default()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
impl HeaderFooter {
|
|
193
|
+
pub fn extract_text(&self) -> String {
|
|
194
|
+
let mut text = String::new();
|
|
195
|
+
|
|
196
|
+
for paragraph in &self.paragraphs {
|
|
197
|
+
let para_text = paragraph.to_text();
|
|
198
|
+
if !para_text.is_empty() {
|
|
199
|
+
text.push_str(¶_text);
|
|
200
|
+
text.push('\n');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for table in &self.tables {
|
|
205
|
+
for row in &table.rows {
|
|
206
|
+
for cell in &row.cells {
|
|
207
|
+
for paragraph in &cell.paragraphs {
|
|
208
|
+
let para_text = paragraph.to_text();
|
|
209
|
+
if !para_text.is_empty() {
|
|
210
|
+
text.push_str(¶_text);
|
|
211
|
+
text.push('\t');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
text.push('\n');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
text
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- Parser ---
|
|
224
|
+
|
|
225
|
+
struct DocxParser<R: Read + Seek> {
|
|
226
|
+
archive: zip::ZipArchive<R>,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
impl<R: Read + Seek> DocxParser<R> {
|
|
230
|
+
fn new(reader: R) -> Result<Self, DocxParseError> {
|
|
231
|
+
let archive = zip::ZipArchive::new(reader)?;
|
|
232
|
+
Ok(Self { archive })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fn parse(mut self) -> Result<Document, DocxParseError> {
|
|
236
|
+
let mut document = Document::new();
|
|
237
|
+
|
|
238
|
+
let document_xml = self.read_file("word/document.xml")?;
|
|
239
|
+
self.parse_document_xml(&document_xml, &mut document)?;
|
|
240
|
+
|
|
241
|
+
if let Ok(numbering_xml) = self.read_file("word/numbering.xml") {
|
|
242
|
+
let numbering_defs = self.parse_numbering(&numbering_xml)?;
|
|
243
|
+
self.process_lists(&mut document, &numbering_defs);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
self.parse_headers_footers(&mut document)?;
|
|
247
|
+
|
|
248
|
+
if let Ok(footnotes_xml) = self.read_file("word/footnotes.xml") {
|
|
249
|
+
self.parse_notes(&footnotes_xml, &mut document.footnotes, NoteType::Footnote)?;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if let Ok(endnotes_xml) = self.read_file("word/endnotes.xml") {
|
|
253
|
+
self.parse_notes(&endnotes_xml, &mut document.endnotes, NoteType::Endnote)?;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
Ok(document)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn read_file(&mut self, path: &str) -> Result<String, DocxParseError> {
|
|
260
|
+
let mut file = self
|
|
261
|
+
.archive
|
|
262
|
+
.by_name(path)
|
|
263
|
+
.map_err(|_| DocxParseError::FileNotFound(path.to_string()))?;
|
|
264
|
+
|
|
265
|
+
let mut contents = String::new();
|
|
266
|
+
file.read_to_string(&mut contents)?;
|
|
267
|
+
Ok(contents)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
fn parse_document_xml(&self, xml: &str, document: &mut Document) -> Result<(), DocxParseError> {
|
|
271
|
+
let mut reader = Reader::from_str(xml);
|
|
272
|
+
reader.config_mut().trim_text(false);
|
|
273
|
+
|
|
274
|
+
let mut buf = Vec::new();
|
|
275
|
+
let mut current_paragraph: Option<Paragraph> = None;
|
|
276
|
+
let mut current_run: Option<Run> = None;
|
|
277
|
+
let mut current_table: Option<Table> = None;
|
|
278
|
+
let mut current_row: Option<TableRow> = None;
|
|
279
|
+
let mut current_cell: Option<TableCell> = None;
|
|
280
|
+
let mut in_text = false;
|
|
281
|
+
let mut in_table = false;
|
|
282
|
+
|
|
283
|
+
loop {
|
|
284
|
+
match reader.read_event_into(&mut buf) {
|
|
285
|
+
Ok(Event::Start(ref e)) => match e.name().as_ref() {
|
|
286
|
+
b"w:p" => {
|
|
287
|
+
if in_table {
|
|
288
|
+
if current_cell.is_none() {
|
|
289
|
+
current_cell = Some(TableCell::default());
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
current_paragraph = Some(Paragraph::new());
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
b"w:numPr" => {
|
|
296
|
+
if let Some(ref mut para) = current_paragraph {
|
|
297
|
+
para.numbering_id = Some(1);
|
|
298
|
+
para.numbering_level = Some(0);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
b"w:r" => {
|
|
302
|
+
current_run = Some(Run::default());
|
|
303
|
+
}
|
|
304
|
+
b"w:t" => {
|
|
305
|
+
in_text = true;
|
|
306
|
+
}
|
|
307
|
+
b"w:tbl" => {
|
|
308
|
+
in_table = true;
|
|
309
|
+
current_table = Some(Table::new());
|
|
310
|
+
}
|
|
311
|
+
b"w:tr" => {
|
|
312
|
+
current_row = Some(TableRow::default());
|
|
313
|
+
}
|
|
314
|
+
b"w:tc" => {
|
|
315
|
+
current_cell = Some(TableCell::default());
|
|
316
|
+
}
|
|
317
|
+
b"w:b" => {
|
|
318
|
+
if let Some(ref mut run) = current_run {
|
|
319
|
+
run.bold = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
b"w:i" => {
|
|
323
|
+
if let Some(ref mut run) = current_run {
|
|
324
|
+
run.italic = true;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
b"w:u" => {
|
|
328
|
+
if let Some(ref mut run) = current_run {
|
|
329
|
+
run.underline = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
_ => {}
|
|
333
|
+
},
|
|
334
|
+
Ok(Event::Text(e)) => {
|
|
335
|
+
if in_text {
|
|
336
|
+
if let Some(ref mut run) = current_run {
|
|
337
|
+
let text = e.decode()?.into_owned();
|
|
338
|
+
run.text.push_str(&text);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
Ok(Event::End(ref e)) => match e.name().as_ref() {
|
|
343
|
+
b"w:t" => {
|
|
344
|
+
in_text = false;
|
|
345
|
+
}
|
|
346
|
+
b"w:r" => {
|
|
347
|
+
if let Some(run) = current_run.take() {
|
|
348
|
+
if in_table {
|
|
349
|
+
if let Some(ref mut cell) = current_cell {
|
|
350
|
+
if cell.paragraphs.is_empty() {
|
|
351
|
+
cell.paragraphs.push(Paragraph::new());
|
|
352
|
+
}
|
|
353
|
+
if let Some(para) = cell.paragraphs.last_mut() {
|
|
354
|
+
para.add_run(run);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} else if let Some(ref mut para) = current_paragraph {
|
|
358
|
+
para.add_run(run);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
b"w:p" => {
|
|
363
|
+
if in_table {
|
|
364
|
+
// handled via cell
|
|
365
|
+
} else if let Some(para) = current_paragraph.take() {
|
|
366
|
+
document.paragraphs.push(para);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
b"w:tc" => {
|
|
370
|
+
if let Some(cell) = current_cell.take() {
|
|
371
|
+
if let Some(ref mut row) = current_row {
|
|
372
|
+
row.cells.push(cell);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
b"w:tr" => {
|
|
377
|
+
if let Some(row) = current_row.take() {
|
|
378
|
+
if let Some(ref mut table) = current_table {
|
|
379
|
+
table.rows.push(row);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
b"w:tbl" => {
|
|
384
|
+
in_table = false;
|
|
385
|
+
if let Some(table) = current_table.take() {
|
|
386
|
+
document.tables.push(table);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
_ => {}
|
|
390
|
+
},
|
|
391
|
+
Ok(Event::Eof) => break,
|
|
392
|
+
Err(e) => return Err(e.into()),
|
|
393
|
+
_ => {}
|
|
394
|
+
}
|
|
395
|
+
buf.clear();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
Ok(())
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fn parse_numbering(&self, xml: &str) -> Result<HashMap<i64, ListType>, DocxParseError> {
|
|
402
|
+
let mut numbering_defs = HashMap::new();
|
|
403
|
+
let mut reader = Reader::from_str(xml);
|
|
404
|
+
reader.config_mut().trim_text(false);
|
|
405
|
+
|
|
406
|
+
let mut buf = Vec::new();
|
|
407
|
+
let mut current_num_id: Option<i64> = None;
|
|
408
|
+
|
|
409
|
+
loop {
|
|
410
|
+
match reader.read_event_into(&mut buf) {
|
|
411
|
+
Ok(Event::Start(ref e)) => {
|
|
412
|
+
if e.name().as_ref() == b"w:num" {
|
|
413
|
+
for attr in e.attributes().flatten() {
|
|
414
|
+
if attr.key.as_ref() == b"w:numId" {
|
|
415
|
+
if let Ok(id_str) = std::str::from_utf8(&attr.value) {
|
|
416
|
+
current_num_id = id_str.parse().ok();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
Ok(Event::End(ref e)) => {
|
|
423
|
+
if e.name().as_ref() == b"w:num" {
|
|
424
|
+
if let Some(id) = current_num_id {
|
|
425
|
+
numbering_defs.insert(id, ListType::Bullet);
|
|
426
|
+
}
|
|
427
|
+
current_num_id = None;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
Ok(Event::Eof) => break,
|
|
431
|
+
_ => {}
|
|
432
|
+
}
|
|
433
|
+
buf.clear();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
Ok(numbering_defs)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
fn process_lists(&self, document: &mut Document, numbering_defs: &HashMap<i64, ListType>) {
|
|
440
|
+
for paragraph in &document.paragraphs {
|
|
441
|
+
if let (Some(num_id), Some(level)) = (paragraph.numbering_id, paragraph.numbering_level) {
|
|
442
|
+
let list_type = numbering_defs.get(&num_id).cloned().unwrap_or(ListType::Bullet);
|
|
443
|
+
|
|
444
|
+
let list_item = ListItem {
|
|
445
|
+
level: level as u32,
|
|
446
|
+
list_type,
|
|
447
|
+
number: None,
|
|
448
|
+
text: paragraph.to_text(),
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
document.lists.push(list_item);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
fn parse_headers_footers(&mut self, document: &mut Document) -> Result<(), DocxParseError> {
|
|
457
|
+
for i in 1..=3 {
|
|
458
|
+
let header_path = format!("word/header{}.xml", i);
|
|
459
|
+
if let Ok(header_xml) = self.read_file(&header_path) {
|
|
460
|
+
let mut header = HeaderFooter::default();
|
|
461
|
+
self.parse_header_footer_content(&header_xml, &mut header)?;
|
|
462
|
+
document.headers.push(header);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let footer_path = format!("word/footer{}.xml", i);
|
|
466
|
+
if let Ok(footer_xml) = self.read_file(&footer_path) {
|
|
467
|
+
let mut footer = HeaderFooter::default();
|
|
468
|
+
self.parse_header_footer_content(&footer_xml, &mut footer)?;
|
|
469
|
+
document.footers.push(footer);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
Ok(())
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
fn parse_header_footer_content(&self, xml: &str, header_footer: &mut HeaderFooter) -> Result<(), DocxParseError> {
|
|
477
|
+
let mut reader = Reader::from_str(xml);
|
|
478
|
+
reader.config_mut().trim_text(false);
|
|
479
|
+
|
|
480
|
+
let mut buf = Vec::new();
|
|
481
|
+
let mut current_paragraph: Option<Paragraph> = None;
|
|
482
|
+
let mut current_run: Option<Run> = None;
|
|
483
|
+
let mut in_text = false;
|
|
484
|
+
|
|
485
|
+
loop {
|
|
486
|
+
match reader.read_event_into(&mut buf) {
|
|
487
|
+
Ok(Event::Start(ref e)) => match e.name().as_ref() {
|
|
488
|
+
b"w:p" => current_paragraph = Some(Paragraph::new()),
|
|
489
|
+
b"w:r" => current_run = Some(Run::default()),
|
|
490
|
+
b"w:t" => in_text = true,
|
|
491
|
+
_ => {}
|
|
492
|
+
},
|
|
493
|
+
Ok(Event::Text(e)) => {
|
|
494
|
+
if in_text {
|
|
495
|
+
if let Some(ref mut run) = current_run {
|
|
496
|
+
let text = e.decode()?.into_owned();
|
|
497
|
+
run.text.push_str(&text);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
Ok(Event::End(ref e)) => match e.name().as_ref() {
|
|
502
|
+
b"w:t" => in_text = false,
|
|
503
|
+
b"w:r" => {
|
|
504
|
+
if let Some(run) = current_run.take() {
|
|
505
|
+
if let Some(ref mut para) = current_paragraph {
|
|
506
|
+
para.add_run(run);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
b"w:p" => {
|
|
511
|
+
if let Some(para) = current_paragraph.take() {
|
|
512
|
+
header_footer.paragraphs.push(para);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
_ => {}
|
|
516
|
+
},
|
|
517
|
+
Ok(Event::Eof) => break,
|
|
518
|
+
_ => {}
|
|
519
|
+
}
|
|
520
|
+
buf.clear();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
Ok(())
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
fn parse_notes(&self, xml: &str, notes: &mut Vec<Note>, note_type: NoteType) -> Result<(), DocxParseError> {
|
|
527
|
+
let mut reader = Reader::from_str(xml);
|
|
528
|
+
reader.config_mut().trim_text(false);
|
|
529
|
+
|
|
530
|
+
let mut buf = Vec::new();
|
|
531
|
+
let mut current_note: Option<Note> = None;
|
|
532
|
+
let mut current_paragraph: Option<Paragraph> = None;
|
|
533
|
+
let mut current_run: Option<Run> = None;
|
|
534
|
+
let mut in_text = false;
|
|
535
|
+
|
|
536
|
+
loop {
|
|
537
|
+
match reader.read_event_into(&mut buf) {
|
|
538
|
+
Ok(Event::Start(ref e)) => match e.name().as_ref() {
|
|
539
|
+
b"w:footnote" | b"w:endnote" => {
|
|
540
|
+
let mut id = String::new();
|
|
541
|
+
for attr in e.attributes().flatten() {
|
|
542
|
+
if attr.key.as_ref() == b"w:id" {
|
|
543
|
+
id = String::from_utf8_lossy(&attr.value).to_string();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
current_note = Some(Note {
|
|
547
|
+
id,
|
|
548
|
+
note_type: note_type.clone(),
|
|
549
|
+
paragraphs: Vec::new(),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
b"w:p" => current_paragraph = Some(Paragraph::new()),
|
|
553
|
+
b"w:r" => current_run = Some(Run::default()),
|
|
554
|
+
b"w:t" => in_text = true,
|
|
555
|
+
_ => {}
|
|
556
|
+
},
|
|
557
|
+
Ok(Event::Text(e)) => {
|
|
558
|
+
if in_text {
|
|
559
|
+
if let Some(ref mut run) = current_run {
|
|
560
|
+
let text = e.decode()?.into_owned();
|
|
561
|
+
run.text.push_str(&text);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
Ok(Event::End(ref e)) => match e.name().as_ref() {
|
|
566
|
+
b"w:t" => in_text = false,
|
|
567
|
+
b"w:r" => {
|
|
568
|
+
if let Some(run) = current_run.take() {
|
|
569
|
+
if let Some(ref mut para) = current_paragraph {
|
|
570
|
+
para.add_run(run);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
b"w:p" => {
|
|
575
|
+
if let Some(para) = current_paragraph.take() {
|
|
576
|
+
if let Some(ref mut note) = current_note {
|
|
577
|
+
note.paragraphs.push(para);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
b"w:footnote" | b"w:endnote" => {
|
|
582
|
+
if let Some(note) = current_note.take() {
|
|
583
|
+
if note.id != "-1" && note.id != "0" {
|
|
584
|
+
notes.push(note);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
_ => {}
|
|
589
|
+
},
|
|
590
|
+
Ok(Event::Eof) => break,
|
|
591
|
+
_ => {}
|
|
592
|
+
}
|
|
593
|
+
buf.clear();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
Ok(())
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- Error ---
|
|
601
|
+
|
|
602
|
+
#[derive(Debug, thiserror::Error)]
|
|
603
|
+
enum DocxParseError {
|
|
604
|
+
#[error("IO error: {0}")]
|
|
605
|
+
Io(#[from] std::io::Error),
|
|
606
|
+
|
|
607
|
+
#[error("ZIP error: {0}")]
|
|
608
|
+
Zip(#[from] zip::result::ZipError),
|
|
609
|
+
|
|
610
|
+
#[error("XML parsing error: {0}")]
|
|
611
|
+
Xml(#[from] quick_xml::Error),
|
|
612
|
+
|
|
613
|
+
#[error("Required file not found in DOCX: {0}")]
|
|
614
|
+
FileNotFound(String),
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// quick-xml's unescape returns an encoding error type
|
|
618
|
+
impl From<quick_xml::encoding::EncodingError> for DocxParseError {
|
|
619
|
+
fn from(e: quick_xml::encoding::EncodingError) -> Self {
|
|
620
|
+
DocxParseError::Xml(quick_xml::Error::Encoding(e))
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// --- Public API ---
|
|
625
|
+
|
|
626
|
+
/// Parse a DOCX document from bytes and return the structured document.
|
|
627
|
+
pub fn parse_document(bytes: &[u8]) -> crate::error::Result<Document> {
|
|
628
|
+
let cursor = Cursor::new(bytes);
|
|
629
|
+
let parser = DocxParser::new(cursor)
|
|
630
|
+
.map_err(|e| crate::error::KreuzbergError::parsing(format!("DOCX parsing failed: {}", e)))?;
|
|
631
|
+
parser
|
|
632
|
+
.parse()
|
|
633
|
+
.map_err(|e| crate::error::KreuzbergError::parsing(format!("DOCX parsing failed: {}", e)))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/// Extract text from DOCX bytes.
|
|
637
|
+
pub fn extract_text_from_bytes(bytes: &[u8]) -> crate::error::Result<String> {
|
|
638
|
+
let doc = parse_document(bytes)?;
|
|
639
|
+
Ok(doc.extract_text())
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#[cfg(test)]
|
|
643
|
+
mod tests {
|
|
644
|
+
use super::*;
|
|
645
|
+
|
|
646
|
+
/// Runs are concatenated directly; whitespace comes from the XML text content.
|
|
647
|
+
#[test]
|
|
648
|
+
fn test_paragraph_to_text_concatenates_runs() {
|
|
649
|
+
let mut para = Paragraph::new();
|
|
650
|
+
para.add_run(Run::new("Hello ".to_string()));
|
|
651
|
+
para.add_run(Run::new("World".to_string()));
|
|
652
|
+
assert_eq!(para.to_text(), "Hello World");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/// Mid-word run splits (e.g. drop caps) must not insert extra spaces.
|
|
656
|
+
#[test]
|
|
657
|
+
fn test_paragraph_to_text_mid_word_split() {
|
|
658
|
+
let mut para = Paragraph::new();
|
|
659
|
+
para.add_run(Run::new("S".to_string()));
|
|
660
|
+
para.add_run(Run::new("ermocination".to_string()));
|
|
661
|
+
assert_eq!(para.to_text(), "Sermocination");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
#[test]
|
|
665
|
+
fn test_paragraph_to_text_single_run() {
|
|
666
|
+
let mut para = Paragraph::new();
|
|
667
|
+
para.add_run(Run::new("Hello".to_string()));
|
|
668
|
+
assert_eq!(para.to_text(), "Hello");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#[test]
|
|
672
|
+
fn test_paragraph_to_text_no_runs() {
|
|
673
|
+
let para = Paragraph::new();
|
|
674
|
+
assert_eq!(para.to_text(), "");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/// Whitespace between words is stored in the run text, not added by join.
|
|
678
|
+
#[test]
|
|
679
|
+
fn test_paragraph_to_text_whitespace_in_runs() {
|
|
680
|
+
let mut para = Paragraph::new();
|
|
681
|
+
para.add_run(Run::new("The ".to_string()));
|
|
682
|
+
para.add_run(Run::new("quick ".to_string()));
|
|
683
|
+
para.add_run(Run::new("fox".to_string()));
|
|
684
|
+
assert_eq!(para.to_text(), "The quick fox");
|
|
685
|
+
}
|
|
686
|
+
}
|