Skip to main content

pptxboss_core/
ppt.rs

1//! Legacy PowerPoint 97-2003 binary presentations (MS-PPT, MS-ODRAW) held
2//! in a compound file. Everything is reached through the persist directory
3//! built from the user-edit chain, never by scanning the stream, so stale
4//! records from earlier edits are ignored as the specification requires.
5//! Slides are mapped onto the same shape model as PresentationML slides.
6
7use std::sync::Arc;
8
9use crate::cfb::Compound;
10use crate::error::{Error, Result};
11use crate::hash::FastMap;
12use crate::model::{
13    Bullet, Content, Emu, OleObject, Paragraph, Picture, Placeholder, PlaceholderKind, Run,
14    RunKind, RunProps, Shape, SlideContent, SlideKind, TextBody, Transform,
15};
16
17const RT_DOCUMENT: u16 = 0x03e8;
18const RT_DOCUMENT_ATOM: u16 = 0x03e9;
19const RT_SLIDE: u16 = 0x03ee;
20const RT_NOTES: u16 = 0x03f0;
21const RT_NOTES_ATOM: u16 = 0x03f1;
22const RT_SLIDE_PERSIST_ATOM: u16 = 0x03f3;
23const RT_SLIDE_SHOW_SLIDE_INFO_ATOM: u16 = 0x03f9;
24const RT_DRAWING_GROUP: u16 = 0x040b;
25const RT_DRAWING: u16 = 0x040c;
26const RT_PLACEHOLDER_ATOM: u16 = 0x0bc3;
27const RT_OUTLINE_TEXT_REF_ATOM: u16 = 0x0f9e;
28const RT_TEXT_HEADER_ATOM: u16 = 0x0f9f;
29const RT_TEXT_CHARS_ATOM: u16 = 0x0fa0;
30const RT_STYLE_TEXT_PROP_ATOM: u16 = 0x0fa1;
31const RT_MASTER_TEXT_PROP_ATOM: u16 = 0x0fa2;
32const RT_TEXT_BYTES_ATOM: u16 = 0x0fa8;
33const RT_CSTRING: u16 = 0x0fba;
34const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0ff0;
35const RT_USER_EDIT_ATOM: u16 = 0x0ff5;
36const RT_CURRENT_USER_ATOM: u16 = 0x0ff6;
37const RT_PERSIST_DIRECTORY_ATOM: u16 = 0x1772;
38const RT_CRYPT_SESSION10_CONTAINER: u16 = 0x2f14;
39
40const OA_DGG_CONTAINER: u16 = 0xf000;
41const OA_BSTORE_CONTAINER: u16 = 0xf001;
42const OA_DG_CONTAINER: u16 = 0xf002;
43const OA_SPGR_CONTAINER: u16 = 0xf003;
44const OA_SP_CONTAINER: u16 = 0xf004;
45const OA_FBSE: u16 = 0xf007;
46const OA_FSP: u16 = 0xf00a;
47const OA_FOPT: u16 = 0xf00b;
48const OA_CLIENT_TEXTBOX: u16 = 0xf00d;
49const OA_CHILD_ANCHOR: u16 = 0xf00f;
50const OA_CLIENT_ANCHOR: u16 = 0xf010;
51const OA_CLIENT_DATA: u16 = 0xf011;
52const OA_SECONDARY_FOPT: u16 = 0xf121;
53const OA_TERTIARY_FOPT: u16 = 0xf122;
54
55const HEADER_TOKEN_ENCRYPTED: u32 = 0xf3d1_c4df;
56const NO_PLACEHOLDER: u32 = 0xffff_ffff;
57const MSOSPT_TEXT_BOX: u16 = 202;
58
59/// One English Metric Unit per master unit (1/576 inch), as a rational: 914400 / 576.
60const EMU_PER_MASTER_UNIT_NUM: i64 = 3175;
61const EMU_PER_MASTER_UNIT_DEN: i64 = 2;
62
63/// A record header plus where its data lies in the stream.
64#[derive(Clone, Copy, Debug)]
65struct Rec {
66    ver: u8,
67    instance: u16,
68    kind: u16,
69    /// Offset of the first data byte.
70    start: usize,
71    len: usize,
72}
73
74impl Rec {
75    fn end(&self) -> usize {
76        self.start + self.len
77    }
78
79    fn is_container(&self) -> bool {
80        self.ver == 0xf
81    }
82}
83
84fn header_at(data: &[u8], at: usize) -> Option<Rec> {
85    let raw = data.get(at..at + 8)?;
86    let word = u16::from_le_bytes([raw[0], raw[1]]);
87    let len = u32::from_le_bytes([raw[4], raw[5], raw[6], raw[7]]) as usize;
88    Some(Rec {
89        ver: (word & 0xf) as u8,
90        instance: word >> 4,
91        kind: u16::from_le_bytes([raw[2], raw[3]]),
92        start: at + 8,
93        len,
94    })
95}
96
97/// The records tiling `start..end`, stopping at the first that overruns.
98fn records(data: &[u8], start: usize, end: usize) -> Vec<Rec> {
99    let end = end.min(data.len());
100    let mut out = Vec::new();
101    let mut cursor = start;
102    while cursor + 8 <= end {
103        let Some(rec) = header_at(data, cursor) else {
104            break;
105        };
106        if rec.end() > end {
107            break;
108        }
109        out.push(rec);
110        cursor = rec.end();
111    }
112    out
113}
114
115fn children(data: &[u8], rec: &Rec) -> Vec<Rec> {
116    records(data, rec.start, rec.end())
117}
118
119fn u16_at(data: &[u8], at: usize) -> Option<u16> {
120    let raw = data.get(at..at + 2)?;
121    Some(u16::from_le_bytes([raw[0], raw[1]]))
122}
123
124fn u32_at(data: &[u8], at: usize) -> Option<u32> {
125    let raw = data.get(at..at + 4)?;
126    Some(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
127}
128
129fn i32_at(data: &[u8], at: usize) -> Option<i32> {
130    u32_at(data, at).map(|v| v as i32)
131}
132
133fn master_units_to_emu(value: i64) -> Emu {
134    value * EMU_PER_MASTER_UNIT_NUM / EMU_PER_MASTER_UNIT_DEN
135}
136
137fn ppt_error(msg: &str) -> Error {
138    Error::Other(format!("ppt: {msg}"))
139}
140
141/// An outline text body from the slide list, with its text type.
142#[derive(Clone, Debug)]
143struct OutlineText {
144    text_type: u32,
145    body: TextBody,
146}
147
148#[derive(Clone, Debug)]
149struct SlideEntry {
150    persist: u32,
151    slide_id: u32,
152    outline: Vec<OutlineText>,
153}
154
155/// A picture in the blip store: where its bytes are.
156#[derive(Clone, Debug)]
157enum BlipSource {
158    /// Offset of the BLIP record header in the Pictures stream.
159    Delay(usize),
160    /// Offset of a BLIP record header embedded in the document stream.
161    Embedded(usize),
162    Missing,
163}
164
165/// A parsed legacy presentation: the streams and the directory needed to
166/// reach every live object, with slides parsed on demand.
167pub struct LegacyDeck {
168    document: Vec<u8>,
169    pictures: Vec<u8>,
170    directory: FastMap<u32, usize>,
171    slides: Vec<SlideEntry>,
172    /// Notes persist ids by the slide id they belong to.
173    notes: FastMap<u32, u32>,
174    blips: Vec<BlipSource>,
175    /// Offsets of every top-level `SlideContainer` in stream order, the
176    /// fallback for slides whose persist id the directory cannot resolve.
177    stream_slides: Vec<usize>,
178    /// True when at least one slide came from the fallback scan.
179    pub slides_recovered_by_scan: bool,
180    pub slide_size: Option<(Emu, Emu)>,
181    pub notes_size: Option<(Emu, Emu)>,
182    pub slide_size_kind: Option<&'static str>,
183    pub first_slide_number: i32,
184}
185
186/// One slide as parsed from its container.
187pub struct LegacySlide {
188    pub id: u32,
189    pub hidden: bool,
190    pub name: Option<String>,
191    pub content: SlideContent,
192}
193
194/// A picture's bytes with their media type.
195pub struct LegacyPicture {
196    pub content_type: &'static str,
197    pub bytes: Vec<u8>,
198}
199
200/// Whether the compound file carries a PowerPoint 97-2003 presentation.
201pub fn is_presentation(compound: &Compound) -> bool {
202    compound.has_stream("PowerPoint Document")
203        || compound.has_stream("PP97_DUALSTORAGE/PowerPoint Document")
204}
205
206impl LegacyDeck {
207    pub fn open(compound: &Compound) -> Result<Self> {
208        let prefix = match compound.has_stream("PP97_DUALSTORAGE/PowerPoint Document") {
209            true => "PP97_DUALSTORAGE/",
210            false => "",
211        };
212        let document = compound
213            .stream(&format!("{prefix}PowerPoint Document"))
214            .ok_or_else(|| ppt_error("no PowerPoint Document stream"))?;
215        let current_user = compound
216            .stream(&format!("{prefix}Current User"))
217            .ok_or_else(|| ppt_error("no Current User stream"))?;
218        let pictures = compound
219            .stream(&format!("{prefix}Pictures"))
220            .unwrap_or_default();
221        let cu = header_at(&current_user, 0)
222            .filter(|rec| rec.kind == RT_CURRENT_USER_ATOM)
223            .ok_or_else(|| {
224                Error::Unsupported("PowerPoint 95 or unknown presentation format".into())
225            })?;
226        let header_token = u32_at(&current_user, cu.start + 4).unwrap_or(0);
227        if header_token == HEADER_TOKEN_ENCRYPTED {
228            return Err(Error::Encrypted(
229                "legacy .ppt encrypted with a password".into(),
230            ));
231        }
232        let mut offset = u32_at(&current_user, cu.start + 8).unwrap_or(0) as usize;
233        let mut directories: Vec<Vec<(u32, usize)>> = Vec::new();
234        let mut doc_persist = 0u32;
235        let mut encrypt_ref = None;
236        let mut edits = 0usize;
237        while offset != 0 {
238            let edit = header_at(&document, offset)
239                .filter(|rec| rec.kind == RT_USER_EDIT_ATOM)
240                .ok_or_else(|| ppt_error("user edit atom missing"))?;
241            let last_edit = u32_at(&document, edit.start + 8).unwrap_or(0) as usize;
242            let dir_offset = u32_at(&document, edit.start + 12).unwrap_or(0) as usize;
243            if edits == 0 {
244                doc_persist = u32_at(&document, edit.start + 16).unwrap_or(0);
245                if edit.len >= 0x20 {
246                    encrypt_ref = u32_at(&document, edit.start + 28).filter(|r| *r != 0);
247                }
248            }
249            let dir = header_at(&document, dir_offset)
250                .filter(|rec| rec.kind == RT_PERSIST_DIRECTORY_ATOM)
251                .ok_or_else(|| ppt_error("persist directory missing"))?;
252            let mut entries = Vec::new();
253            let mut cursor = dir.start;
254            while cursor + 4 <= dir.end().min(document.len()) {
255                let word = u32_at(&document, cursor).unwrap_or(0);
256                let persist_id = word & 0x000f_ffff;
257                let count = (word >> 20) & 0xfff;
258                cursor += 4;
259                if count == 0 {
260                    break;
261                }
262                for i in 0..count {
263                    let Some(value) = u32_at(&document, cursor) else {
264                        break;
265                    };
266                    entries.push((persist_id + i, value as usize));
267                    cursor += 4;
268                }
269            }
270            directories.push(entries);
271            edits += 1;
272            if last_edit >= offset || edits > 4096 {
273                break;
274            }
275            offset = last_edit;
276        }
277        let mut directory = FastMap::default();
278        for entries in directories.iter().rev() {
279            for (id, value) in entries {
280                directory.insert(*id, *value);
281            }
282        }
283        if let Some(reference) = encrypt_ref {
284            let encrypted = directory
285                .get(&reference)
286                .and_then(|&at| header_at(&document, at))
287                .is_some_and(|rec| rec.kind == RT_CRYPT_SESSION10_CONTAINER);
288            if encrypted {
289                return Err(Error::Encrypted(
290                    "legacy .ppt encrypted with a password".into(),
291                ));
292            }
293        }
294        let doc_offset = *directory
295            .get(&doc_persist)
296            .ok_or_else(|| ppt_error("document container not in the persist directory"))?;
297        let doc_rec = header_at(&document, doc_offset)
298            .filter(|rec| rec.kind == RT_DOCUMENT && rec.is_container())
299            .ok_or_else(|| ppt_error("document container missing"))?;
300
301        let mut deck = LegacyDeck {
302            document,
303            pictures,
304            directory,
305            slides: Vec::new(),
306            notes: FastMap::default(),
307            blips: Vec::new(),
308            stream_slides: Vec::new(),
309            slides_recovered_by_scan: false,
310            slide_size: None,
311            notes_size: None,
312            slide_size_kind: None,
313            first_slide_number: 1,
314        };
315        let doc_children = children(&deck.document, &doc_rec);
316        let mut notes_persists: Vec<u32> = Vec::new();
317        for child in &doc_children {
318            match child.kind {
319                RT_DOCUMENT_ATOM => deck.read_document_atom(child),
320                RT_DRAWING_GROUP => deck.read_drawing_group(child),
321                RT_SLIDE_LIST_WITH_TEXT => match child.instance {
322                    0 => deck.read_slide_list(child),
323                    2 => {
324                        for atom in children(&deck.document, child) {
325                            if atom.kind == RT_SLIDE_PERSIST_ATOM {
326                                if let Some(persist) = u32_at(&deck.document, atom.start) {
327                                    notes_persists.push(persist);
328                                }
329                            }
330                        }
331                    }
332                    _ => {}
333                },
334                _ => {}
335            }
336        }
337        deck.recover_missing_slides();
338        for persist in notes_persists {
339            let Some(&at) = deck.directory.get(&persist) else {
340                continue;
341            };
342            let Some(rec) = header_at(&deck.document, at).filter(|r| r.kind == RT_NOTES) else {
343                continue;
344            };
345            let slide_id = children(&deck.document, &rec)
346                .iter()
347                .find(|c| c.kind == RT_NOTES_ATOM)
348                .and_then(|atom| u32_at(&deck.document, atom.start))
349                .unwrap_or(0);
350            if slide_id != 0 {
351                deck.notes.insert(slide_id, persist);
352            }
353        }
354        Ok(deck)
355    }
356
357    fn read_document_atom(&mut self, rec: &Rec) {
358        let data = &self.document;
359        let width = i32_at(data, rec.start).unwrap_or(0);
360        let height = i32_at(data, rec.start + 4).unwrap_or(0);
361        if width > 0 && height > 0 {
362            self.slide_size = Some((
363                master_units_to_emu(i64::from(width)),
364                master_units_to_emu(i64::from(height)),
365            ));
366        }
367        let notes_width = i32_at(data, rec.start + 8).unwrap_or(0);
368        let notes_height = i32_at(data, rec.start + 12).unwrap_or(0);
369        if notes_width > 0 && notes_height > 0 {
370            self.notes_size = Some((
371                master_units_to_emu(i64::from(notes_width)),
372                master_units_to_emu(i64::from(notes_height)),
373            ));
374        }
375        self.first_slide_number = i32::from(u16_at(data, rec.start + 32).unwrap_or(1));
376        self.slide_size_kind = match u16_at(data, rec.start + 34).unwrap_or(6) {
377            0 => Some("screen4x3"),
378            1 => Some("letter"),
379            2 => Some("A4"),
380            3 => Some("35mm"),
381            4 => Some("overhead"),
382            5 => Some("banner"),
383            _ => None,
384        };
385    }
386
387    /// Reads the document's blip store: one entry per picture, in order.
388    fn read_drawing_group(&mut self, rec: &Rec) {
389        let data = &self.document;
390        for dgg in children(data, rec)
391            .iter()
392            .filter(|c| c.kind == OA_DGG_CONTAINER)
393        {
394            for store in children(data, dgg)
395                .iter()
396                .filter(|c| c.kind == OA_BSTORE_CONTAINER)
397            {
398                for block in children(data, store) {
399                    let source = match block.kind {
400                        OA_FBSE => {
401                            let name_len =
402                                data.get(block.start + 33).copied().unwrap_or(0) as usize;
403                            let delay = u32_at(data, block.start + 28).unwrap_or(0xffff_ffff);
404                            let embedded_at = block.start + 36 + name_len;
405                            match embedded_at + 8 <= block.end() {
406                                true => BlipSource::Embedded(embedded_at),
407                                false if delay != 0xffff_ffff => BlipSource::Delay(delay as usize),
408                                false => BlipSource::Missing,
409                            }
410                        }
411                        0xf018..=0xf117 => BlipSource::Embedded(block.start - 8),
412                        _ => BlipSource::Missing,
413                    };
414                    self.blips.push(source);
415                }
416            }
417        }
418    }
419
420    /// Reads the slide list: slide order, ids and the outline text of each slide.
421    fn read_slide_list(&mut self, rec: &Rec) {
422        let data = &self.document;
423        let items = children(data, rec);
424        let mut current: Option<SlideEntry> = None;
425        let mut index = 0;
426        while index < items.len() {
427            let item = items[index];
428            if item.kind == RT_SLIDE_PERSIST_ATOM {
429                if let Some(entry) = current.take() {
430                    self.slides.push(entry);
431                }
432                current = Some(SlideEntry {
433                    persist: u32_at(data, item.start).unwrap_or(0),
434                    slide_id: u32_at(data, item.start + 12).unwrap_or(0),
435                    outline: Vec::new(),
436                });
437                index += 1;
438                continue;
439            }
440            if item.kind == RT_TEXT_HEADER_ATOM {
441                let text_type = u32_at(data, item.start).unwrap_or(4);
442                let mut end = index + 1;
443                while end < items.len()
444                    && !matches!(items[end].kind, RT_TEXT_HEADER_ATOM | RT_SLIDE_PERSIST_ATOM)
445                {
446                    end += 1;
447                }
448                let body = parse_text_body(data, &items[index + 1..end]);
449                if let Some(entry) = current.as_mut() {
450                    entry.outline.push(OutlineText { text_type, body });
451                }
452                index = end;
453                continue;
454            }
455            index += 1;
456        }
457        if let Some(entry) = current {
458            self.slides.push(entry);
459        }
460    }
461
462    /// Points unresolved slide persist ids at unreferenced top-level slide
463    /// containers, in stream order. The persist directory is authoritative
464    /// (2.1.2); this only runs when it is broken, and says so.
465    fn recover_missing_slides(&mut self) {
466        let missing: Vec<usize> = self
467            .slides
468            .iter()
469            .enumerate()
470            .filter(|(_, slide)| {
471                !self
472                    .directory
473                    .get(&slide.persist)
474                    .and_then(|&at| header_at(&self.document, at))
475                    .is_some_and(|rec| rec.kind == RT_SLIDE && rec.is_container())
476            })
477            .map(|(index, _)| index)
478            .collect();
479        if missing.is_empty() {
480            return;
481        }
482        let referenced: Vec<usize> = self.directory.values().copied().collect();
483        self.stream_slides = records(&self.document, 0, self.document.len())
484            .iter()
485            .filter(|rec| rec.kind == RT_SLIDE && rec.is_container())
486            .map(|rec| rec.start - 8)
487            .filter(|at| !referenced.contains(at))
488            .collect();
489        let mut spare = self.stream_slides.iter().copied();
490        let mut next_id = 0x00f0_0000u32;
491        for index in missing {
492            let Some(at) = spare.next() else {
493                break;
494            };
495            while self.directory.contains_key(&next_id) {
496                next_id += 1;
497            }
498            self.directory.insert(next_id, at);
499            self.slides[index].persist = next_id;
500            self.slides_recovered_by_scan = true;
501        }
502    }
503
504    pub fn slide_count(&self) -> usize {
505        self.slides.len()
506    }
507
508    /// The slide ids in presentation order.
509    pub fn slide_ids(&self) -> Vec<u32> {
510        self.slides.iter().map(|slide| slide.slide_id).collect()
511    }
512
513    /// Parses slide `index` (zero-based, presentation order).
514    pub fn slide(&self, index: usize) -> Result<LegacySlide> {
515        let entry = self.slides.get(index).ok_or(Error::SlideNotFound(index))?;
516        let at = *self
517            .directory
518            .get(&entry.persist)
519            .ok_or_else(|| ppt_error("slide persist id not in the directory"))?;
520        let rec = header_at(&self.document, at)
521            .filter(|rec| rec.kind == RT_SLIDE && rec.is_container())
522            .ok_or_else(|| ppt_error("slide container missing"))?;
523        let mut hidden = false;
524        let mut name = None;
525        let mut shapes = Vec::new();
526        for child in children(&self.document, &rec) {
527            match child.kind {
528                RT_SLIDE_SHOW_SLIDE_INFO_ATOM => {
529                    hidden = u16_at(&self.document, child.start + 10).unwrap_or(0) & 0x0004 != 0;
530                }
531                RT_CSTRING if child.instance == 3 => {
532                    name = Some(utf16_string(&self.document[child.start..child.end()]));
533                }
534                RT_DRAWING => {
535                    shapes = self.drawing_shapes(&child, &entry.outline);
536                }
537                _ => {}
538            }
539        }
540        Ok(LegacySlide {
541            id: entry.slide_id,
542            hidden,
543            name: name.clone(),
544            content: SlideContent {
545                kind: SlideKind::Slide,
546                name,
547                show: !hidden,
548                shapes,
549            },
550        })
551    }
552
553    /// Whether slide `index` has a notes slide.
554    pub fn has_notes(&self, index: usize) -> bool {
555        self.slides
556            .get(index)
557            .is_some_and(|slide| self.notes.contains_key(&slide.slide_id))
558    }
559
560    /// The speaker notes of slide `index`: the notes body placeholder, or
561    /// every text shape on the notes slide other than the slide image.
562    pub fn notes(&self, index: usize) -> Result<Option<TextBody>> {
563        let entry = self.slides.get(index).ok_or(Error::SlideNotFound(index))?;
564        let Some(persist) = self.notes.get(&entry.slide_id) else {
565            return Ok(None);
566        };
567        let Some(&at) = self.directory.get(persist) else {
568            return Ok(None);
569        };
570        let Some(rec) = header_at(&self.document, at).filter(|r| r.kind == RT_NOTES) else {
571            return Ok(None);
572        };
573        let drawing = children(&self.document, &rec)
574            .into_iter()
575            .find(|c| c.kind == RT_DRAWING);
576        let Some(drawing) = drawing else {
577            return Ok(None);
578        };
579        let shapes = self.drawing_shapes(&drawing, &[]);
580        let content = SlideContent {
581            kind: SlideKind::Notes,
582            name: None,
583            show: true,
584            shapes,
585        };
586        let body_placeholder = content
587            .walk()
588            .find(|shape| {
589                shape
590                    .placeholder
591                    .as_ref()
592                    .is_some_and(|ph| ph.kind == PlaceholderKind::Body)
593            })
594            .and_then(|shape| shape.text_body().cloned());
595        if let Some(body) = body_placeholder {
596            return Ok(Some(body));
597        }
598        let mut merged = TextBody::default();
599        for shape in content.walk() {
600            let skip = shape
601                .placeholder
602                .as_ref()
603                .is_some_and(|ph| ph.kind == PlaceholderKind::SlideImage || ph.kind.is_furniture());
604            if skip {
605                continue;
606            }
607            if let Some(body) = shape.text_body() {
608                merged.paragraphs.extend(body.paragraphs.iter().cloned());
609            }
610        }
611        Ok(Some(merged))
612    }
613
614    /// The shapes of a `DrawingContainer` in z-order, groups nested.
615    fn drawing_shapes(&self, drawing: &Rec, outline: &[OutlineText]) -> Vec<Shape> {
616        let data = &self.document;
617        let mut shapes = Vec::new();
618        for dg in children(data, drawing)
619            .iter()
620            .filter(|c| c.kind == OA_DG_CONTAINER)
621        {
622            for child in children(data, dg) {
623                match child.kind {
624                    OA_SPGR_CONTAINER => {
625                        let members = children(data, &child);
626                        for (position, member) in members.iter().enumerate() {
627                            if position == 0 {
628                                continue;
629                            }
630                            if let Some(shape) = self.shape_of(member, outline) {
631                                shapes.push(shape);
632                            }
633                        }
634                    }
635                    OA_SP_CONTAINER => {
636                        if let Some(shape) = self.shape_of(&child, outline) {
637                            shapes.push(shape);
638                        }
639                    }
640                    _ => {}
641                }
642            }
643        }
644        shapes
645    }
646
647    /// A shape (`OfficeArtSpContainer`) or group (`OfficeArtSpgrContainer`).
648    fn shape_of(&self, rec: &Rec, outline: &[OutlineText]) -> Option<Shape> {
649        let data = &self.document;
650        if rec.kind == OA_SPGR_CONTAINER {
651            let members = children(data, rec);
652            let group_shape = members
653                .first()
654                .and_then(|first| self.shape_of(first, outline));
655            let children_shapes: Vec<Shape> = members
656                .iter()
657                .skip(1)
658                .filter_map(|member| self.shape_of(member, outline))
659                .collect();
660            let mut group = group_shape.unwrap_or_else(|| blank_shape(0));
661            group.content = Content::Group(children_shapes, None);
662            return Some(group);
663        }
664        if rec.kind != OA_SP_CONTAINER {
665            return None;
666        }
667        let parts = children(data, rec);
668        let fsp = parts.iter().find(|p| p.kind == OA_FSP)?;
669        let spid = u32_at(data, fsp.start).unwrap_or(0);
670        let flags = u32_at(data, fsp.start + 4).unwrap_or(0);
671        let deleted = flags & 0x0008 != 0;
672        let patriarch = flags & 0x0004 != 0;
673        if deleted || patriarch {
674            return None;
675        }
676        let ole = flags & 0x0010 != 0;
677        let connector = flags & 0x0100 != 0;
678        let flip_h = flags & 0x0040 != 0;
679        let flip_v = flags & 0x0080 != 0;
680        let shape_type = fsp.instance;
681        let mut shape = blank_shape(spid);
682        shape.text_box = shape_type == MSOSPT_TEXT_BOX;
683        let mut pib = None;
684        for fopt in parts
685            .iter()
686            .filter(|p| matches!(p.kind, OA_FOPT | OA_SECONDARY_FOPT | OA_TERTIARY_FOPT))
687        {
688            let props = parse_properties(data, fopt);
689            for prop in props {
690                match prop.id {
691                    0x0104 if !prop.complex => pib = Some(prop.value),
692                    0x0380 => {
693                        if let Some(name) = prop.text(data) {
694                            shape.name = name;
695                        }
696                    }
697                    0x0381 => {
698                        shape.description = prop.text(data).filter(|text| !text.is_empty());
699                    }
700                    _ => {}
701                }
702            }
703        }
704        let mut transform = None;
705        for anchor in parts
706            .iter()
707            .filter(|p| matches!(p.kind, OA_CLIENT_ANCHOR | OA_CHILD_ANCHOR))
708        {
709            let (top, left, right, bottom) = match anchor.len {
710                8 => (
711                    i64::from(u16_at(data, anchor.start).unwrap_or(0) as i16),
712                    i64::from(u16_at(data, anchor.start + 2).unwrap_or(0) as i16),
713                    i64::from(u16_at(data, anchor.start + 4).unwrap_or(0) as i16),
714                    i64::from(u16_at(data, anchor.start + 6).unwrap_or(0) as i16),
715                ),
716                16 => match anchor.kind {
717                    OA_CLIENT_ANCHOR => (
718                        i64::from(i32_at(data, anchor.start).unwrap_or(0)),
719                        i64::from(i32_at(data, anchor.start + 4).unwrap_or(0)),
720                        i64::from(i32_at(data, anchor.start + 8).unwrap_or(0)),
721                        i64::from(i32_at(data, anchor.start + 12).unwrap_or(0)),
722                    ),
723                    _ => (
724                        i64::from(i32_at(data, anchor.start + 4).unwrap_or(0)),
725                        i64::from(i32_at(data, anchor.start).unwrap_or(0)),
726                        i64::from(i32_at(data, anchor.start + 8).unwrap_or(0)),
727                        i64::from(i32_at(data, anchor.start + 12).unwrap_or(0)),
728                    ),
729                },
730                _ => continue,
731            };
732            if anchor.kind == OA_CLIENT_ANCHOR || transform.is_none() {
733                transform = Some(Transform {
734                    x: master_units_to_emu(left),
735                    y: master_units_to_emu(top),
736                    cx: master_units_to_emu(right - left),
737                    cy: master_units_to_emu(bottom - top),
738                    rot: 0,
739                    flip_h,
740                    flip_v,
741                });
742            }
743        }
744        shape.transform = transform;
745        let mut placeholder = None;
746        for client_data in parts.iter().filter(|p| p.kind == OA_CLIENT_DATA) {
747            for atom in children(data, client_data) {
748                if atom.kind != RT_PLACEHOLDER_ATOM {
749                    continue;
750                }
751                let position = u32_at(data, atom.start).unwrap_or(NO_PLACEHOLDER);
752                if position == NO_PLACEHOLDER {
753                    continue;
754                }
755                let placement = data.get(atom.start + 4).copied().unwrap_or(0);
756                placeholder = Some(Placeholder {
757                    kind: placeholder_kind(placement),
758                    idx: position,
759                });
760            }
761        }
762        let mut body: Option<(u32, TextBody)> = None;
763        for textbox in parts.iter().filter(|p| p.kind == OA_CLIENT_TEXTBOX) {
764            let atoms = children(data, textbox);
765            if let Some(reference) = atoms.iter().find(|a| a.kind == RT_OUTLINE_TEXT_REF_ATOM) {
766                let index = i32_at(data, reference.start).unwrap_or(-1);
767                if index >= 0 {
768                    if let Some(text) = outline.get(index as usize) {
769                        body = Some((text.text_type, text.body.clone()));
770                    }
771                }
772                continue;
773            }
774            if let Some(position) = atoms.iter().position(|a| a.kind == RT_TEXT_HEADER_ATOM) {
775                let text_type = u32_at(data, atoms[position].start).unwrap_or(4);
776                let parsed = parse_text_body(data, &atoms[position + 1..]);
777                body = Some((text_type, parsed));
778            }
779        }
780        if placeholder.is_none() {
781            if let Some((text_type, _)) = &body {
782                placeholder = match text_type {
783                    0 => Some(Placeholder {
784                        kind: PlaceholderKind::Title,
785                        idx: 0,
786                    }),
787                    6 => Some(Placeholder {
788                        kind: PlaceholderKind::CenterTitle,
789                        idx: 0,
790                    }),
791                    _ => None,
792                };
793            }
794        }
795        shape.placeholder = placeholder;
796        let picture = pib.filter(|index| *index > 0).map(|index| Picture {
797            embed: Some(index.to_string()),
798            link: None,
799            media: None,
800        });
801        shape.content = match (body, picture, ole, connector) {
802            (Some((_, text)), _, _, _) if !text.is_empty() => Content::Text(text),
803            (_, Some(picture), true, _) => Content::Ole(OleObject {
804                prog_id: None,
805                rel_id: None,
806                preview: Some(picture),
807            }),
808            (_, Some(picture), false, _) => Content::Picture(picture),
809            (_, None, true, _) => Content::Ole(OleObject {
810                prog_id: None,
811                rel_id: None,
812                preview: None,
813            }),
814            (_, None, false, true) => Content::Connector,
815            (Some((_, text)), None, false, false) => Content::Text(text),
816            (None, None, false, false) => Content::Text(TextBody::default()),
817        };
818        Some(shape)
819    }
820
821    /// The picture with one-based blip index `index`, decoded to its file bytes.
822    pub fn picture(&self, index: usize) -> Result<Option<LegacyPicture>> {
823        let Some(source) = index
824            .checked_sub(1)
825            .and_then(|position| self.blips.get(position))
826        else {
827            return Ok(None);
828        };
829        let (data, at): (&[u8], usize) = match source {
830            BlipSource::Delay(at) => (&self.pictures, *at),
831            BlipSource::Embedded(at) => (&self.document, *at),
832            BlipSource::Missing => return Ok(None),
833        };
834        let Some(rec) = header_at(data, at) else {
835            return Ok(None);
836        };
837        let end = rec.end().min(data.len());
838        if rec.start > end {
839            return Ok(None);
840        }
841        let payload = &data[rec.start..end];
842        Ok(decode_blip(rec.kind, rec.instance, payload))
843    }
844
845    /// The media type of the picture with one-based blip index `index`.
846    pub fn picture_content_type(&self, index: usize) -> Option<&'static str> {
847        let source = self.blips.get(index.checked_sub(1)?)?;
848        let (data, at): (&[u8], usize) = match source {
849            BlipSource::Delay(at) => (&self.pictures, *at),
850            BlipSource::Embedded(at) => (&self.document, *at),
851            BlipSource::Missing => return None,
852        };
853        blip_content_type(header_at(data, at)?.kind)
854    }
855}
856
857fn blank_shape(id: u32) -> Shape {
858    Shape {
859        id,
860        name: format!("Shape {id}"),
861        hidden: false,
862        description: None,
863        hyperlink: None,
864        placeholder: None,
865        transform: None,
866        text_box: false,
867        content: Content::Text(TextBody::default()),
868    }
869}
870
871/// Maps a `PlaceholderEnum` value onto the PresentationML placeholder kinds.
872fn placeholder_kind(placement: u8) -> PlaceholderKind {
873    match placement {
874        0x01 | 0x0d | 0x11 => PlaceholderKind::Title,
875        0x03 | 0x0f => PlaceholderKind::CenterTitle,
876        0x04 | 0x10 => PlaceholderKind::Subtitle,
877        0x02 | 0x06 | 0x0c | 0x0e | 0x12 => PlaceholderKind::Body,
878        0x07 => PlaceholderKind::DateTime,
879        0x08 => PlaceholderKind::SlideNumber,
880        0x09 => PlaceholderKind::Footer,
881        0x0a => PlaceholderKind::Header,
882        0x05 | 0x0b => PlaceholderKind::SlideImage,
883        0x13 | 0x19 => PlaceholderKind::Object,
884        0x14 => PlaceholderKind::Chart,
885        0x15 => PlaceholderKind::Table,
886        0x16 => PlaceholderKind::ClipArt,
887        0x17 => PlaceholderKind::Diagram,
888        0x18 => PlaceholderKind::Media,
889        0x1a => PlaceholderKind::Picture,
890        _ => PlaceholderKind::Other,
891    }
892}
893
894/// One entry of an `OfficeArtFOPT` property table.
895struct Property {
896    id: u16,
897    complex: bool,
898    value: u32,
899    /// `(start, len)` of the complex data in the stream.
900    data: Option<(usize, usize)>,
901}
902
903impl Property {
904    /// The complex data as a UTF-16 string without its terminator.
905    fn text(&self, stream: &[u8]) -> Option<String> {
906        let (start, len) = self.data?;
907        let raw = stream.get(start..start + len)?;
908        Some(utf16_string(raw))
909    }
910}
911
912fn utf16_string(raw: &[u8]) -> String {
913    let (pairs, _) = raw.as_chunks::<2>();
914    let units: Vec<u16> = pairs
915        .iter()
916        .map(|pair| u16::from_le_bytes(*pair))
917        .take_while(|&unit| unit != 0)
918        .collect();
919    String::from_utf16_lossy(&units)
920}
921
922fn parse_properties(data: &[u8], fopt: &Rec) -> Vec<Property> {
923    let count = usize::from(fopt.instance);
924    let mut props = Vec::with_capacity(count);
925    let mut cursor = fopt.start;
926    for _ in 0..count {
927        if cursor + 6 > fopt.end() {
928            break;
929        }
930        let word = u16_at(data, cursor).unwrap_or(0);
931        props.push(Property {
932            id: word & 0x3fff,
933            complex: word & 0x8000 != 0,
934            value: u32_at(data, cursor + 2).unwrap_or(0),
935            data: None,
936        });
937        cursor += 6;
938    }
939    for prop in props.iter_mut().filter(|p| p.complex) {
940        let len = prop.value as usize;
941        if cursor + len > fopt.end() {
942            break;
943        }
944        prop.data = Some((cursor, len));
945        cursor += len;
946    }
947    props
948}
949
950/// Formatting spans of a text body, one entry per character.
951struct Spans {
952    level: Vec<u8>,
953    bullet: Vec<Bullet>,
954    bold: Vec<Option<bool>>,
955    italic: Vec<Option<bool>>,
956    underline: Vec<Option<bool>>,
957}
958
959impl Spans {
960    fn new(len: usize) -> Self {
961        Self {
962            level: vec![0; len],
963            bullet: vec![Bullet::Inherited; len],
964            bold: vec![None; len],
965            italic: vec![None; len],
966            underline: vec![None; len],
967        }
968    }
969}
970
971/// Builds a text body from the records that follow a `TextHeaderAtom`.
972fn parse_text_body(data: &[u8], atoms: &[Rec]) -> TextBody {
973    let mut chars: Vec<char> = Vec::new();
974    for atom in atoms {
975        match atom.kind {
976            RT_TEXT_CHARS_ATOM => {
977                let raw = &data[atom.start..atom.end().min(data.len())];
978                let (pairs, _) = raw.as_chunks::<2>();
979                let units: Vec<u16> = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect();
980                chars = char::decode_utf16(units)
981                    .map(|unit| unit.unwrap_or(char::REPLACEMENT_CHARACTER))
982                    .collect();
983                break;
984            }
985            RT_TEXT_BYTES_ATOM => {
986                chars = data[atom.start..atom.end().min(data.len())]
987                    .iter()
988                    .map(|&byte| char::from(byte))
989                    .collect();
990                break;
991            }
992            _ => {}
993        }
994    }
995    let total = chars.len() + 1;
996    let mut spans = Spans::new(total);
997    if let Some(style) = atoms.iter().find(|a| a.kind == RT_STYLE_TEXT_PROP_ATOM) {
998        apply_style_runs(data, style, &mut spans);
999    } else if let Some(master) = atoms.iter().find(|a| a.kind == RT_MASTER_TEXT_PROP_ATOM) {
1000        let mut cursor = master.start;
1001        let mut position = 0usize;
1002        while cursor + 6 <= master.end().min(data.len()) && position < total {
1003            let count = u32_at(data, cursor).unwrap_or(0) as usize;
1004            let level = u16_at(data, cursor + 4).unwrap_or(0).min(8) as u8;
1005            let end = (position + count).min(total);
1006            for slot in &mut spans.level[position..end] {
1007                *slot = level;
1008            }
1009            position = end;
1010            cursor += 6;
1011        }
1012    }
1013    let mut body = TextBody::default();
1014    let mut start = 0usize;
1015    let mut position = 0usize;
1016    while position <= chars.len() {
1017        let at_end = position == chars.len();
1018        if at_end || chars[position] == '\r' {
1019            body.paragraphs
1020                .push(paragraph_from(&chars[start..position], start, &spans));
1021            start = position + 1;
1022        }
1023        position += 1;
1024    }
1025    if body.paragraphs.len() > 1
1026        && body.paragraphs.last().is_some_and(Paragraph::is_empty)
1027        && chars.last() == Some(&'\r')
1028    {
1029        body.paragraphs.pop();
1030    }
1031    body
1032}
1033
1034/// A paragraph from `chars` (starting at text position `offset`): runs split
1035/// where bold, italic or underline change, vertical tabs as line breaks.
1036fn paragraph_from(chars: &[char], offset: usize, spans: &Spans) -> Paragraph {
1037    let level = spans.level.get(offset).copied().unwrap_or(0);
1038    let bullet = spans
1039        .bullet
1040        .get(offset)
1041        .cloned()
1042        .unwrap_or(Bullet::Inherited);
1043    let mut runs: Vec<Run> = Vec::new();
1044    let mut current = String::new();
1045    let mut current_props: Option<RunProps> = None;
1046    let flush = |runs: &mut Vec<Run>, text: &mut String, props: &Option<RunProps>| {
1047        if text.is_empty() {
1048            return;
1049        }
1050        runs.push(Run {
1051            kind: RunKind::Text,
1052            text: std::mem::take(text),
1053            props: props.clone().unwrap_or_default(),
1054        });
1055    };
1056    for (i, &ch) in chars.iter().enumerate() {
1057        let position = offset + i;
1058        if ch == '\u{b}' {
1059            flush(&mut runs, &mut current, &current_props);
1060            runs.push(Run {
1061                kind: RunKind::LineBreak,
1062                text: String::new(),
1063                props: RunProps::default(),
1064            });
1065            continue;
1066        }
1067        if ch.is_control() && ch != '\t' {
1068            continue;
1069        }
1070        let props = RunProps {
1071            bold: spans.bold.get(position).copied().flatten(),
1072            italic: spans.italic.get(position).copied().flatten(),
1073            underline: spans.underline.get(position).copied().flatten(),
1074            ..RunProps::default()
1075        };
1076        if current_props.as_ref() != Some(&props) {
1077            flush(&mut runs, &mut current, &current_props);
1078            current_props = Some(props);
1079        }
1080        current.push(ch);
1081    }
1082    flush(&mut runs, &mut current, &current_props);
1083    Paragraph {
1084        level,
1085        bullet,
1086        runs,
1087    }
1088}
1089
1090/// Reads the paragraph and character runs of a `StyleTextPropAtom`.
1091fn apply_style_runs(data: &[u8], atom: &Rec, spans: &mut Spans) {
1092    let end = atom.end().min(data.len());
1093    let total = spans.level.len();
1094    let mut cursor = atom.start;
1095    let mut position = 0usize;
1096    while position < total && cursor + 6 <= end {
1097        let count = u32_at(data, cursor).unwrap_or(0) as usize;
1098        let level = u16_at(data, cursor + 4).unwrap_or(0).min(8) as u8;
1099        let masks = u32_at(data, cursor + 6).unwrap_or(0);
1100        cursor += 10;
1101        let mut bullet = Bullet::Inherited;
1102        let mut bullet_char = None;
1103        let mut has_bullet = None;
1104        if masks & 0x0000_000f != 0 {
1105            let flags = u16_at(data, cursor).unwrap_or(0);
1106            if masks & 0x0000_0001 != 0 {
1107                has_bullet = Some(flags & 0x0001 != 0);
1108            }
1109            cursor += 2;
1110        }
1111        if masks & 0x0000_0080 != 0 {
1112            bullet_char = u16_at(data, cursor).and_then(|unit| char::from_u32(u32::from(unit)));
1113            cursor += 2;
1114        }
1115        if masks & 0x0000_0010 != 0 {
1116            cursor += 2;
1117        }
1118        if masks & 0x0000_0040 != 0 {
1119            cursor += 2;
1120        }
1121        if masks & 0x0000_0020 != 0 {
1122            cursor += 4;
1123        }
1124        for bit in [0x0800u32, 0x1000, 0x2000, 0x4000, 0x0100, 0x0400, 0x8000] {
1125            if masks & bit != 0 {
1126                cursor += 2;
1127            }
1128        }
1129        if masks & 0x0010_0000 != 0 {
1130            let tabs = u16_at(data, cursor).unwrap_or(0) as usize;
1131            cursor += 2 + tabs * 4;
1132        }
1133        if masks & 0x0001_0000 != 0 {
1134            cursor += 2;
1135        }
1136        if masks & 0x000e_0000 != 0 {
1137            cursor += 2;
1138        }
1139        if masks & 0x0020_0000 != 0 {
1140            cursor += 2;
1141        }
1142        match has_bullet {
1143            Some(true) => bullet = Bullet::Char(bullet_char.unwrap_or('\u{2022}').to_string()),
1144            Some(false) => bullet = Bullet::None,
1145            None => {}
1146        }
1147        let run_end = (position + count).min(total);
1148        for i in position..run_end {
1149            spans.level[i] = level;
1150            spans.bullet[i] = bullet.clone();
1151        }
1152        position = run_end;
1153        if count == 0 {
1154            break;
1155        }
1156    }
1157    position = 0;
1158    while position < total && cursor + 8 <= end {
1159        let count = u32_at(data, cursor).unwrap_or(0) as usize;
1160        let masks = u32_at(data, cursor + 4).unwrap_or(0);
1161        cursor += 8;
1162        let mut bold = None;
1163        let mut italic = None;
1164        let mut underline = None;
1165        if masks & 0x0000_3ea7 != 0 {
1166            let style = u16_at(data, cursor).unwrap_or(0);
1167            if masks & 0x0000_0001 != 0 {
1168                bold = Some(style & 0x0001 != 0);
1169            }
1170            if masks & 0x0000_0002 != 0 {
1171                italic = Some(style & 0x0002 != 0);
1172            }
1173            if masks & 0x0000_0004 != 0 {
1174                underline = Some(style & 0x0004 != 0);
1175            }
1176            cursor += 2;
1177        }
1178        for bit in [
1179            0x0001_0000u32,
1180            0x0020_0000,
1181            0x0040_0000,
1182            0x0080_0000,
1183            0x0002_0000,
1184        ] {
1185            if masks & bit != 0 {
1186                cursor += 2;
1187            }
1188        }
1189        if masks & 0x0004_0000 != 0 {
1190            cursor += 4;
1191        }
1192        if masks & 0x0008_0000 != 0 {
1193            cursor += 2;
1194        }
1195        let run_end = (position + count).min(total);
1196        for i in position..run_end {
1197            spans.bold[i] = bold;
1198            spans.italic[i] = italic;
1199            spans.underline[i] = underline;
1200        }
1201        position = run_end;
1202        if count == 0 {
1203            break;
1204        }
1205    }
1206}
1207
1208fn blip_content_type(kind: u16) -> Option<&'static str> {
1209    Some(match kind {
1210        0xf01a => "image/x-emf",
1211        0xf01b => "image/x-wmf",
1212        0xf01c => "image/x-pict",
1213        0xf01d | 0xf02a => "image/jpeg",
1214        0xf01e => "image/png",
1215        0xf01f => "image/bmp",
1216        0xf029 => "image/tiff",
1217        _ => return None,
1218    })
1219}
1220
1221/// The file bytes of a BLIP record's payload: UIDs stripped, metafiles
1222/// inflated when compressed, DIBs given a bitmap file header.
1223fn decode_blip(kind: u16, instance: u16, payload: &[u8]) -> Option<LegacyPicture> {
1224    let content_type = blip_content_type(kind)?;
1225    let metafile = matches!(kind, 0xf01a..=0xf01c);
1226    let two_uids = match kind {
1227        0xf01a => instance == 0x3d5,
1228        0xf01b => instance == 0x217,
1229        0xf01c => instance == 0x543,
1230        0xf01d | 0xf02a => matches!(instance, 0x46b | 0x6e3),
1231        0xf01e => instance == 0x6e1,
1232        0xf01f => instance == 0x7a9,
1233        0xf029 => instance == 0x6e5,
1234        _ => false,
1235    };
1236    let uid_len = match two_uids {
1237        true => 32,
1238        false => 16,
1239    };
1240    if metafile {
1241        let header = payload.get(uid_len..uid_len + 34)?;
1242        let uncompressed =
1243            u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
1244        let compression = header[32];
1245        let body = payload.get(uid_len + 34..)?;
1246        let bytes = match compression {
1247            0x00 => {
1248                let stream = body.get(2..)?;
1249                let mut out = Vec::new();
1250                crate::inflate::inflate(stream, uncompressed, &mut out).ok()?;
1251                out
1252            }
1253            _ => body.to_vec(),
1254        };
1255        return Some(LegacyPicture {
1256            content_type,
1257            bytes,
1258        });
1259    }
1260    let body = payload.get(uid_len + 1..)?;
1261    let bytes = match kind {
1262        0xf01f => dib_to_bmp(body),
1263        _ => body.to_vec(),
1264    };
1265    Some(LegacyPicture {
1266        content_type,
1267        bytes,
1268    })
1269}
1270
1271/// Prepends a BITMAPFILEHEADER to a device-independent bitmap.
1272fn dib_to_bmp(dib: &[u8]) -> Vec<u8> {
1273    let header_size = u32_at(dib, 0).unwrap_or(40) as usize;
1274    let bit_count = u16_at(dib, 14).unwrap_or(24);
1275    let compression = u32_at(dib, 16).unwrap_or(0);
1276    let colors_used = u32_at(dib, 32).unwrap_or(0) as usize;
1277    let palette_entries = match colors_used {
1278        0 => 1usize << bit_count.min(8),
1279        n => n,
1280    };
1281    let palette = match bit_count {
1282        1..=8 => palette_entries * 4,
1283        _ => 0,
1284    };
1285    let masks = match (compression, header_size) {
1286        (3, 40) => 12,
1287        _ => 0,
1288    };
1289    let offset = 14 + header_size + palette + masks;
1290    let mut out = Vec::with_capacity(14 + dib.len());
1291    out.extend_from_slice(b"BM");
1292    out.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
1293    out.extend_from_slice(&0u32.to_le_bytes());
1294    out.extend_from_slice(&(offset as u32).to_le_bytes());
1295    out.extend_from_slice(dib);
1296    out
1297}
1298
1299/// Opens the compound file at `source` as a legacy deck.
1300pub fn open_compound(compound: &Compound) -> Result<Arc<LegacyDeck>> {
1301    LegacyDeck::open(compound).map(Arc::new)
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    #[test]
1309    fn text_bodies_split_paragraphs_and_read_levels_and_bullets() {
1310        // TextBytesAtom "First\rSecond\x0bline" + StyleTextPropAtom with two PF runs and one CF run.
1311        let text = b"First\rSecond\x0bline";
1312        let mut stream = Vec::new();
1313        let text_at = stream.len();
1314        stream.extend_from_slice(&[0x00, 0x00, 0xa8, 0x0f]);
1315        stream.extend_from_slice(&(text.len() as u32).to_le_bytes());
1316        stream.extend_from_slice(text);
1317        let style_at = stream.len();
1318        let mut style = Vec::new();
1319        // PF run 1: 6 chars ("First\r"), level 0, masks hasBullet with fHasBullet = 0
1320        style.extend_from_slice(&6u32.to_le_bytes());
1321        style.extend_from_slice(&0u16.to_le_bytes());
1322        style.extend_from_slice(&0x0000_0001u32.to_le_bytes());
1323        style.extend_from_slice(&0u16.to_le_bytes());
1324        // PF run 2: remaining 12 chars (11 + terminator), level 1, masks hasBullet|bulletChar with bullet '-'
1325        style.extend_from_slice(&12u32.to_le_bytes());
1326        style.extend_from_slice(&1u16.to_le_bytes());
1327        style.extend_from_slice(&0x0000_0081u32.to_le_bytes());
1328        style.extend_from_slice(&1u16.to_le_bytes());
1329        style.extend_from_slice(&(b'-' as u16).to_le_bytes());
1330        // CF run: 5 chars bold, then 13 chars plain
1331        style.extend_from_slice(&5u32.to_le_bytes());
1332        style.extend_from_slice(&0x0000_0001u32.to_le_bytes());
1333        style.extend_from_slice(&0x0001u16.to_le_bytes());
1334        style.extend_from_slice(&13u32.to_le_bytes());
1335        style.extend_from_slice(&0x0000_0001u32.to_le_bytes());
1336        style.extend_from_slice(&0x0000u16.to_le_bytes());
1337        stream.extend_from_slice(&[0x00, 0x00, 0xa1, 0x0f]);
1338        stream.extend_from_slice(&(style.len() as u32).to_le_bytes());
1339        stream.extend_from_slice(&style);
1340        let atoms = records(&stream, 0, stream.len());
1341        assert_eq!(atoms.len(), 2);
1342        assert_eq!(atoms[0].start, text_at + 8);
1343        assert_eq!(atoms[1].start, style_at + 8);
1344        let body = parse_text_body(&stream, &atoms);
1345        assert_eq!(body.paragraphs.len(), 2);
1346        assert_eq!(body.paragraphs[0].text(), "First");
1347        assert_eq!(body.paragraphs[0].level, 0);
1348        assert_eq!(body.paragraphs[0].bullet, Bullet::None);
1349        assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
1350        assert_eq!(body.paragraphs[1].text(), "Second\nline");
1351        assert_eq!(body.paragraphs[1].level, 1);
1352        assert_eq!(body.paragraphs[1].bullet, Bullet::Char("-".into()));
1353        assert_eq!(body.paragraphs[1].runs[0].props.bold, Some(false));
1354    }
1355
1356    #[test]
1357    fn property_tables_read_complex_strings_after_the_fixed_entries() {
1358        let name: Vec<u8> = "Box\0"
1359            .encode_utf16()
1360            .flat_map(|u| u.to_le_bytes())
1361            .collect();
1362        let mut stream = vec![0x33, 0x00, 0x0b, 0xf0];
1363        let body_len = 12 + name.len();
1364        stream.extend_from_slice(&(body_len as u32).to_le_bytes());
1365        stream.extend_from_slice(&(0x0104u16 | 0x4000).to_le_bytes());
1366        stream.extend_from_slice(&3u32.to_le_bytes());
1367        stream.extend_from_slice(&(0x0380u16 | 0x8000).to_le_bytes());
1368        stream.extend_from_slice(&(name.len() as u32).to_le_bytes());
1369        stream.extend_from_slice(&name);
1370        let mut fopt = header_at(&stream, 0).unwrap();
1371        fopt.instance = 2;
1372        let props = parse_properties(&stream, &fopt);
1373        assert_eq!(props.len(), 2);
1374        assert_eq!(props[0].id, 0x0104);
1375        assert_eq!(props[0].value, 3);
1376        assert!(!props[0].complex);
1377        assert_eq!(props[1].text(&stream).as_deref(), Some("Box"));
1378    }
1379
1380    #[test]
1381    fn master_units_convert_to_emu() {
1382        assert_eq!(master_units_to_emu(576), 914_400);
1383        assert_eq!(master_units_to_emu(5760), 9_144_000);
1384    }
1385
1386    #[test]
1387    fn dib_gets_a_file_header() {
1388        let mut dib = vec![0u8; 40];
1389        dib[0] = 40;
1390        dib[14] = 24;
1391        dib.extend_from_slice(&[1, 2, 3]);
1392        let bmp = dib_to_bmp(&dib);
1393        assert_eq!(&bmp[..2], b"BM");
1394        assert_eq!(u32::from_le_bytes([bmp[10], bmp[11], bmp[12], bmp[13]]), 54);
1395        assert_eq!(bmp.len(), 14 + dib.len());
1396    }
1397}