Skip to main content

easyofd_reader/
ofd_reader.rs

1//! OFD 文档读取器。
2
3use std::fs::File;
4use std::io::{Cursor, Read, Seek};
5
6use easyofd_core::{ContentObject, OfdError, OfdMetadata, OfdPage, OfdResult};
7use easyofd_package::validate_archive;
8
9use crate::parser::{
10    doc_path, parse_document_entry, parse_document_resources, parse_ofd_entry, parse_page_entry,
11};
12use crate::read_options::ReadOptions;
13
14/// OFD 文档读取器。
15pub struct OfdReader {
16    pages: Vec<OfdPage>,
17    metadata: OfdMetadata,
18    /// 原始 ZIP 中不由写入器重新生成的条目(模板页、注释、附件、
19    /// 签名、自定义标签等容器内容),用于无损 roundtrip。
20    raw_entries: Vec<(String, Vec<u8>)>,
21    /// 原始 OFD.xml 完整 XML 文本(roundtrip 保真用)。
22    raw_ofd_xml: Option<String>,
23    /// 原始 Document.xml 完整 XML 文本(roundtrip 保真用)。
24    raw_document_xml: Option<String>,
25}
26
27impl OfdReader {
28    /// 从文件路径打开并解析 OFD 文件。
29    ///
30    /// # 错误
31    ///
32    /// 文件无法读取或包含无效 OFD 数据时返回错误。
33    pub fn open(path: impl AsRef<std::path::Path>) -> OfdResult<Self> {
34        Self::open_with_options(path, ReadOptions::default())
35    }
36
37    /// 使用指定选项打开 OFD 文件。
38    ///
39    /// # 错误
40    ///
41    /// 文件、ZIP 包或 XML 无效时返回错误。
42    pub fn open_with_options(
43        path: impl AsRef<std::path::Path>,
44        options: ReadOptions,
45    ) -> OfdResult<Self> {
46        let file = File::open(path)?;
47        Self::from_seek(file, options)
48    }
49
50    /// 从内存字节数组解析 OFD 文件。
51    ///
52    /// # 错误
53    ///
54    /// 数据无效时返回错误。
55    pub fn from_bytes(data: &[u8]) -> OfdResult<Self> {
56        Self::from_seek(Cursor::new(data), ReadOptions::default())
57    }
58
59    /// 从实现 `Read + Seek` 的输入读取文档。
60    ///
61    /// # 错误
62    ///
63    /// ZIP 包或 XML 无效时返回错误。
64    pub fn from_seek<R: Read + Seek>(source: R, options: ReadOptions) -> OfdResult<Self> {
65        let mut pages = Vec::new();
66        let mut raw_entries = Vec::new();
67        let (metadata, raw_ofd_xml, raw_document_xml) = visit_archive(
68            source,
69            options,
70            |_, page| {
71                pages.push(page);
72                Ok(())
73            },
74            &mut raw_entries,
75        )?;
76        Ok(Self {
77            pages,
78            metadata,
79            raw_entries,
80            raw_ofd_xml,
81            raw_document_xml,
82        })
83    }
84
85    /// 逐页访问文件,不在内存中保留已经处理过的页面。
86    ///
87    /// 回调页码从 1 开始。回调返回错误时立即停止解析。
88    ///
89    /// # 错误
90    ///
91    /// 文件、ZIP、XML 或页面回调失败时返回错误。
92    pub fn visit_path(
93        path: impl AsRef<std::path::Path>,
94        options: ReadOptions,
95        mut visitor: impl FnMut(usize, OfdPage) -> OfdResult<()>,
96    ) -> OfdResult<usize> {
97        let mut count = 0usize;
98        let mut raw_entries = Vec::new();
99        let _ = visit_archive(
100            File::open(path)?,
101            options,
102            |page_number, page| {
103                count += 1;
104                visitor(page_number, page)
105            },
106            &mut raw_entries,
107        )?;
108        Ok(count)
109    }
110
111    /// 文档页数。
112    #[must_use]
113    pub fn page_count(&self) -> usize {
114        self.pages.len()
115    }
116
117    /// 所有已解析的页面。
118    #[must_use]
119    pub fn pages(&self) -> &[OfdPage] {
120        &self.pages
121    }
122
123    /// 文档元数据(从 OFD.xml 提取)。
124    #[must_use]
125    pub fn metadata(&self) -> &OfdMetadata {
126        &self.metadata
127    }
128
129    /// 原始 ZIP 中不由写入器重新生成的条目(按名字排序),可用于无损
130    /// roundtrip:读取后把这些条目原样写回。
131    ///
132    /// 排除了 `OFD.xml`、`Document.xml`、`DocumentRes.xml`、`PublicRes.xml`、
133    /// 页面内容以及写入器按自身命名规则生成的图片资源。
134    #[must_use]
135    pub fn raw_entries(&self) -> &[(String, Vec<u8>)] {
136        &self.raw_entries
137    }
138
139    /// 原始 OFD.xml 完整 XML 文本(roundtrip 保真用)。
140    ///
141    /// 传给 [`OfdWriter::set_raw_ofd_xml`] 可让 writer 原样输出 OFD.xml,
142    /// 从而保证 Version、xmlns URI、DocType、DocInfo 子元素顺序等
143    /// 全部与原始一致。
144    #[must_use]
145    pub fn raw_ofd_xml(&self) -> Option<&str> {
146        self.raw_ofd_xml.as_deref()
147    }
148
149    /// 原始 Document.xml 完整 XML 文本(roundtrip 保真用)。
150    ///
151    /// 传给 [`OfdWriter::set_raw_document_xml`] 可让 writer 原样输出
152    /// Document.xml,从而保证 CommonData 子元素顺序、Page ID、MaxUnitID、
153    /// PhysicalBox 原始文本、命名空间 URI 等全部与原始一致。
154    #[must_use]
155    pub fn raw_document_xml(&self) -> Option<&str> {
156        self.raw_document_xml.as_deref()
157    }
158
159    /// 从所有页面提取文本,每页一个 `String`。
160    #[must_use]
161    pub fn extract_text(&self) -> Vec<String> {
162        self.pages.iter().map(page_text).collect()
163    }
164
165    /// 提取所有文本并合并为单个字符串,以页面分隔符分隔。
166    #[must_use]
167    pub fn extract_all_text(&self) -> String {
168        self.extract_text().join("\n---\n")
169    }
170}
171
172fn visit_archive<R: Read + Seek>(
173    source: R,
174    options: ReadOptions,
175    mut visitor: impl FnMut(usize, OfdPage) -> OfdResult<()>,
176    raw_entries: &mut Vec<(String, Vec<u8>)>,
177) -> OfdResult<(OfdMetadata, Option<String>, Option<String>)> {
178    let mut archive = zip::ZipArchive::new(source).map_err(|e| OfdError::Zip(e.to_string()))?;
179    validate_archive(&mut archive, options.package_limits)?;
180
181    let ofd_entry = parse_ofd_entry(&mut archive)?;
182    let doc_dir = &ofd_entry.doc_dir;
183    let document_entry = parse_document_entry(&mut archive, doc_dir, &ofd_entry.document_file)?;
184    let page_refs = &document_entry.pages;
185    let resources = parse_document_resources(
186        &mut archive,
187        doc_dir,
188        document_entry.document_res.as_deref(),
189    )?;
190
191    // Collect entries that the writer will not regenerate, so a roundtrip
192    // can carry them over verbatim (template pages, annotations, attachments,
193    // signatures, custom tags and their payload files).  This runs after
194    // parsing OFD.xml so the writer-regenerated set can exclude the actual
195    // document file name (e.g. "Document_0.xml").
196    for i in 0..archive.len() {
197        let mut file = archive
198            .by_index(i)
199            .map_err(|e| OfdError::Zip(e.to_string()))?;
200        let name = file.name().to_string();
201        if !writer_regenerates(&name, doc_dir, &ofd_entry.document_file) {
202            // Clamp the preallocation hint to a sane bound on 32-bit targets.
203            let capacity = usize::try_from(file.size()).unwrap_or(usize::MAX);
204            let mut data = Vec::with_capacity(capacity);
205            std::io::Read::read_to_end(&mut file, &mut data).map_err(OfdError::Io)?;
206            raw_entries.push((name, data));
207        }
208    }
209
210    for (index, page_loc) in page_refs.iter().enumerate() {
211        let page_number = index + 1;
212        if options.first_page.is_some_and(|first| page_number < first)
213            || options.last_page.is_some_and(|last| page_number > last)
214        {
215            continue;
216        }
217        let page_path = doc_path(doc_dir, page_loc);
218        let page = parse_page_entry(&mut archive, &page_path, doc_dir, &resources)?;
219        visitor(page_number, page)?;
220    }
221    // Parse date strings into NaiveDateTime if present.  Accepts ISO formats
222    // ("2024-05-31", "2024-05-31T00:00:00") and PDF-style dates
223    // ("D:20220708103442+02'34'") that WPS-generated OFD files use.
224    // 原始文本保留:roundtrip 时 writer 优先使用 raw 原样输出,避免格式偏差。
225    let creation_date_raw = ofd_entry.creation_date.clone();
226    let mod_date_raw = ofd_entry.mod_date.clone();
227    let mod_date = ofd_entry.mod_date.as_deref().and_then(parse_ofd_date);
228    let creation_date = ofd_entry.creation_date.as_deref().and_then(parse_ofd_date);
229
230    let metadata = OfdMetadata {
231        doc_id: ofd_entry.doc_id,
232        title: ofd_entry.title,
233        author: ofd_entry.author,
234        creator: ofd_entry.creator,
235        creator_version: ofd_entry.creator_version,
236        mod_date,
237        mod_date_raw,
238        creation_date,
239        creation_date_raw,
240        max_unit_id: ofd_entry.max_unit_id,
241        bookmarks: document_entry.bookmarks,
242        outlines: document_entry.outlines,
243        custom_datas: ofd_entry.custom_datas,
244        doc_usage: ofd_entry.doc_usage,
245        keywords: ofd_entry.keywords,
246        subject: ofd_entry.subject,
247        application_box: document_entry.application_box,
248        content_box: document_entry.content_box,
249        clip_box: document_entry.clip_box,
250        bleed_box: document_entry.bleed_box,
251        trim_box: document_entry.trim_box,
252        signatures_path: ofd_entry.signatures_path,
253        template_pages: document_entry.template_pages,
254        annotations_path: document_entry.annotations_path,
255        attachments_path: document_entry.attachments_path,
256        custom_tags_path: document_entry.custom_tags_path,
257        page_area_present: document_entry.page_area_present,
258        doc_dir: ofd_entry.doc_dir.clone(),
259        document_file: ofd_entry.document_file,
260        document_res: document_entry.document_res,
261        document_res_element_present: document_entry.document_res_element_present,
262        permissions: document_entry.permissions,
263        public_res_present: {
264            let target = format!("{doc_dir}/PublicRes.xml");
265            archive.by_name(&target).is_ok()
266        },
267        public_res_element_present: document_entry.public_res_element_present,
268        ..OfdMetadata::default()
269    };
270
271    Ok((
272        metadata,
273        Some(ofd_entry.raw_xml),
274        Some(document_entry.raw_xml),
275    ))
276}
277
278/// 判断某 ZIP 条目是否由 `OfdWriter` 在写出时重新生成。
279///
280/// 这些条目在 roundtrip 时不应原样复制(否则会产生重复条目):
281/// 文档主文件(`Document.xml` 或非标准名如 `Document_0.xml`)、页面内容,
282/// 以及写入器按自身命名规则(`{doc_dir}/Res/Image_N.*`)生成的图片资源。
283///
284/// 注意:`DocumentRes.xml` 不在此列——写入器只在有图片时生成它,未引用
285/// 的残留 `DocumentRes.xml`(多文档样本中常见)应原样保留;写入器生成时
286/// 会在写出阶段按名字去重。
287fn writer_regenerates(name: &str, doc_dir: &str, document_file: &str) -> bool {
288    if name == "OFD.xml"
289        || name == format!("{doc_dir}/{document_file}")
290        || name.ends_with("/PublicRes.xml")
291        // Only page content files are regenerated; directory entries such
292        // as "Doc_0/Pages/Page_0/" must be preserved verbatim.
293        || (name.contains(&format!("/{doc_dir}/Pages/Page_"))
294            && name.ends_with("/Content.xml"))
295    {
296        return true;
297    }
298    // Writer-assigned image names: {doc_dir}/Res/Image_N.<ext>
299    let prefix = format!("{doc_dir}/Res/Image_");
300    if let Some(rest) = name.strip_prefix(&prefix) {
301        return !rest.is_empty()
302            && rest
303                .rsplit_once('.')
304                .is_some_and(|(idx, _)| idx.chars().all(|c| c.is_ascii_digit()));
305    }
306    false
307}
308
309/// 解析 OFD 文档日期字符串为 `NaiveDateTime`。
310///
311/// 支持多种输入:RFC3339 带时区(`"2021-05-28T12:50:45+08:00"`)、
312/// ISO(`"2024-05-31T00:00:00"`、`"2020-12-08 18:17:21"`)、裸日期
313/// (`"2024-05-31"`、无前导零的 `"2023-7-12"`),以及 WPS 生成文件中
314/// 出现的 PDF 风格日期(`"D:20220708103442+02'34'"`)。
315fn parse_ofd_date(s: &str) -> Option<chrono::NaiveDateTime> {
316    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
317        return Some(dt.naive_utc());
318    }
319    for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] {
320        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
321            return Some(dt);
322        }
323    }
324    if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
325        return d.and_hms_opt(0, 0, 0);
326    }
327    // PDF-style date ("D:20220708103442+02'34'"): extract the leading run of
328    // digits (YYYYMMDDHHMMSS) and ignore the timezone offset.
329    let digits: String = s
330        .strip_prefix("D:")
331        .or_else(|| s.strip_prefix('D'))
332        .unwrap_or(s)
333        .chars()
334        .take_while(|c| c.is_ascii_digit())
335        .collect();
336    chrono::NaiveDateTime::parse_from_str(&digits[..digits.len().min(14)], "%Y%m%d%H%M%S").ok()
337}
338
339/// 将页面上所有文本对象合并为一个字符串。
340pub(crate) fn page_text(page: &OfdPage) -> String {
341    page.content
342        .iter()
343        .filter_map(|obj| {
344            if let ContentObject::Text(t) = obj {
345                Some(t.text.as_str())
346            } else {
347                None
348            }
349        })
350        .collect::<Vec<_>>()
351        .join("\n")
352}