1#![allow(clippy::too_many_lines)]
2use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub struct ReadOptions {
32 pub first_page: Option<usize>,
34 pub last_page: Option<usize>,
36 pub package_limits: PackageLimits,
38}
39
40pub struct OfdReader {
42 pages: Vec<OfdPage>,
43}
44
45impl OfdReader {
46 pub fn open(path: impl AsRef<std::path::Path>) -> OfdResult<Self> {
52 Self::open_with_options(path, ReadOptions::default())
53 }
54
55 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 pub fn from_bytes(data: &[u8]) -> OfdResult<Self> {
74 Self::from_seek(Cursor::new(data), ReadOptions::default())
75 }
76
77 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 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 #[must_use]
108 pub fn page_count(&self) -> usize {
109 self.pages.len()
110 }
111
112 #[must_use]
114 pub fn pages(&self) -> &[OfdPage] {
115 &self.pages
116 }
117
118 #[must_use]
120 pub fn extract_text(&self) -> Vec<String> {
121 self.pages.iter().map(page_text).collect()
122 }
123
124 #[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
157fn 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
172fn 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 Ok(doc_root
210 .strip_suffix("/Document.xml")
211 .unwrap_or(&doc_root)
212 .to_string())
213}
214
215fn 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
325fn 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
461struct 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
646fn 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#[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}