Skip to main content

easyofd_reader/
lib.rs

1#![allow(clippy::too_many_lines)]
2//! # easyofd-reader
3//!
4//! OFD file reader that parses GB/T 33190-2016 compliant ZIP archives.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! input.ofd (ZIP)
10//! ├── OFD.xml                    → find DocRoot
11//! └── Doc_0/
12//!     ├── Document.xml           → read page list
13//!     └── Pages/
14//!         ├── Page_0.xml         → parse content
15//!         └── Page_N.xml
16//! ```
17
18mod ofd_reader;
19mod parser;
20mod read_options;
21
22// ── ofdrw 对齐新增模块 ──
23pub mod bad_ofd_exception;
24pub mod content_extractor;
25pub mod delta_tool;
26pub mod dl_ofd_reader;
27pub mod error_path_exception;
28pub mod extractor;
29pub mod keyword;
30pub mod model;
31pub mod ofd_reader_facade;
32pub mod page_info;
33pub mod resource_locator;
34pub mod resource_manage;
35pub mod seal_ofd_reader;
36pub mod tools;
37pub mod zip_util;
38
39// ── 默认导出 ──
40pub use bad_ofd_exception::BadOfdException;
41pub use content_extractor::ContentExtractor;
42#[allow(deprecated)]
43pub use dl_ofd_reader::DlOfdReader;
44pub use error_path_exception::ErrorPathException;
45pub use keyword::{KeywordExtractor, KeywordPosition, KeywordResource, TextCodeEntry};
46#[allow(deprecated)]
47pub use model::{
48    AnnotionEntity, OfdDocumentVo, OfdPageVo, SealDataVo, StampAnnotVo, TemplatePageEntity,
49    TemplateZOrder,
50};
51pub use ofd_reader::OfdReader;
52pub use ofd_reader_facade::OfdReaderFacade;
53pub use page_info::PageInfo;
54pub use read_options::ReadOptions;
55pub use resource_locator::ResourceLocator;
56pub use resource_manage::ResourceManage;
57#[allow(deprecated)]
58pub use seal_ofd_reader::SealOfdReader;
59
60// ── ofdrw Java 类名别名(snake_case → PascalCase 对齐) ──
61
62/// 对应 Java: `org.ofdrw.reader.OFDReader`
63///
64/// Java 原始类名为全大写 `OFDReader`,Rust 版使用 [`OfdReader`]。
65/// 此别名保持与 Java API 的名称兼容。
66pub type OFDReader = OfdReader;
67
68/// 对应 Java: `org.ofdrw.reader.BadOFDException`
69///
70/// Java 原始类名为 `BadOFDException`,Rust 版使用 [`BadOfdException`]。
71pub type BadOFDException = BadOfdException;
72
73/// 对应 Java: `org.ofdrw.reader.DLOFDReader`
74///
75/// Java 原始类名为 `DLOFDReader`,Rust 版使用 [`DlOfdReader`]。
76#[allow(deprecated)]
77pub type DLOFDReader = DlOfdReader;
78
79/// 对应 Java: `org.ofdrw.reader.SealOFDReader`
80///
81/// Java 原始类名为 `SealOFDReader`,Rust 版使用 [`SealOfdReader`]。
82#[allow(deprecated)]
83pub type SealOFDReader = SealOfdReader;
84
85/// 对应 Java: `org.ofdrw.reader.model.OFDDocumentVo`
86///
87/// Java 原始类名为 `OFDDocumentVo`,Rust 版使用 [`OfdDocumentVo`]。
88#[allow(deprecated)]
89pub type OFDDocumentVo = OfdDocumentVo;
90
91/// 对应 Java: `org.ofdrw.reader.model.OFDPageVo`
92///
93/// Java 原始类名为 `OFDPageVo`,Rust 版使用 [`OfdPageVo`]。
94#[allow(deprecated)]
95pub type OFDPageVo = OfdPageVo;
96
97/// 对应 Java: `org.ofdrw.reader.tools.NameSpaceModifier`
98///
99/// Java 原始类名为 `NameSpaceModifier`(驼峰含大写 S),
100/// Rust 版使用 [`tools::namespace_modifier::NamespaceModifier`]。
101#[allow(deprecated)]
102pub type NameSpaceModifier = tools::namespace_modifier::NamespaceModifier;
103
104/// 对应 Java: `org.ofdrw.reader.tools.NameSpaceCleaner`
105///
106/// Java 原始类名为 `NameSpaceCleaner`(驼峰含大写 S),
107/// Rust 版使用 [`tools::namespace_cleaner::NamespaceCleaner`]。
108pub type NameSpaceCleaner = tools::namespace_cleaner::NamespaceCleaner;
109
110/// 对应 Java: `org.ofdrw.reader.ZipUtil`
111///
112/// 工具类以模块级函数形式实现,见 [`zip_util`] 模块。
113pub use zip_util as ZipUtil;
114
115/// 对应 Java: `org.ofdrw.reader.DeltaTool`
116///
117/// 工具类以模块级函数形式实现,见 [`delta_tool`] 模块。
118pub use delta_tool as DeltaTool;
119
120/// 对应 Java: `org.ofdrw.reader.tools.ImageUtils`
121///
122/// 工具类以模块级函数形式实现,见 [`tools::image_utils`] 模块。
123pub use tools::image_utils as ImageUtils;
124
125/// 对应 Java: `org.ofdrw.reader.tools.NameSpaceModifier`(命名空间常量)
126///
127/// OFD 标准命名空间 URI: `http://www.ofdspec.org/2016`。
128pub use tools::namespace_modifier::OFD_NAMESPACE;
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use easyofd_core::{ImageObject, PathObject, TextObject};
134    use easyofd_writer::OfdWriter;
135
136    use easyofd_core::OfdPage;
137
138    fn roundtrip(pages: Vec<OfdPage>) -> Vec<u8> {
139        let mut writer = OfdWriter::new();
140        for page in pages {
141            writer.add_page(page);
142        }
143        writer.build().unwrap()
144    }
145
146    #[test]
147    fn test_empty_document() {
148        let bytes = OfdWriter::new().build().unwrap();
149        let reader = OfdReader::from_bytes(&bytes).unwrap();
150        assert_eq!(reader.page_count(), 0);
151    }
152
153    #[test]
154    fn test_single_text_page() {
155        let mut page = OfdPage::new(210.0, 297.0);
156        page.add_text(TextObject::new(20.0, 30.0, "Hello OFD Reader!"));
157        let bytes = roundtrip(vec![page]);
158
159        let reader = OfdReader::from_bytes(&bytes).unwrap();
160        assert_eq!(reader.page_count(), 1);
161        assert_eq!(reader.pages()[0].content.len(), 1);
162
163        let text = reader.extract_text();
164        assert_eq!(text.len(), 1);
165        assert!(text[0].contains("Hello OFD Reader!"));
166    }
167
168    #[test]
169    fn test_multiple_pages() {
170        let mut pages = Vec::new();
171        for i in 1..=3 {
172            let mut page = OfdPage::new(210.0, 297.0);
173            page.add_text(TextObject::new(10.0, 20.0, format!("Page {i} text")));
174            pages.push(page);
175        }
176        let bytes = roundtrip(pages);
177
178        let reader = OfdReader::from_bytes(&bytes).unwrap();
179        assert_eq!(reader.page_count(), 3);
180        let text = reader.extract_text();
181        assert_eq!(text.len(), 3);
182        assert!(text[0].contains("Page 1"));
183        assert!(text[2].contains("Page 3"));
184    }
185
186    #[test]
187    fn test_extract_all_text() {
188        let mut p1 = OfdPage::new(210.0, 297.0);
189        p1.add_text(TextObject::new(10.0, 20.0, "First"));
190        let mut p2 = OfdPage::new(210.0, 297.0);
191        p2.add_text(TextObject::new(10.0, 20.0, "Second"));
192        let bytes = roundtrip(vec![p1, p2]);
193
194        let reader = OfdReader::from_bytes(&bytes).unwrap();
195        let all = reader.extract_all_text();
196        assert!(all.contains("First"));
197        assert!(all.contains("Second"));
198        assert!(all.contains("---"));
199    }
200
201    #[test]
202    fn test_text_and_image() {
203        let mut page = OfdPage::new(210.0, 297.0);
204        page.add_text(TextObject::new(20.0, 30.0, "Invoice"));
205        page.add_image(ImageObject::jpeg(150.0, 30.0, 30.0, 30.0, vec![0xFF, 0xD8]));
206        let bytes = roundtrip(vec![page]);
207
208        let reader = OfdReader::from_bytes(&bytes).unwrap();
209        assert_eq!(reader.pages()[0].content.len(), 2);
210        let easyofd_core::ContentObject::Image(image) = &reader.pages()[0].content[1] else {
211            panic!("expected image");
212        };
213        assert_eq!(image.data, vec![0xFF, 0xD8]);
214    }
215
216    #[test]
217    fn test_visit_selected_pages_without_collecting() {
218        let mut pages = Vec::new();
219        for number in 1..=4 {
220            let mut page = OfdPage::new(210.0, 297.0);
221            page.add_text(TextObject::new(10.0, 10.0, format!("page {number}")));
222            pages.push(page);
223        }
224        let bytes = roundtrip(pages);
225        let path = std::env::temp_dir().join("easyofd_visit_pages.ofd");
226        std::fs::write(&path, bytes).unwrap();
227        let mut visited = Vec::new();
228        let count = OfdReader::visit_path(
229            &path,
230            ReadOptions {
231                first_page: Some(2),
232                last_page: Some(3),
233                ..ReadOptions::default()
234            },
235            |number, page| {
236                visited.push((number, ofd_reader::page_text(&page)));
237                Ok(())
238            },
239        )
240        .unwrap();
241        assert_eq!(count, 2);
242        assert_eq!(visited[0], (2, "page 2".to_string()));
243        assert_eq!(visited[1], (3, "page 3".to_string()));
244        let _ = std::fs::remove_file(path);
245    }
246
247    #[test]
248    fn test_path_roundtrip_is_not_silently_lost() {
249        let mut page = OfdPage::new(210.0, 297.0);
250        page.add_path(PathObject::hline(10.0, 20.0, 50.0));
251        let bytes = roundtrip(vec![page]);
252        let reader = OfdReader::from_bytes(&bytes).unwrap();
253        assert!(matches!(
254            reader.pages()[0].content[0],
255            easyofd_core::ContentObject::Path(_)
256        ));
257    }
258
259    #[test]
260    fn test_from_file() {
261        let dir = std::env::temp_dir().join("easyofd_reader");
262        std::fs::create_dir_all(&dir).unwrap();
263        let path = dir.join("test.ofd");
264
265        let mut page = OfdPage::new(210.0, 297.0);
266        page.add_text(TextObject::new(10.0, 20.0, "File test"));
267        let mut w = OfdWriter::new();
268        w.add_page(page);
269        w.build_to_file(&path).unwrap();
270
271        let reader = OfdReader::open(&path).unwrap();
272        assert_eq!(reader.page_count(), 1);
273        assert!(reader.extract_all_text().contains("File test"));
274        let _ = std::fs::remove_file(&path);
275    }
276
277    #[test]
278    fn test_invalid_data() {
279        assert!(OfdReader::from_bytes(b"not a zip file").is_err());
280    }
281
282    #[test]
283    fn test_styled_text() {
284        let mut page = OfdPage::new(210.0, 297.0);
285        page.add_text(
286            TextObject::new(10.0, 20.0, "Styled")
287                .font("SimHei")
288                .size(18.0)
289                .bold(),
290        );
291        let bytes = roundtrip(vec![page]);
292        let reader = OfdReader::from_bytes(&bytes).unwrap();
293        assert_eq!(reader.page_count(), 1);
294        assert!(reader.extract_all_text().contains("Styled"));
295    }
296
297    // ─── BaseLoc resource resolution tests ─────────────────────────────────
298
299    /// Build a minimal OFD ZIP whose `Doc_0/DocumentRes.xml` is the given
300    /// XML string.  Returns the raw bytes of the ZIP archive.
301    fn build_zip_with_document_res(document_res_xml: &str) -> Vec<u8> {
302        use std::io::Write;
303
304        let mut buf = std::io::Cursor::new(Vec::new());
305        {
306            let mut zip = zip::ZipWriter::new(&mut buf);
307            let options = zip::write::SimpleFileOptions::default()
308                .compression_method(zip::CompressionMethod::Stored);
309
310            // OFD.xml
311            zip.start_file("OFD.xml", options).unwrap();
312            zip.write_all(
313                br#"<?xml version="1.0" encoding="UTF-8"?>
314<ofd:OFD xmlns:ofd="http://www.ofdspec.org/2016">
315  <ofd:DocBody><ofd:DocRoot>Doc_0/Document.xml</ofd:DocRoot></ofd:DocBody>
316</ofd:OFD>"#,
317            )
318            .unwrap();
319
320            // Doc_0/Document.xml (single empty page)
321            zip.start_file("Doc_0/Document.xml", options).unwrap();
322            zip.write_all(
323                br#"<?xml version="1.0" encoding="UTF-8"?>
324<ofd:Document xmlns:ofd="http://www.ofdspec.org/2016">
325  <ofd:CommonData>
326    <ofd:PageArea><ofd:PhysicalBox>0 0 210 297</ofd:PhysicalBox></ofd:PageArea>
327    <ofd:DocumentRes>DocumentRes.xml</ofd:DocumentRes>
328  </ofd:CommonData>
329  <ofd:Pages><ofd:Page BaseLoc="Pages/Page_0/Content.xml"/></ofd:Pages>
330</ofd:Document>"#,
331            )
332            .unwrap();
333
334            // Doc_0/Pages/Page_0/Content.xml (empty page)
335            zip.start_file("Doc_0/Pages/Page_0/Content.xml", options)
336                .unwrap();
337            zip.write_all(
338                br#"<?xml version="1.0" encoding="UTF-8"?>
339<ofd:Page xmlns:ofd="http://www.ofdspec.org/2016">
340  <ofd:Content/>
341</ofd:Page>"#,
342            )
343            .unwrap();
344
345            // Doc_0/DocumentRes.xml (caller-supplied content)
346            zip.start_file("Doc_0/DocumentRes.xml", options).unwrap();
347            zip.write_all(document_res_xml.as_bytes()).unwrap();
348
349            zip.finish().unwrap();
350        }
351        buf.into_inner()
352    }
353
354    /// Helper: parse the DocumentRes.xml inside a test ZIP and return
355    /// the `ResourceEntry` map keyed by resource ID.
356    fn parse_resources_from_zip(bytes: &[u8]) -> std::collections::HashMap<String, String> {
357        use parser::parse_document_resources;
358
359        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
360        let resources =
361            parse_document_resources(&mut archive, "Doc_0", Some("DocumentRes.xml")).unwrap();
362        resources
363            .into_iter()
364            .map(|(id, entry)| (id, entry.location))
365            .collect()
366    }
367
368    /// 对应 Java: ofdrw/ofdrw-parser/ResourceParser#parseBaseLoc
369    ///
370    /// When `<ofd:Res BaseLoc="Res">` is present, MediaFile paths must be
371    /// resolved relative to the `Res/` subdirectory.
372    #[test]
373    fn parse_document_resources_respects_base_loc() {
374        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
375<ofd:Res xmlns:ofd="http://www.ofdspec.org/2016" BaseLoc="Res">
376  <ofd:MultiMedias>
377    <ofd:MultiMedia ID="6" Type="Image" Format="PNG">
378      <ofd:MediaFile>qrcode.png</ofd:MediaFile>
379    </ofd:MultiMedia>
380  </ofd:MultiMedias>
381</ofd:Res>"#;
382        let bytes = build_zip_with_document_res(xml);
383        let resources = parse_resources_from_zip(&bytes);
384
385        assert_eq!(
386            resources.get("6").map(String::as_str),
387            Some("Res/qrcode.png"),
388            "MediaFile path must include BaseLoc prefix"
389        );
390    }
391
392    /// When there is no `BaseLoc` attribute, paths are stored as-is
393    /// (backward-compatible with the pre-fix behaviour).
394    #[test]
395    fn parse_document_resources_no_base_loc_uses_default() {
396        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
397<ofd:Res xmlns:ofd="http://www.ofdspec.org/2016">
398  <ofd:MultiMedias>
399    <ofd:MultiMedia ID="10" Type="Image" Format="JPEG">
400      <ofd:MediaFile>photo.jpg</ofd:MediaFile>
401    </ofd:MultiMedia>
402  </ofd:MultiMedias>
403</ofd:Res>"#;
404        let bytes = build_zip_with_document_res(xml);
405        let resources = parse_resources_from_zip(&bytes);
406
407        assert_eq!(
408            resources.get("10").map(String::as_str),
409            Some("photo.jpg"),
410            "without BaseLoc, raw MediaFile path is kept"
411        );
412    }
413
414    /// Nested `<ofd:Res>` elements must each maintain their own BaseLoc
415    /// on the stack.  Resources inside the inner Res use the inner
416    /// BaseLoc; resources after the inner Res close tag use the outer.
417    #[test]
418    fn parse_document_resources_nested_res() {
419        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
420<ofd:Res xmlns:ofd="http://www.ofdspec.org/2016" BaseLoc="Outer">
421  <ofd:MultiMedias>
422    <ofd:MultiMedia ID="1" Type="Image">
423      <ofd:MediaFile>outer.png</ofd:MediaFile>
424    </ofd:MultiMedia>
425  </ofd:MultiMedias>
426  <ofd:Res BaseLoc="Inner">
427    <ofd:MultiMedias>
428      <ofd:MultiMedia ID="2" Type="Image">
429        <ofd:MediaFile>inner.png</ofd:MediaFile>
430      </ofd:MultiMedia>
431    </ofd:MultiMedias>
432  </ofd:Res>
433  <ofd:MultiMedias>
434    <ofd:MultiMedia ID="3" Type="Image">
435      <ofd:MediaFile>back_outer.png</ofd:MediaFile>
436    </ofd:MultiMedia>
437  </ofd:MultiMedias>
438</ofd:Res>"#;
439        let bytes = build_zip_with_document_res(xml);
440        let resources = parse_resources_from_zip(&bytes);
441
442        assert_eq!(
443            resources.get("1").map(String::as_str),
444            Some("Outer/outer.png"),
445            "outer resource uses outer BaseLoc"
446        );
447        assert_eq!(
448            resources.get("2").map(String::as_str),
449            Some("Inner/inner.png"),
450            "inner resource uses inner BaseLoc"
451        );
452        assert_eq!(
453            resources.get("3").map(String::as_str),
454            Some("Outer/back_outer.png"),
455            "resource after inner Res closes uses outer BaseLoc again"
456        );
457    }
458
459    // ─── Java 名称别名测试 ─────────────────────────────────────────────────
460
461    #[test]
462    fn test_ofd_reader_alias() {
463        let bytes = OfdWriter::new().build().unwrap();
464        // 通过 Java 风格别名 OFDReader 访问
465        let reader = OFDReader::from_bytes(&bytes).unwrap();
466        assert_eq!(reader.page_count(), 0);
467    }
468
469    #[test]
470    fn test_bad_ofd_exception_alias() {
471        let e = BadOFDException::corrupted("test alias");
472        assert!(e.to_string().contains("test alias"));
473    }
474
475    #[test]
476    #[allow(deprecated)]
477    fn test_dl_ofd_reader_alias() {
478        let bytes = OfdWriter::new().build().unwrap();
479        let reader = DLOFDReader::from_bytes(&bytes).unwrap();
480        assert_eq!(reader.page_count(), 0);
481    }
482
483    #[test]
484    #[allow(deprecated)]
485    fn test_seal_ofd_reader_alias() {
486        let bytes = OfdWriter::new().build().unwrap();
487        let reader = SealOFDReader::from_bytes(&bytes).unwrap();
488        assert_eq!(reader.page_count(), 0);
489    }
490
491    #[test]
492    #[allow(deprecated)]
493    fn test_ofd_document_vo_alias() {
494        let vo = OFDDocumentVo::new("Doc_0", 210.0, 297.0, vec![]);
495        assert_eq!(vo.doc_path, "Doc_0");
496    }
497
498    #[test]
499    fn test_namespace_modifier_alias() {
500        #[allow(deprecated)]
501        let modifier = NameSpaceModifier::new();
502        assert_eq!(modifier.expected_namespace(), OFD_NAMESPACE);
503    }
504
505    #[test]
506    fn test_namespace_cleaner_alias() {
507        let xml = "<ofd:OFD><ofd:DocBody/></ofd:OFD>";
508        let result = NameSpaceCleaner::remove_ofd_prefix(xml);
509        assert!(!result.contains("ofd:"));
510    }
511
512    #[test]
513    fn test_zip_util_module_alias() {
514        // ZipUtil 是 zip_util 模块的别名
515        let data = {
516            let mut buf = std::io::Cursor::new(Vec::new());
517            {
518                let mut zip = zip::ZipWriter::new(&mut buf);
519                let options = zip::write::SimpleFileOptions::default()
520                    .compression_method(zip::CompressionMethod::Stored);
521                zip.start_file("test.txt", options).unwrap();
522                std::io::Write::write_all(&mut zip, b"hello").unwrap();
523                zip.finish().unwrap();
524            }
525            buf.into_inner()
526        };
527        let reader = std::io::Cursor::new(&data);
528        let mut archive = zip::ZipArchive::new(reader).unwrap();
529        let entries = ZipUtil::list_entries(&mut archive).unwrap();
530        assert_eq!(entries.len(), 1);
531    }
532
533    #[test]
534    fn test_delta_tool_module_alias() {
535        let delta = vec!["1.0".into(), "2.0".into()];
536        let result = DeltaTool::get_delta(Some(&delta), 2);
537        assert_eq!(result.len(), 2);
538    }
539
540    #[test]
541    fn test_image_utils_module_alias() {
542        assert_eq!(
543            ImageUtils::detect_format(&[0x89, 0x50, 0x4E, 0x47]),
544            ImageUtils::ImageFormat::Png
545        );
546    }
547
548    #[test]
549    fn test_ofd_namespace_constant() {
550        assert_eq!(OFD_NAMESPACE, "http://www.ofdspec.org/2016");
551    }
552}