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
18use std::collections::HashMap;
19use std::fs::File;
20use std::io::{BufReader, Cursor, Read, Seek};
21
22use easyofd_core::{
23    ContentObject, ImageFormat, ImageObject, OfdError, OfdPage, OfdResult, PathObject, TextObject,
24};
25use easyofd_package::{PackageLimits, validate_archive};
26use quick_xml::Reader as XmlReader;
27use quick_xml::events::Event;
28
29/// OFD 读取选项。
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub struct ReadOptions {
32    /// 第一个读取页码,使用从 1 开始的页码。
33    pub first_page: Option<usize>,
34    /// 最后一个读取页码,使用从 1 开始的页码。
35    pub last_page: Option<usize>,
36    /// ZIP 包安全限制。
37    pub package_limits: PackageLimits,
38}
39
40/// An OFD document reader.
41pub struct OfdReader {
42    pages: Vec<OfdPage>,
43}
44
45impl OfdReader {
46    /// Open and parse an OFD file from a path.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the file cannot be read or contains invalid OFD data.
51    pub fn open(path: impl AsRef<std::path::Path>) -> OfdResult<Self> {
52        Self::open_with_options(path, ReadOptions::default())
53    }
54
55    /// 使用指定选项打开 OFD 文件。
56    ///
57    /// # Errors
58    ///
59    /// 文件、ZIP 包或 XML 无效时返回错误。
60    pub fn open_with_options(
61        path: impl AsRef<std::path::Path>,
62        options: ReadOptions,
63    ) -> OfdResult<Self> {
64        let file = File::open(path)?;
65        Self::from_seek(file, options)
66    }
67
68    /// Parse an OFD file from in-memory bytes.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the data is invalid.
73    pub fn from_bytes(data: &[u8]) -> OfdResult<Self> {
74        Self::from_seek(Cursor::new(data), ReadOptions::default())
75    }
76
77    /// 从实现 `Read + Seek` 的输入读取文档。
78    ///
79    /// # Errors
80    ///
81    /// ZIP 包或 XML 无效时返回错误。
82    pub fn from_seek<R: Read + Seek>(source: R, options: ReadOptions) -> OfdResult<Self> {
83        let mut pages = Vec::new();
84        visit_archive(source, options, |_, page| {
85            pages.push(page);
86            Ok(())
87        })?;
88        Ok(Self { pages })
89    }
90
91    /// 逐页访问文件,不在内存中保留已经处理过的页面。
92    ///
93    /// 回调页码从 1 开始。回调返回错误时立即停止解析。
94    ///
95    /// # Errors
96    ///
97    /// 文件、ZIP、XML 或页面回调失败时返回错误。
98    pub fn visit_path(
99        path: impl AsRef<std::path::Path>,
100        options: ReadOptions,
101        visitor: impl FnMut(usize, OfdPage) -> OfdResult<()>,
102    ) -> OfdResult<usize> {
103        visit_archive(File::open(path)?, options, visitor)
104    }
105
106    /// Number of pages in the document.
107    #[must_use]
108    pub fn page_count(&self) -> usize {
109        self.pages.len()
110    }
111
112    /// All parsed pages.
113    #[must_use]
114    pub fn pages(&self) -> &[OfdPage] {
115        &self.pages
116    }
117
118    /// Extract text from all pages, one `String` per page.
119    #[must_use]
120    pub fn extract_text(&self) -> Vec<String> {
121        self.pages.iter().map(page_text).collect()
122    }
123
124    /// Extract all text joined into a single string with page separators.
125    #[must_use]
126    pub fn extract_all_text(&self) -> String {
127        self.extract_text().join("\n---\n")
128    }
129}
130
131fn visit_archive<R: Read + Seek>(
132    source: R,
133    options: ReadOptions,
134    mut visitor: impl FnMut(usize, OfdPage) -> OfdResult<()>,
135) -> OfdResult<usize> {
136    let mut archive = zip::ZipArchive::new(source).map_err(|e| OfdError::Zip(e.to_string()))?;
137    validate_archive(&mut archive, options.package_limits)?;
138    let doc_root = parse_ofd_entry(&mut archive)?;
139    let page_refs = parse_document_entry(&mut archive, &doc_root)?;
140    let resources = parse_document_resources(&mut archive, &doc_root)?;
141    let mut visited = 0;
142    for (index, page_loc) in page_refs.iter().enumerate() {
143        let page_number = index + 1;
144        if options.first_page.is_some_and(|first| page_number < first)
145            || options.last_page.is_some_and(|last| page_number > last)
146        {
147            continue;
148        }
149        let page_path = format!("{doc_root}/{page_loc}");
150        let page = parse_page_entry(&mut archive, &page_path, &doc_root, &resources)?;
151        visitor(page_number, page)?;
152        visited += 1;
153    }
154    Ok(visited)
155}
156
157/// Join all text objects on a page into one string.
158fn page_text(page: &OfdPage) -> String {
159    page.content
160        .iter()
161        .filter_map(|obj| {
162            if let ContentObject::Text(t) = obj {
163                Some(t.text.as_str())
164            } else {
165                None
166            }
167        })
168        .collect::<Vec<_>>()
169        .join("\n")
170}
171
172// ─── XML Parsing ─────────────────────────────────────────────────────────────
173
174/// Parse OFD.xml → return the DocRoot directory (e.g. "Doc_0").
175fn parse_ofd_entry<R: Read + std::io::Seek>(archive: &mut zip::ZipArchive<R>) -> OfdResult<String> {
176    let xml = read_zip_entry(archive, "OFD.xml")?;
177    let mut reader = XmlReader::from_reader(BufReader::new(Cursor::new(&xml)));
178    reader.config_mut().trim_text(true);
179    let mut buf = Vec::new();
180    let mut doc_root = String::new();
181    let mut in_target = false;
182
183    loop {
184        match reader.read_event_into(&mut buf) {
185            Ok(Event::Start(ref e)) if e.name().as_ref() == b"ofd:DocRoot" => {
186                in_target = true;
187            }
188            Ok(Event::Text(ref e)) if in_target => {
189                doc_root = e
190                    .xml10_content()
191                    .map(|c| c.into_owned())
192                    .unwrap_or_default();
193            }
194            Ok(Event::End(ref e)) if e.name().as_ref() == b"ofd:DocRoot" => {
195                in_target = false;
196            }
197            Ok(Event::Eof) => break,
198            Err(e) => return Err(OfdError::Xml(format!("OFD.xml: {e}"))),
199            _ => {}
200        }
201        buf.clear();
202    }
203
204    if doc_root.is_empty() {
205        return Err(OfdError::InvalidDocument("missing DocRoot".into()));
206    }
207
208    // Strip "/Document.xml" suffix to get the doc directory
209    Ok(doc_root
210        .strip_suffix("/Document.xml")
211        .unwrap_or(&doc_root)
212        .to_string())
213}
214
215/// Parse Document.xml → return list of page BaseLoc paths (e.g. "Pages/Page_0.xml").
216fn parse_document_entry<R: Read + std::io::Seek>(
217    archive: &mut zip::ZipArchive<R>,
218    doc_dir: &str,
219) -> OfdResult<Vec<String>> {
220    let path = format!("{doc_dir}/Document.xml");
221    let xml = read_zip_entry(archive, &path)?;
222    let mut reader = XmlReader::from_reader(BufReader::new(Cursor::new(&xml)));
223    reader.config_mut().trim_text(true);
224    let mut buf = Vec::new();
225    let mut pages = Vec::new();
226
227    loop {
228        match reader.read_event_into(&mut buf) {
229            Ok(Event::Empty(ref e) | Event::Start(ref e)) if e.name().as_ref() == b"ofd:Page" => {
230                for attr in e.attributes().flatten() {
231                    if attr.key.as_ref() == b"BaseLoc" {
232                        let val = attr
233                            .decoded_and_normalized_value(
234                                quick_xml::XmlVersion::Explicit1_0,
235                                reader.decoder(),
236                            )
237                            .unwrap_or_default();
238                        pages.push(val.to_string());
239                    }
240                }
241            }
242            Ok(Event::Eof) => break,
243            Err(e) => return Err(OfdError::Xml(format!("Document.xml: {e}"))),
244            _ => {}
245        }
246        buf.clear();
247    }
248    Ok(pages)
249}
250
251#[derive(Debug, Clone)]
252struct ResourceEntry {
253    location: String,
254    format: ImageFormat,
255}
256
257fn parse_document_resources<R: Read + Seek>(
258    archive: &mut zip::ZipArchive<R>,
259    doc_dir: &str,
260) -> OfdResult<HashMap<String, ResourceEntry>> {
261    let path = format!("{doc_dir}/DocumentRes.xml");
262    let xml = match read_zip_entry(archive, &path) {
263        Ok(xml) => xml,
264        Err(_) => return Ok(HashMap::new()),
265    };
266    let mut reader = XmlReader::from_reader(BufReader::new(Cursor::new(&xml)));
267    reader.config_mut().trim_text(true);
268    let mut buf = Vec::new();
269    let mut current: Option<(String, ImageFormat)> = None;
270    let mut in_media_file = false;
271    let mut resources = HashMap::new();
272    loop {
273        match reader.read_event_into(&mut buf) {
274            Ok(Event::Start(ref event)) if event.name().as_ref() == b"ofd:MultiMedia" => {
275                let mut id = None;
276                let mut format = ImageFormat::Jpeg;
277                for attribute in event.attributes().flatten() {
278                    let value = attribute
279                        .decoded_and_normalized_value(
280                            quick_xml::XmlVersion::Explicit1_0,
281                            reader.decoder(),
282                        )
283                        .unwrap_or_default();
284                    match attribute.key.as_ref() {
285                        b"ID" => id = Some(value.to_string()),
286                        b"Type" => format = parse_image_format(&value),
287                        _ => {}
288                    }
289                }
290                current = id.map(|id| (id, format));
291            }
292            Ok(Event::Start(ref event)) if event.name().as_ref() == b"ofd:MediaFile" => {
293                in_media_file = true;
294            }
295            Ok(Event::Text(ref event)) if in_media_file => {
296                if let Some((id, format)) = current.take() {
297                    let location = event
298                        .xml10_content()
299                        .map(|value| value.into_owned())
300                        .unwrap_or_default();
301                    resources.insert(id, ResourceEntry { location, format });
302                }
303            }
304            Ok(Event::End(ref event)) if event.name().as_ref() == b"ofd:MediaFile" => {
305                in_media_file = false;
306            }
307            Ok(Event::Eof) => break,
308            Err(error) => return Err(OfdError::Xml(format!("{path}: {error}"))),
309            _ => {}
310        }
311        buf.clear();
312    }
313    Ok(resources)
314}
315
316fn parse_image_format(value: &str) -> ImageFormat {
317    match value.to_ascii_uppercase().as_str() {
318        "PNG" => ImageFormat::Png,
319        "BMP" => ImageFormat::Bmp,
320        "TIFF" | "TIF" => ImageFormat::Tiff,
321        _ => ImageFormat::Jpeg,
322    }
323}
324
325/// Parse Page_N.xml → return `OfdPage` with dimensions and content objects.
326fn parse_page_entry<R: Read + std::io::Seek>(
327    archive: &mut zip::ZipArchive<R>,
328    page_path: &str,
329    doc_dir: &str,
330    resources: &HashMap<String, ResourceEntry>,
331) -> OfdResult<OfdPage> {
332    let xml = read_zip_entry(archive, page_path)?;
333    let mut reader = XmlReader::from_reader(BufReader::new(Cursor::new(&xml)));
334    reader.config_mut().trim_text(true);
335    let mut buf = Vec::new();
336
337    let mut width = 210.0_f64;
338    let mut height = 297.0_f64;
339    let mut content = Vec::new();
340
341    let mut current_text: Option<TextObjectBuilder> = None;
342    let mut current_path: Option<PathObjectBuilder> = None;
343    let mut in_text_code = false;
344    let mut in_path_data = false;
345    let mut in_physical_box = false;
346
347    loop {
348        match reader.read_event_into(&mut buf) {
349            Ok(Event::Start(ref e) | Event::Empty(ref e)) => match e.name().as_ref() {
350                b"ofd:PhysicalBox" => in_physical_box = true,
351                b"ofd:TextObject" => {
352                    current_text = Some(parse_text_object_attrs(e, reader.decoder())?)
353                }
354                b"ofd:TextCode" => in_text_code = true,
355                b"ofd:PathObject" => {
356                    current_path = Some(parse_path_object_attrs(e, reader.decoder())?)
357                }
358                b"ofd:AbbreviatedData" => in_path_data = true,
359                b"ofd:ImageObject" => {
360                    let img = parse_image_object_attrs(e, reader.decoder())?;
361                    let (data, format) = if let Some(resource) = resources.get(&img.resource_id) {
362                        let resource_path = resolve_resource_path(doc_dir, &resource.location)?;
363                        (read_zip_entry(archive, &resource_path)?, resource.format)
364                    } else {
365                        (Vec::new(), img.format)
366                    };
367                    content.push(ContentObject::Image(ImageObject::new(
368                        img.x, img.y, img.width, img.height, data, format,
369                    )));
370                }
371                _ => {}
372            },
373            Ok(Event::Text(ref e)) => {
374                let text = e
375                    .xml10_content()
376                    .map(|c| c.into_owned())
377                    .unwrap_or_default();
378                if in_physical_box {
379                    let parts: Vec<f64> = text
380                        .split_whitespace()
381                        .filter_map(|s| s.parse().ok())
382                        .collect();
383                    if parts.len() >= 4 {
384                        width = parts[2];
385                        height = parts[3];
386                    }
387                }
388                if in_text_code {
389                    if let Some(ref mut t) = current_text {
390                        t.text.push_str(&text);
391                    }
392                } else if in_path_data {
393                    if let Some(ref mut path) = current_path {
394                        path.path_data.push_str(&text);
395                    }
396                }
397            }
398            Ok(Event::GeneralRef(ref reference)) => {
399                let name = reference
400                    .xml10_content()
401                    .map(|value| value.into_owned())
402                    .unwrap_or_default();
403                let value = resolve_xml_reference(&name).ok_or_else(|| {
404                    OfdError::Xml(format!("{page_path}: unresolved entity &{name};"))
405                })?;
406                if in_text_code {
407                    if let Some(ref mut text) = current_text {
408                        text.text.push(value);
409                    }
410                } else if in_path_data {
411                    if let Some(ref mut path) = current_path {
412                        path.path_data.push(value);
413                    }
414                }
415            }
416            Ok(Event::End(ref e)) => match e.name().as_ref() {
417                b"ofd:PhysicalBox" => in_physical_box = false,
418                b"ofd:TextObject" => {
419                    if let Some(t) = current_text.take() {
420                        let mut obj = TextObject::new(t.x, t.y, t.text);
421                        if let Some(f) = t.font {
422                            obj = obj.font(f);
423                        }
424                        if let Some(s) = t.size {
425                            obj = obj.size(s);
426                        }
427                        obj.width = t.width;
428                        obj.height = t.height;
429                        content.push(ContentObject::Text(obj));
430                    }
431                }
432                b"ofd:TextCode" => in_text_code = false,
433                b"ofd:PathObject" => {
434                    if let Some(path) = current_path.take() {
435                        let mut object = PathObject::new(path.x, path.y, path.path_data)
436                            .stroke_color(path.stroke_color)
437                            .stroke_width(path.stroke_width);
438                        if let Some(fill_color) = path.fill_color {
439                            object = object.fill_color(fill_color);
440                        }
441                        content.push(ContentObject::Path(object));
442                    }
443                }
444                b"ofd:AbbreviatedData" => in_path_data = false,
445                _ => {}
446            },
447            Ok(Event::Eof) => break,
448            Err(e) => return Err(OfdError::Xml(format!("{page_path}: {e}"))),
449            _ => {}
450        }
451        buf.clear();
452    }
453
454    Ok(OfdPage {
455        width,
456        height,
457        content,
458    })
459}
460
461// ─── Attribute Parsing Helpers ───────────────────────────────────────────────
462
463struct TextObjectBuilder {
464    x: f64,
465    y: f64,
466    text: String,
467    font: Option<String>,
468    size: Option<f64>,
469    width: Option<f64>,
470    height: Option<f64>,
471}
472
473fn parse_text_object_attrs(
474    e: &quick_xml::events::BytesStart,
475    decoder: quick_xml::encoding::Decoder,
476) -> OfdResult<TextObjectBuilder> {
477    let mut x = 0.0_f64;
478    let mut y = 0.0_f64;
479    let mut font = None;
480    let mut size = None;
481    let mut width = None;
482    let mut height = None;
483
484    for attr in e.attributes().flatten() {
485        match attr.key.as_ref() {
486            b"Boundary" => {
487                let parts: Vec<f64> = attr
488                    .decoded_and_normalized_value(quick_xml::XmlVersion::Explicit1_0, decoder)
489                    .unwrap_or_default()
490                    .split_whitespace()
491                    .filter_map(|s| s.parse().ok())
492                    .collect();
493                if parts.len() >= 2 {
494                    x = parts[0];
495                    y = parts[1];
496                }
497                if parts.len() >= 4 {
498                    width = Some(parts[2]);
499                    height = Some(parts[3]);
500                }
501            }
502            b"Font" => {
503                font = Some(
504                    attr.decoded_and_normalized_value(quick_xml::XmlVersion::Explicit1_0, decoder)
505                        .unwrap_or_default()
506                        .to_string(),
507                );
508            }
509            b"Size" => {
510                size = attr
511                    .decoded_and_normalized_value(quick_xml::XmlVersion::Explicit1_0, decoder)
512                    .unwrap_or_default()
513                    .parse()
514                    .ok();
515            }
516            _ => {}
517        }
518    }
519
520    Ok(TextObjectBuilder {
521        x,
522        y,
523        text: String::new(),
524        font,
525        size,
526        width,
527        height,
528    })
529}
530
531struct PathObjectBuilder {
532    x: f64,
533    y: f64,
534    stroke_color: u32,
535    stroke_width: f64,
536    fill_color: Option<u32>,
537    path_data: String,
538}
539
540fn parse_path_object_attrs(
541    event: &quick_xml::events::BytesStart,
542    decoder: quick_xml::encoding::Decoder,
543) -> OfdResult<PathObjectBuilder> {
544    let mut builder = PathObjectBuilder {
545        x: 0.0,
546        y: 0.0,
547        stroke_color: 0,
548        stroke_width: 0.35,
549        fill_color: None,
550        path_data: String::new(),
551    };
552    for attribute in event.attributes().flatten() {
553        let value = attribute
554            .decoded_and_normalized_value(quick_xml::XmlVersion::Explicit1_0, decoder)
555            .unwrap_or_default();
556        match attribute.key.as_ref() {
557            b"Boundary" => {
558                let parts: Vec<f64> = value
559                    .split_whitespace()
560                    .filter_map(|part| part.parse().ok())
561                    .collect();
562                if parts.len() >= 2 {
563                    builder.x = parts[0];
564                    builder.y = parts[1];
565                }
566            }
567            b"StrokeColor" => builder.stroke_color = parse_hex_color(&value).unwrap_or(0),
568            b"FillColor" => builder.fill_color = parse_hex_color(&value),
569            b"LineWidth" => builder.stroke_width = value.parse().unwrap_or(0.35),
570            _ => {}
571        }
572    }
573    Ok(builder)
574}
575
576fn parse_hex_color(value: &str) -> Option<u32> {
577    u32::from_str_radix(value.trim_start_matches('#'), 16).ok()
578}
579
580fn resolve_xml_reference(value: &str) -> Option<char> {
581    if let Some(entity) = quick_xml::escape::resolve_xml_entity(value) {
582        return entity.chars().next();
583    }
584    let number = if let Some(hex) = value.strip_prefix("#x") {
585        u32::from_str_radix(hex, 16).ok()
586    } else if let Some(decimal) = value.strip_prefix('#') {
587        decimal.parse().ok()
588    } else {
589        None
590    }?;
591    char::from_u32(number)
592}
593
594struct ImageObjectBuilder {
595    x: f64,
596    y: f64,
597    width: f64,
598    height: f64,
599    format: ImageFormat,
600    resource_id: String,
601}
602
603#[allow(clippy::many_single_char_names)]
604fn parse_image_object_attrs(
605    e: &quick_xml::events::BytesStart,
606    decoder: quick_xml::encoding::Decoder,
607) -> OfdResult<ImageObjectBuilder> {
608    let mut x = 0.0_f64;
609    let mut y = 0.0_f64;
610    let mut w = 0.0_f64;
611    let mut h = 0.0_f64;
612    let mut resource_id = String::new();
613
614    for attr in e.attributes().flatten() {
615        if attr.key.as_ref() == b"Boundary" {
616            let parts: Vec<f64> = attr
617                .decoded_and_normalized_value(quick_xml::XmlVersion::Explicit1_0, decoder)
618                .unwrap_or_default()
619                .split_whitespace()
620                .filter_map(|s| s.parse().ok())
621                .collect();
622            if parts.len() >= 4 {
623                x = parts[0];
624                y = parts[1];
625                w = parts[2];
626                h = parts[3];
627            }
628        } else if attr.key.as_ref() == b"ResourceID" {
629            resource_id = attr
630                .decoded_and_normalized_value(quick_xml::XmlVersion::Explicit1_0, decoder)
631                .unwrap_or_default()
632                .to_string();
633        }
634    }
635
636    Ok(ImageObjectBuilder {
637        x,
638        y,
639        width: w,
640        height: h,
641        format: ImageFormat::Jpeg,
642        resource_id,
643    })
644}
645
646// ─── ZIP Helper ──────────────────────────────────────────────────────────────
647
648fn read_zip_entry<R: Read + std::io::Seek>(
649    archive: &mut zip::ZipArchive<R>,
650    name: &str,
651) -> OfdResult<Vec<u8>> {
652    let mut file = archive
653        .by_name(name)
654        .map_err(|e| OfdError::Zip(format!("{name}: {e}")))?;
655    let mut buf = Vec::new();
656    file.read_to_end(&mut buf).map_err(OfdError::Io)?;
657    Ok(buf)
658}
659
660fn resolve_resource_path(doc_dir: &str, location: &str) -> OfdResult<String> {
661    let location = location.trim_start_matches('/');
662    let path = if location.starts_with(doc_dir) {
663        location.to_string()
664    } else {
665        format!("{doc_dir}/{location}")
666    };
667    easyofd_package::validate_entry_name(&path)?;
668    Ok(path)
669}
670
671// ─── Tests ───────────────────────────────────────────────────────────────────
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use easyofd_core::OfdPage;
677    use easyofd_writer::OfdWriter;
678
679    fn roundtrip(pages: Vec<OfdPage>) -> Vec<u8> {
680        let mut writer = OfdWriter::new();
681        for page in pages {
682            writer.add_page(page);
683        }
684        writer.build().unwrap()
685    }
686
687    #[test]
688    fn test_empty_document() {
689        let bytes = OfdWriter::new().build().unwrap();
690        let reader = OfdReader::from_bytes(&bytes).unwrap();
691        assert_eq!(reader.page_count(), 0);
692    }
693
694    #[test]
695    fn test_single_text_page() {
696        let mut page = OfdPage::new(210.0, 297.0);
697        page.add_text(TextObject::new(20.0, 30.0, "Hello OFD Reader!"));
698        let bytes = roundtrip(vec![page]);
699
700        let reader = OfdReader::from_bytes(&bytes).unwrap();
701        assert_eq!(reader.page_count(), 1);
702        assert_eq!(reader.pages()[0].content.len(), 1);
703
704        let text = reader.extract_text();
705        assert_eq!(text.len(), 1);
706        assert!(text[0].contains("Hello OFD Reader!"));
707    }
708
709    #[test]
710    fn test_multiple_pages() {
711        let mut pages = Vec::new();
712        for i in 1..=3 {
713            let mut page = OfdPage::new(210.0, 297.0);
714            page.add_text(TextObject::new(10.0, 20.0, format!("Page {i} text")));
715            pages.push(page);
716        }
717        let bytes = roundtrip(pages);
718
719        let reader = OfdReader::from_bytes(&bytes).unwrap();
720        assert_eq!(reader.page_count(), 3);
721        let text = reader.extract_text();
722        assert_eq!(text.len(), 3);
723        assert!(text[0].contains("Page 1"));
724        assert!(text[2].contains("Page 3"));
725    }
726
727    #[test]
728    fn test_extract_all_text() {
729        let mut p1 = OfdPage::new(210.0, 297.0);
730        p1.add_text(TextObject::new(10.0, 20.0, "First"));
731        let mut p2 = OfdPage::new(210.0, 297.0);
732        p2.add_text(TextObject::new(10.0, 20.0, "Second"));
733        let bytes = roundtrip(vec![p1, p2]);
734
735        let reader = OfdReader::from_bytes(&bytes).unwrap();
736        let all = reader.extract_all_text();
737        assert!(all.contains("First"));
738        assert!(all.contains("Second"));
739        assert!(all.contains("---"));
740    }
741
742    #[test]
743    fn test_text_and_image() {
744        let mut page = OfdPage::new(210.0, 297.0);
745        page.add_text(TextObject::new(20.0, 30.0, "Invoice"));
746        page.add_image(ImageObject::jpeg(150.0, 30.0, 30.0, 30.0, vec![0xFF, 0xD8]));
747        let bytes = roundtrip(vec![page]);
748
749        let reader = OfdReader::from_bytes(&bytes).unwrap();
750        assert_eq!(reader.pages()[0].content.len(), 2);
751        let ContentObject::Image(image) = &reader.pages()[0].content[1] else {
752            panic!("expected image");
753        };
754        assert_eq!(image.data, vec![0xFF, 0xD8]);
755    }
756
757    #[test]
758    fn test_visit_selected_pages_without_collecting() {
759        let mut pages = Vec::new();
760        for number in 1..=4 {
761            let mut page = OfdPage::new(210.0, 297.0);
762            page.add_text(TextObject::new(10.0, 10.0, format!("page {number}")));
763            pages.push(page);
764        }
765        let bytes = roundtrip(pages);
766        let path = std::env::temp_dir().join("easyofd_visit_pages.ofd");
767        std::fs::write(&path, bytes).unwrap();
768        let mut visited = Vec::new();
769        let count = OfdReader::visit_path(
770            &path,
771            ReadOptions {
772                first_page: Some(2),
773                last_page: Some(3),
774                ..ReadOptions::default()
775            },
776            |number, page| {
777                visited.push((number, page_text(&page)));
778                Ok(())
779            },
780        )
781        .unwrap();
782        assert_eq!(count, 2);
783        assert_eq!(visited[0], (2, "page 2".to_string()));
784        assert_eq!(visited[1], (3, "page 3".to_string()));
785        let _ = std::fs::remove_file(path);
786    }
787
788    #[test]
789    fn test_path_roundtrip_is_not_silently_lost() {
790        let mut page = OfdPage::new(210.0, 297.0);
791        page.add_path(PathObject::hline(10.0, 20.0, 50.0));
792        let bytes = roundtrip(vec![page]);
793        let reader = OfdReader::from_bytes(&bytes).unwrap();
794        assert!(matches!(
795            reader.pages()[0].content[0],
796            ContentObject::Path(_)
797        ));
798    }
799
800    #[test]
801    fn test_from_file() {
802        let dir = std::env::temp_dir().join("easyofd_reader");
803        std::fs::create_dir_all(&dir).unwrap();
804        let path = dir.join("test.ofd");
805
806        let mut page = OfdPage::new(210.0, 297.0);
807        page.add_text(TextObject::new(10.0, 20.0, "File test"));
808        let mut w = OfdWriter::new();
809        w.add_page(page);
810        w.build_to_file(&path).unwrap();
811
812        let reader = OfdReader::open(&path).unwrap();
813        assert_eq!(reader.page_count(), 1);
814        assert!(reader.extract_all_text().contains("File test"));
815        let _ = std::fs::remove_file(&path);
816    }
817
818    #[test]
819    fn test_invalid_data() {
820        assert!(OfdReader::from_bytes(b"not a zip file").is_err());
821    }
822
823    #[test]
824    fn test_styled_text() {
825        let mut page = OfdPage::new(210.0, 297.0);
826        page.add_text(
827            TextObject::new(10.0, 20.0, "Styled")
828                .font("SimHei")
829                .size(18.0)
830                .bold(),
831        );
832        let bytes = roundtrip(vec![page]);
833        let reader = OfdReader::from_bytes(&bytes).unwrap();
834        assert_eq!(reader.page_count(), 1);
835        assert!(reader.extract_all_text().contains("Styled"));
836    }
837}