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