Skip to main content

xberg_libwpd/
dto.rs

1//! Typed decode of the binary document model produced by `shim.cpp`.
2//!
3//! # Wire format (version 1)
4//!
5//! This spec MUST stay byte-for-byte identical to the comment block above the
6//! serializer in `src/shim.cpp` — the two are independently hand-written
7//! mirrors of the same format, not generated from a shared schema. All
8//! integers are little-endian. Strings are raw UTF-8 bytes (not
9//! NUL-terminated) with an explicit `u32` byte length, so embedded NULs never
10//! truncate anything.
11//!
12//! ```text
13//! document := version metadata_section event_section
14//!
15//! version         := u8                      // must equal WIRE_VERSION (1)
16//!
17//! metadata_section:= u32 metadata_count
18//!                    metadata_count * metadata_entry
19//! metadata_entry  := string key string value  // e.g. key = "dc:title"
20//!
21//! event_section   := u32 event_count
22//!                    event_count * event
23//! event           := u8 tag  payload          // payload shape depends on tag
24//!
25//! string          := u32 byte_len  byte_len * u8
26//! ```
27//!
28//! ## Event tags and payloads
29//!
30//! | tag | event                | payload                                      |
31//! |----:|-----------------------|-----------------------------------------------|
32//! |   0 | `Text`                | `string text`                                 |
33//! |   1 | `Tab`                 | —                                              |
34//! |   2 | `Space`               | —                                              |
35//! |   3 | `LineBreak`           | —                                              |
36//! |   4 | `ParagraphEnd`        | —                                              |
37//! |   5 | `ListItemStart`       | `u8 ordered` `u8 level` `u32 counter`         |
38//! |   6 | `ListItemEnd`         | —                                              |
39//! |   7 | `HeadingStart`        | `u8 level`                                    |
40//! |   8 | `BoldStart`           | —                                              |
41//! |   9 | `BoldEnd`             | —                                              |
42//! |  10 | `ItalicStart`         | —                                              |
43//! |  11 | `ItalicEnd`           | —                                              |
44//! |  12 | `UnderlineStart`      | —                                              |
45//! |  13 | `UnderlineEnd`        | —                                              |
46//! |  14 | `StrikethroughStart`  | —                                              |
47//! |  15 | `StrikethroughEnd`    | —                                              |
48//! |  16 | `SuperscriptStart`    | —                                              |
49//! |  17 | `SuperscriptEnd`      | —                                              |
50//! |  18 | `SubscriptStart`      | —                                              |
51//! |  19 | `SubscriptEnd`        | —                                              |
52//! |  20 | `TableStart`          | —                                              |
53//! |  21 | `RowStart`            | `u8 header`                                   |
54//! |  22 | `CellStart`           | `i32 column` `u32 col_span` `u32 row_span`    |
55//! |  23 | `CoveredCell`         | `i32 column`                                  |
56//! |  24 | `CellEnd`             | —                                              |
57//! |  25 | `RowEnd`              | —                                              |
58//! |  26 | `TableEnd`            | —                                              |
59//! |  27 | `HeaderStart`         | — (document running header, not a heading)    |
60//! |  28 | `HeaderEnd`           | —                                              |
61//! |  29 | `FooterStart`         | —                                              |
62//! |  30 | `FooterEnd`           | —                                              |
63//! |  31 | `NoteStart`           | `u8 endnote`                                   |
64//! |  32 | `NoteEnd`             | —                                              |
65//! |  33 | `AsideStart`          | `string kind`                                 |
66//! |  34 | `AsideEnd`            | —                                              |
67//! |  35 | `LinkStart`           | `string href`                                 |
68//! |  36 | `LinkEnd`             | —                                              |
69//! |  37 | `Field`               | `string text`                                 |
70//!
71//! `column` is `-1` when libwpd did not report `librevenge:column` for that
72//! cell (see `shim.cpp`'s `getIntOr` default); every other integer is
73//! non-negative.
74//!
75//! Booleans are serialized as a single `u8` (`0` or `1`); any other byte value
76//! is a decode error.
77//!
78//! Metadata entries are not turned into events: they are collected up front
79//! into [`WpdMetadata`]. The known keys are `dc:title`, `meta:initial-creator`,
80//! `dc:subject` and `meta:keyword`, mapped onto `title`/`author`/`subject`/
81//! `keywords` respectively. `meta:initial-creator` is libwpd's "Author" summary
82//! field; `dc:creator` (WordPerfect's separate "Typist" field), `dc:type` and
83//! `dc:language` (all also captured by the shim) have no dedicated field and are
84//! only reachable via [`WpdMetadata::raw`], alongside every other pair, so no
85//! metadata the shim captured is silently dropped.
86
87// The binary wire decoder below (WIRE_VERSION, the size-clamp consts, Reader, decode,
88// decode_event) is reached only through the FFI extractor, which is gated
89// `#[cfg(any(target_os = "linux", "macos", "windows"))]` (see lib.rs). On other targets
90// — e.g. the iOS slices the Swift artifact bundle cross-compiles — only the re-exported
91// DTO types are used, leaving the decoder unreferenced. Allow that rather than fail the
92// bundle's `-D warnings` build; on the FFI targets the decoder is used, so nothing is masked.
93#![allow(dead_code)]
94
95use crate::WpdError;
96
97/// The wire format version this decoder understands. Bump alongside the
98/// serializer in `shim.cpp` any time the layout above changes; a mismatched
99/// version is rejected rather than misparsed.
100const WIRE_VERSION: u8 = 1;
101
102/// Smallest possible encoded size of one metadata entry: two zero-length,
103/// length-prefixed strings (`u32` key length + `u32` value length, both zero).
104/// Used to clamp the pre-allocation for the metadata vector so an untrusted
105/// count can never request an abort-sized allocation.
106const MIN_METADATA_ENTRY_BYTES: usize = 8;
107
108/// Smallest possible encoded size of one event: a single tag byte with no
109/// payload (e.g. `Tab`). Used to clamp the pre-allocation for the event vector.
110const MIN_EVENT_BYTES: usize = 1;
111
112/// A single event recorded from the librevenge callback walk, decoded 1:1
113/// from the binary stream `shim.cpp` produces. Events are strictly ordered
114/// and properly nested (each `*Start` has a matching `*End`), mirroring the
115/// order libwpd invoked the corresponding callbacks in.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum WpdEvent {
118    /// Literal run of text.
119    Text(String),
120    /// A tab character.
121    Tab,
122    /// A non-breaking/explicit space.
123    Space,
124    /// An explicit line break within a paragraph.
125    LineBreak,
126    /// End of the current paragraph.
127    ParagraphEnd,
128    /// Start of a list item.
129    ListItemStart {
130        /// Whether the enclosing list is ordered (numbered) or unordered (bulleted).
131        ordered: bool,
132        /// 1-based nesting depth of the enclosing list.
133        level: u8,
134        /// 1-based position within an ordered list; `0` for unordered lists.
135        counter: u32,
136    },
137    /// End of a list item.
138    ListItemEnd,
139    /// Start of a heading paragraph (`text:outline-level` 1-6).
140    HeadingStart {
141        /// Heading level, 1 through 6.
142        level: u8,
143    },
144    /// Start of a bold span.
145    BoldStart,
146    /// End of a bold span.
147    BoldEnd,
148    /// Start of an italic span.
149    ItalicStart,
150    /// End of an italic span.
151    ItalicEnd,
152    /// Start of an underline span.
153    UnderlineStart,
154    /// End of an underline span.
155    UnderlineEnd,
156    /// Start of a strikethrough span.
157    StrikethroughStart,
158    /// End of a strikethrough span.
159    StrikethroughEnd,
160    /// Start of a superscript span.
161    SuperscriptStart,
162    /// End of a superscript span.
163    SuperscriptEnd,
164    /// Start of a subscript span.
165    SubscriptStart,
166    /// End of a subscript span.
167    SubscriptEnd,
168    /// Start of a table.
169    TableStart,
170    /// Start of a table row.
171    RowStart {
172        /// Whether librevenge flagged this row as a header row.
173        header: bool,
174    },
175    /// Start of a real (non-covered) table cell.
176    CellStart {
177        /// Absolute grid column from `librevenge:column`, or `-1` if libwpd
178        /// did not report one for this cell.
179        column: i32,
180        /// Number of columns this cell spans (at least 1).
181        col_span: u32,
182        /// Number of rows this cell spans (at least 1).
183        row_span: u32,
184    },
185    /// A covered (merged-away) grid position from a vertical or horizontal span.
186    CoveredCell {
187        /// Absolute grid column from `librevenge:column`, or `-1` if unknown.
188        column: i32,
189    },
190    /// End of a table cell.
191    CellEnd,
192    /// End of a table row.
193    RowEnd,
194    /// End of a table.
195    TableEnd,
196    /// Start of the document's running header (recurs on every page).
197    HeaderStart,
198    /// End of the document's running header.
199    HeaderEnd,
200    /// Start of the document's running footer.
201    FooterStart,
202    /// End of the document's running footer.
203    FooterEnd,
204    /// Start of a footnote or endnote body, anchored at this point in the flow.
205    NoteStart {
206        /// `true` for an endnote, `false` for a footnote.
207        endnote: bool,
208    },
209    /// End of a footnote or endnote body.
210    NoteEnd,
211    /// Start of a comment or text-box aside.
212    AsideStart {
213        /// `"comment"` or `"box"`.
214        kind: String,
215    },
216    /// End of a comment or text-box aside.
217    AsideEnd,
218    /// Start of a hyperlink span.
219    LinkStart {
220        /// The link target, if librevenge reported one.
221        href: String,
222    },
223    /// End of a hyperlink span.
224    LinkEnd,
225    /// An inserted field (page number, page count, date, time, or another
226    /// field type libwpd reported by name), already mapped to a stable
227    /// placeholder string by the shim.
228    Field(String),
229}
230
231/// Document metadata captured from libwpd's `setDocumentMetaData` callback.
232#[derive(Debug, Clone, PartialEq, Eq, Default)]
233pub struct WpdMetadata {
234    /// `dc:title`.
235    pub title: Option<String>,
236    /// `dc:creator`.
237    pub author: Option<String>,
238    /// `dc:subject`.
239    pub subject: Option<String>,
240    /// `meta:keyword`.
241    pub keywords: Option<String>,
242    /// Every metadata key/value pair the shim captured, in the order libwpd
243    /// reported them, including keys with no dedicated field above (for
244    /// example `dc:type`, `dc:language`).
245    pub raw: Vec<(String, String)>,
246}
247
248/// The structured WordPerfect document model: an ordered event stream plus
249/// document-level metadata.
250#[derive(Debug, Clone, PartialEq, Eq, Default)]
251pub struct WpdDocument {
252    /// The recorded event stream, in document order.
253    pub events: Vec<WpdEvent>,
254    /// Document metadata, if libwpd reported any.
255    pub metadata: WpdMetadata,
256}
257
258/// Cursor over the wire bytes; every read is bounds-checked and reports
259/// `WpdError::Internal` on truncation rather than panicking or reading out of
260/// bounds. Malformed shim output must never crash the Rust side. ~keep
261struct Reader<'a> {
262    bytes: &'a [u8],
263    pos: usize,
264}
265
266impl<'a> Reader<'a> {
267    fn new(bytes: &'a [u8]) -> Self {
268        Self { bytes, pos: 0 }
269    }
270
271    /// Bytes not yet consumed. Used to bound pre-allocations against untrusted
272    /// length prefixes so a lying count can never request an abort-sized `Vec`.
273    fn remaining(&self) -> usize {
274        self.bytes.len().saturating_sub(self.pos)
275    }
276
277    fn u8(&mut self) -> Result<u8, WpdError> {
278        let b = *self.bytes.get(self.pos).ok_or(WpdError::Internal)?;
279        self.pos += 1;
280        Ok(b)
281    }
282
283    fn bool(&mut self) -> Result<bool, WpdError> {
284        match self.u8()? {
285            0 => Ok(false),
286            1 => Ok(true),
287            _ => Err(WpdError::Internal),
288        }
289    }
290
291    fn u32(&mut self) -> Result<u32, WpdError> {
292        let end = self.pos.checked_add(4).ok_or(WpdError::Internal)?;
293        let slice = self.bytes.get(self.pos..end).ok_or(WpdError::Internal)?;
294        self.pos = end;
295        Ok(u32::from_le_bytes(slice.try_into().expect("slice is exactly 4 bytes")))
296    }
297
298    fn i32(&mut self) -> Result<i32, WpdError> {
299        self.u32().map(|v| v as i32)
300    }
301
302    fn string(&mut self) -> Result<String, WpdError> {
303        let len = self.u32()? as usize;
304        let end = self.pos.checked_add(len).ok_or(WpdError::Internal)?;
305        let slice = self.bytes.get(self.pos..end).ok_or(WpdError::Internal)?;
306        self.pos = end;
307        String::from_utf8(slice.to_vec()).map_err(|_| WpdError::InvalidUtf8)
308    }
309}
310
311/// Decode a document serialized by `xberg_wpd_extract_document` in `shim.cpp`.
312/// See the module-level wire-format spec above; the two must stay in sync.
313pub fn decode(bytes: &[u8]) -> Result<WpdDocument, WpdError> {
314    let mut r = Reader::new(bytes);
315
316    let version = r.u8()?;
317    if version != WIRE_VERSION {
318        return Err(WpdError::Internal);
319    }
320
321    let metadata_count = r.u32()?;
322    // Clamp the pre-allocation to what the remaining bytes could possibly hold:
323    // a lying count (e.g. u32::MAX in a 5-byte blob) must fail fast on the first
324    // out-of-range read, never request an abort-sized `Vec` up front. ~keep
325    let raw_cap = (metadata_count as usize).min(r.remaining() / MIN_METADATA_ENTRY_BYTES);
326    let mut raw = Vec::with_capacity(raw_cap);
327    let mut metadata = WpdMetadata::default();
328    for _ in 0..metadata_count {
329        let key = r.string()?;
330        let value = r.string()?;
331        match key.as_str() {
332            "dc:title" => metadata.title = Some(value.clone()),
333            // `dc:creator` is WordPerfect's separate "Typist" field, kept only in
334            // `raw` rather than mistaken for the author.
335            "meta:initial-creator" => metadata.author = Some(value.clone()),
336            "dc:subject" => metadata.subject = Some(value.clone()),
337            "meta:keyword" => metadata.keywords = Some(value.clone()),
338            _ => {}
339        }
340        raw.push((key, value));
341    }
342    metadata.raw = raw;
343
344    let event_count = r.u32()?;
345    let events_cap = (event_count as usize).min(r.remaining() / MIN_EVENT_BYTES);
346    let mut events = Vec::with_capacity(events_cap);
347    for _ in 0..event_count {
348        events.push(decode_event(&mut r)?);
349    }
350
351    Ok(WpdDocument { events, metadata })
352}
353
354fn decode_event(r: &mut Reader<'_>) -> Result<WpdEvent, WpdError> {
355    let tag = r.u8()?;
356    Ok(match tag {
357        0 => WpdEvent::Text(r.string()?),
358        1 => WpdEvent::Tab,
359        2 => WpdEvent::Space,
360        3 => WpdEvent::LineBreak,
361        4 => WpdEvent::ParagraphEnd,
362        5 => {
363            let ordered = r.bool()?;
364            let level = r.u8()?;
365            let counter = r.u32()?;
366            WpdEvent::ListItemStart {
367                ordered,
368                level,
369                counter,
370            }
371        }
372        6 => WpdEvent::ListItemEnd,
373        7 => WpdEvent::HeadingStart { level: r.u8()? },
374        8 => WpdEvent::BoldStart,
375        9 => WpdEvent::BoldEnd,
376        10 => WpdEvent::ItalicStart,
377        11 => WpdEvent::ItalicEnd,
378        12 => WpdEvent::UnderlineStart,
379        13 => WpdEvent::UnderlineEnd,
380        14 => WpdEvent::StrikethroughStart,
381        15 => WpdEvent::StrikethroughEnd,
382        16 => WpdEvent::SuperscriptStart,
383        17 => WpdEvent::SuperscriptEnd,
384        18 => WpdEvent::SubscriptStart,
385        19 => WpdEvent::SubscriptEnd,
386        20 => WpdEvent::TableStart,
387        21 => WpdEvent::RowStart { header: r.bool()? },
388        22 => {
389            let column = r.i32()?;
390            let col_span = r.u32()?;
391            let row_span = r.u32()?;
392            WpdEvent::CellStart {
393                column,
394                col_span,
395                row_span,
396            }
397        }
398        23 => WpdEvent::CoveredCell { column: r.i32()? },
399        24 => WpdEvent::CellEnd,
400        25 => WpdEvent::RowEnd,
401        26 => WpdEvent::TableEnd,
402        27 => WpdEvent::HeaderStart,
403        28 => WpdEvent::HeaderEnd,
404        29 => WpdEvent::FooterStart,
405        30 => WpdEvent::FooterEnd,
406        31 => WpdEvent::NoteStart { endnote: r.bool()? },
407        32 => WpdEvent::NoteEnd,
408        33 => WpdEvent::AsideStart { kind: r.string()? },
409        34 => WpdEvent::AsideEnd,
410        35 => WpdEvent::LinkStart { href: r.string()? },
411        36 => WpdEvent::LinkEnd,
412        37 => WpdEvent::Field(r.string()?),
413        _ => return Err(WpdError::Internal),
414    })
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn string_bytes(s: &str) -> Vec<u8> {
422        let mut out = (s.len() as u32).to_le_bytes().to_vec();
423        out.extend_from_slice(s.as_bytes());
424        out
425    }
426
427    #[test]
428    fn decode_rejects_unknown_version() {
429        let bytes = vec![99u8, 0, 0, 0, 0, 0, 0, 0, 0];
430        assert!(matches!(decode(&bytes), Err(WpdError::Internal)));
431    }
432
433    #[test]
434    fn decode_rejects_truncated_input() {
435        assert!(matches!(decode(&[1]), Err(WpdError::Internal)));
436        assert!(matches!(decode(&[1, 0, 0]), Err(WpdError::Internal)));
437    }
438
439    #[test]
440    fn decode_rejects_unknown_event_tag() {
441        let mut bytes = vec![WIRE_VERSION];
442        bytes.extend_from_slice(&0u32.to_le_bytes()); // metadata_count
443        bytes.extend_from_slice(&1u32.to_le_bytes()); // event_count
444        bytes.push(255); // unknown tag
445        assert!(matches!(decode(&bytes), Err(WpdError::Internal)));
446    }
447
448    #[test]
449    fn decode_rejects_abort_sized_count_without_allocating() {
450        // first out-of-range read, never pre-allocate a ~200GB Vec (which would
451        // abort the process, not return Err). Same for event_count.
452        assert!(matches!(
453            decode(&[WIRE_VERSION, 0xFF, 0xFF, 0xFF, 0xFF]),
454            Err(WpdError::Internal)
455        ));
456        let mut bytes = vec![WIRE_VERSION];
457        bytes.extend_from_slice(&0u32.to_le_bytes()); // metadata_count = 0
458        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
459        assert!(matches!(decode(&bytes), Err(WpdError::Internal)));
460    }
461
462    #[test]
463    fn decode_maps_initial_creator_to_author_not_typist() {
464        // `dc:creator` is the separate Typist field and must stay raw-only.
465        let mut bytes = vec![WIRE_VERSION];
466        bytes.extend_from_slice(&2u32.to_le_bytes());
467        bytes.extend(string_bytes("dc:creator"));
468        bytes.extend(string_bytes("The Typist"));
469        bytes.extend(string_bytes("meta:initial-creator"));
470        bytes.extend(string_bytes("The Author"));
471        bytes.extend_from_slice(&0u32.to_le_bytes()); // no events
472
473        let doc = decode(&bytes).expect("valid document");
474        assert_eq!(doc.metadata.author.as_deref(), Some("The Author"));
475        assert!(
476            doc.metadata
477                .raw
478                .iter()
479                .any(|(k, v)| k == "dc:creator" && v == "The Typist")
480        );
481    }
482
483    #[test]
484    fn decode_parses_metadata_and_events() {
485        let mut bytes = vec![WIRE_VERSION];
486        bytes.extend_from_slice(&2u32.to_le_bytes());
487        bytes.extend(string_bytes("dc:title"));
488        bytes.extend(string_bytes("Sample"));
489        bytes.extend(string_bytes("dc:type"));
490        bytes.extend(string_bytes("report"));
491
492        bytes.extend_from_slice(&3u32.to_le_bytes());
493        bytes.push(1); // Tab
494        bytes.push(7); // HeadingStart
495        bytes.push(2); // level
496        bytes.push(35); // LinkStart
497        bytes.extend(string_bytes("https://example.com"));
498
499        let doc = decode(&bytes).expect("valid document");
500        assert_eq!(doc.metadata.title.as_deref(), Some("Sample"));
501        assert_eq!(
502            doc.metadata.raw,
503            vec![
504                ("dc:title".to_string(), "Sample".to_string()),
505                ("dc:type".to_string(), "report".to_string()),
506            ]
507        );
508        assert_eq!(
509            doc.events,
510            vec![
511                WpdEvent::Tab,
512                WpdEvent::HeadingStart { level: 2 },
513                WpdEvent::LinkStart {
514                    href: "https://example.com".to_string()
515                },
516            ]
517        );
518    }
519}