Skip to main content

xberg_libwpd/
lib.rs

1//! WordPerfect structured document extraction for Xberg.
2//!
3//! Thin, safe wrapper over [libwpd](https://libwpd.sourceforge.net/) and its
4//! document-model dependency librevenge, both built from source against their
5//! MPL-2.0 arm (see `build.rs`). libwpd covers the whole WordPerfect binary
6//! family (WP 4.2 through the X-series).
7//!
8//! libwpd has no `extract()` entry point; it drives a librevenge callback
9//! interface. A hand-written C++ shim (`src/shim.cpp`) implements that
10//! interface, records a flat, format-agnostic internal document as libwpd
11//! walks the input, and serializes that one document into a versioned binary
12//! blob exposed through a flat C API this crate wraps. [`extract_document`]
13//! decodes that blob into a typed [`WpdDocument`]: an ordered [`WpdEvent`]
14//! stream (text runs, formatting spans, list items, table structure with
15//! column/row spans and header-row flags, hyperlinks, fields, footnotes and
16//! endnotes kept as distinct sequences, headers/footers, and comment/text-box
17//! asides) plus [`WpdMetadata`] (title, author, subject, keywords, and every
18//! raw key/value pair libwpd reported). This crate performs no text or
19//! Markdown rendering; producing a flattened string from the structured model
20//! is left to the caller. WordPerfect support targets Linux, macOS and
21//! Windows; on other platforms [`extract_document`] returns
22//! [`WpdError::UnsupportedPlatform`].
23
24#![deny(clippy::print_stdout, clippy::print_stderr)]
25#![cfg_attr(test, allow(clippy::print_stdout, clippy::print_stderr))]
26
27mod dto;
28mod error;
29
30pub use dto::{WpdDocument, WpdEvent, WpdMetadata};
31pub use error::WpdError;
32
33#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
34mod imp {
35    use crate::{WpdDocument, WpdError, dto};
36    use std::ffi::CStr;
37    use std::os::raw::{c_char, c_int, c_uchar, c_ulong};
38    use std::{ptr, slice};
39
40    unsafe extern "C" {
41        fn xberg_wpd_is_supported(data: *const c_uchar, len: c_ulong) -> c_int;
42        fn xberg_wpd_extract_document(
43            data: *const c_uchar,
44            len: c_ulong,
45            out_buf: *mut *mut c_char,
46            out_len: *mut c_ulong,
47            out_err: *mut *mut c_char,
48        ) -> c_int;
49        fn xberg_wpd_free_string(s: *mut c_char);
50        #[cfg(test)]
51        fn xberg_wpd_self_test_separation() -> c_int;
52        #[cfg(test)]
53        fn xberg_wpd_self_test_features() -> c_int;
54    }
55
56    /// Returns true if `data` looks like a WordPerfect document libwpd can parse.
57    pub fn is_supported(data: &[u8]) -> bool {
58        if data.is_empty() || data.len() > u32::MAX as usize {
59            return false;
60        }
61        // SAFETY: `data` is a valid slice of `len` bytes; the shim only reads it
62        // and catches any C++ exception internally. ~keep
63        unsafe { xberg_wpd_is_supported(data.as_ptr(), data.len() as c_ulong) != 0 }
64    }
65
66    /// Extract the structured document model of a WordPerfect document held
67    /// entirely in memory.
68    pub fn extract_document(data: &[u8]) -> Result<WpdDocument, WpdError> {
69        if data.is_empty() || data.len() > u32::MAX as usize {
70            return Err(WpdError::InvalidArgs);
71        }
72
73        let mut out: *mut c_char = ptr::null_mut();
74        let mut out_len: c_ulong = 0;
75        let mut out_err: *mut c_char = ptr::null_mut();
76        // SAFETY: `data` is a valid slice of `len` bytes; `out`/`out_len`/`out_err`
77        // are valid out-pointers. The shim catches any C++ exception and reports
78        // it via the return code (plus, optionally, a detail message). On a zero
79        // return it hands back a malloc'd buffer of exactly `out_len` bytes whose
80        // ownership transfers to us. ~keep
81        let code = unsafe {
82            xberg_wpd_extract_document(
83                data.as_ptr(),
84                data.len() as c_ulong,
85                &mut out,
86                &mut out_len,
87                &mut out_err,
88            )
89        };
90        if !out_err.is_null() {
91            // SAFETY: `out_err` is a malloc'd, NUL-terminated buffer the shim
92            // handed us; freed unconditionally right after reading it. ~keep
93            let detail = unsafe {
94                let msg = CStr::from_ptr(out_err).to_string_lossy().into_owned();
95                xberg_wpd_free_string(out_err);
96                msg
97            };
98            tracing::warn!(code, error = %detail, "libwpd raised an exception during extraction");
99        }
100        if code != 0 {
101            // Defensive: the FFI contract is that `out` stays null on any
102            // non-zero return, but a future shim regression that sets it
103            // anyway must not leak the buffer it allocated. ~keep
104            if !out.is_null() {
105                // SAFETY: `out` would only be non-null here if the shim
106                // violated its own contract by allocating a buffer on an
107                // error path; if so it is still the same malloc'd buffer
108                // `xberg_wpd_free_string` is designed to free. ~keep
109                unsafe { xberg_wpd_free_string(out) };
110            }
111            return Err(WpdError::from_code(code));
112        }
113        if out.is_null() {
114            return Err(WpdError::Internal);
115        }
116
117        // SAFETY: `out` is the non-null buffer the shim allocated, exactly
118        // `out_len` bytes long; we copy it out and free it through the matching
119        // deallocator before returning. Using the explicit length (rather than
120        // scanning for a NUL terminator) means the binary blob's embedded
121        // length-prefixed strings can't be silently truncated at an embedded
122        // NUL. ~keep
123        let bytes = unsafe {
124            let bytes = slice::from_raw_parts(out as *const u8, out_len as usize).to_vec();
125            xberg_wpd_free_string(out);
126            bytes
127        };
128        dto::decode(&bytes)
129    }
130
131    #[cfg(test)]
132    mod tests {
133        use super::*;
134
135        #[test]
136        fn collector_separates_asides_from_body() {
137            // SAFETY: takes no arguments and only touches its own stack-local state. ~keep
138            assert_eq!(unsafe { xberg_wpd_self_test_separation() }, 1);
139        }
140
141        #[test]
142        fn collector_captures_links_tables_fields_and_notes() {
143            // SAFETY: takes no arguments and only touches its own stack-local state. ~keep
144            assert_eq!(unsafe { xberg_wpd_self_test_features() }, 1);
145        }
146    }
147}
148
149#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
150mod imp {
151    /// WordPerfect extraction is desktop-only; unavailable on this target.
152    pub fn is_supported(_data: &[u8]) -> bool {
153        false
154    }
155
156    /// WordPerfect extraction is desktop-only; unavailable on this target.
157    pub fn extract_document(_data: &[u8]) -> Result<super::WpdDocument, super::WpdError> {
158        Err(super::WpdError::UnsupportedPlatform)
159    }
160}
161
162pub use imp::{extract_document, is_supported};