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}