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}