Skip to main content

extend_docx/doc/
images.rs

1//! Inline pictures: a run with fSpec and character 0x01 carries
2//! sprmCPicLocation, an offset to a PICF structure ([MS-DOC] 2.9.190) in the
3//! Data stream. Word 97+ stores the actual bitmap inside an OfficeArt
4//! ([MS-ODRAW]) record stream following the PICF header.
5
6#[derive(Debug, Clone)]
7pub struct InlineImage {
8    pub bytes: Vec<u8>,
9    pub extension: &'static str,
10    pub content_type: &'static str,
11    pub width_emu: u64,
12    pub height_emu: u64,
13}
14
15fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
16    bytes
17        .get(offset..offset + 2)
18        .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
19}
20
21fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
22    bytes
23        .get(offset..offset + 4)
24        .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
25}
26
27pub fn extract_image(data_stream: &[u8], fc: u32) -> Option<InlineImage> {
28    let base = fc as usize;
29    let lcb = read_u32(data_stream, base)? as usize;
30    let cb_header = read_u16(data_stream, base + 4)? as usize;
31    if cb_header < 0x2C || lcb < cb_header {
32        return None;
33    }
34    let mm = read_u16(data_stream, base + 6)?;
35
36    // Display size: dxaGoal/dyaGoal twips scaled by mx/my (per-mille).
37    let dxa_goal = read_u16(data_stream, base + 28)? as i64;
38    let dya_goal = read_u16(data_stream, base + 30)? as i64;
39    let mx = read_u16(data_stream, base + 32)? as i64;
40    let my = read_u16(data_stream, base + 34)? as i64;
41    let scale = |goal: i64, factor: i64| -> u64 {
42        let factor = if factor == 0 { 1000 } else { factor };
43        let twips = goal * factor / 1000;
44        (twips.max(0) as u64) * 635 // twips -> EMU
45    };
46    let width_emu = scale(dxa_goal, mx);
47    let height_emu = scale(dya_goal, my);
48
49    let payload = data_stream.get(base + cb_header..base + lcb)?;
50
51    // mm 0x64/0x66: OfficeArt inline shape; otherwise legacy metafile storage
52    // we do not support.
53    if mm != 0x64 && mm != 0x66 {
54        return None;
55    }
56
57    let payload = if mm == 0x66 {
58        payload.get(1 + usize::from(*payload.first()?)..)?
59    } else {
60        payload
61    };
62    let (bytes, extension, content_type) = find_blip(payload, 0)?;
63    Some(InlineImage {
64        bytes,
65        extension,
66        content_type,
67        width_emu,
68        height_emu,
69    })
70}
71
72/// Walks an OfficeArt record stream (descending into containers) until a
73/// renderable blip record is found.
74fn find_blip(bytes: &[u8], depth: usize) -> Option<(Vec<u8>, &'static str, &'static str)> {
75    if depth > 8 {
76        return None;
77    }
78    let mut pos = 0usize;
79    while pos + 8 <= bytes.len() {
80        let ver_inst = read_u16(bytes, pos)?;
81        let rec_type = read_u16(bytes, pos + 2)?;
82        let rec_len = read_u32(bytes, pos + 4)? as usize;
83        let body_start = pos + 8;
84        let is_container = ver_inst & 0x000F == 0x000F;
85        let instance = ver_inst >> 4;
86
87        if (0xF018..=0xF117).contains(&rec_type) {
88            let body = bytes.get(body_start..(body_start + rec_len).min(bytes.len()))?;
89            if let Some(result) = decode_blip(rec_type, instance, body) {
90                return Some(result);
91            }
92        } else if rec_type == 0xF007 {
93            // OfficeArtFBSE: 36-byte header, embedded blip may follow.
94            if rec_len > 36 {
95                let name_len = usize::from(*bytes.get(body_start + 33)?);
96                let body = bytes.get(body_start + 36 + name_len..body_start + rec_len)?;
97                if let Some(result) = find_blip(body, depth + 1) {
98                    return Some(result);
99                }
100            }
101        } else if is_container {
102            let body = bytes.get(body_start..(body_start + rec_len).min(bytes.len()))?;
103            if let Some(result) = find_blip(body, depth + 1) {
104                return Some(result);
105            }
106        }
107        pos = body_start + rec_len;
108    }
109    None
110}
111
112fn decode_blip(
113    rec_type: u16,
114    instance: u16,
115    body: &[u8],
116) -> Option<(Vec<u8>, &'static str, &'static str)> {
117    // Tagged bitmap blips: N UIDs (16 bytes each) + 1 tag byte + data. The
118    // "+1" instance variant carries a second UID.
119    let tagged = |base_instance: u16,
120                  extension: &'static str,
121                  content_type: &'static str|
122     -> Option<(Vec<u8>, &'static str, &'static str)> {
123        let uid_count = if instance == base_instance + 1 { 2 } else { 1 };
124        let data_start = uid_count * 16 + 1;
125        let data = body.get(data_start..)?;
126        if data.is_empty() {
127            return None;
128        }
129        Some((data.to_vec(), extension, content_type))
130    };
131
132    match rec_type {
133        0xF01D => tagged(0x46A, "jpeg", "image/jpeg"),
134        0xF02A => tagged(0x6E2, "jpeg", "image/jpeg"), // JPEG (CMYK variant record)
135        0xF01E => tagged(0x6E0, "png", "image/png"),
136        0xF029 => tagged(0x6E4, "tiff", "image/tiff"),
137        0xF01F => {
138            // DIB: BMP without the 14-byte file header; reconstruct it.
139            let uid_count = if instance == 0x7A9 { 2 } else { 1 };
140            let data_start = uid_count * 16 + 1;
141            let dib = body.get(data_start..)?;
142            Some((dib_to_bmp(dib)?, "bmp", "image/bmp"))
143        }
144        0xF01A | 0xF01B => {
145            let base_instance = if rec_type == 0xF01A { 0x3D4 } else { 0x216 };
146            let offset = if instance == base_instance + 1 {
147                32
148            } else {
149                16
150            };
151            let size = read_u32(body, offset)? as usize;
152            if size > 64 * 1024 * 1024 {
153                return None;
154            }
155            let saved = read_u32(body, offset + 28)? as usize;
156            let data = body.get(offset + 34..offset + 34 + saved)?;
157            let data = match *body.get(offset + 32)? {
158                0 => miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, size).ok()?,
159                0xFE => data.to_vec(),
160                _ => return None,
161            };
162            if data.len() != size {
163                return None;
164            }
165            let (extension, mime) = if rec_type == 0xF01A {
166                ("emf", "image/x-emf")
167            } else {
168                ("wmf", "image/x-wmf")
169            };
170            Some((data, extension, mime))
171        }
172        _ => None,
173    }
174}
175
176fn dib_to_bmp(dib: &[u8]) -> Option<Vec<u8>> {
177    let bi_size = read_u32(dib, 0)? as usize;
178    if bi_size < 40 || dib.len() < bi_size {
179        return None;
180    }
181    let bit_count = read_u16(dib, 14)? as usize;
182    let clr_used = read_u32(dib, 32)? as usize;
183    let palette_entries = if clr_used != 0 {
184        clr_used
185    } else if bit_count <= 8 {
186        1usize << bit_count
187    } else {
188        0
189    };
190    let pixel_offset = 14 + bi_size + palette_entries * 4;
191
192    let mut bmp = Vec::with_capacity(14 + dib.len());
193    bmp.extend_from_slice(b"BM");
194    bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
195    bmp.extend_from_slice(&0u32.to_le_bytes());
196    bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
197    bmp.extend_from_slice(dib);
198    Some(bmp)
199}
200
201pub(super) struct FloatingImage {
202    pub anchor: super::textboxes::Spa,
203    pub image: InlineImage,
204    pub tiled: bool,
205}
206
207type Blip = (Vec<u8>, &'static str, &'static str);
208
209fn records(mut bytes: &[u8]) -> impl Iterator<Item = (u16, u16, &[u8])> {
210    std::iter::from_fn(move || {
211        let header = read_u16(bytes, 0)?;
212        let kind = read_u16(bytes, 2)?;
213        let len = read_u32(bytes, 4)? as usize;
214        let body = bytes.get(8..8usize.checked_add(len)?)?;
215        bytes = &bytes[8 + len..];
216        Some((header, kind, body))
217    })
218}
219
220fn bstore(bytes: &[u8], word: &[u8], depth: usize, blips: &mut Vec<Option<Blip>>) {
221    if depth > 16 {
222        return;
223    }
224    for (header, kind, body) in records(bytes) {
225        if kind == 0xF007 {
226            let image = body
227                .get(33)
228                .and_then(|name_len| find_blip(body.get(36 + usize::from(*name_len)..)?, 0))
229                .or_else(|| {
230                    let offset = read_u32(body, 28)? as usize;
231                    let (header, kind, body) = records(word.get(offset..)?).next()?;
232                    decode_blip(kind, header >> 4, body)
233                });
234            blips.push(image);
235        } else if header & 15 == 15 {
236            bstore(body, word, depth + 1, blips);
237        }
238    }
239}
240
241fn picture_shapes(
242    bytes: &[u8],
243    depth: usize,
244    pictures: &mut std::collections::HashMap<u32, (usize, bool)>,
245) {
246    if depth > 16 {
247        return;
248    }
249    for (header, kind, body) in records(bytes) {
250        if kind == 0xF004 {
251            let mut id = None;
252            let mut pib = None;
253            let mut fill = None;
254            let mut fill_type = 0;
255            for (header, kind, data) in records(body) {
256                match kind {
257                    0xF00A => id = read_u32(data, 0),
258                    0xF00B | 0xF121 | 0xF122 => {
259                        for property in data.chunks_exact(6).take((header >> 4) as usize) {
260                            let op = read_u16(property, 0).unwrap_or(0);
261                            if op & 0xBFFF == 0x0104 {
262                                pib = read_u32(property, 2).and_then(|v| v.checked_sub(1));
263                            } else if op & 0xBFFF == 0x0186 {
264                                fill = read_u32(property, 2).and_then(|v| v.checked_sub(1));
265                            } else if op == 0x0180 {
266                                fill_type = read_u32(property, 2).unwrap_or(0);
267                            }
268                        }
269                    }
270                    _ => {}
271                }
272            }
273            if let (Some(id), Some((index, tiled))) = (
274                id,
275                pib.map(|p| (p, false))
276                    .or_else(|| fill.map(|p| (p, matches!(fill_type, 1 | 2)))),
277            ) {
278                pictures.insert(id, (index as usize, tiled));
279            }
280        } else if header & 15 == 15 {
281            picture_shapes(body, depth + 1, pictures);
282        }
283    }
284}
285
286fn shape_anchor(
287    bytes: &[u8],
288    parent: Option<(super::textboxes::Spa, [i32; 4])>,
289    anchors: &mut std::collections::HashMap<u32, super::textboxes::Spa>,
290) -> Option<super::textboxes::Spa> {
291    let id = records(bytes)
292        .find(|r| r.1 == 0xF00A)
293        .and_then(|r| read_u32(r.2, 0))?;
294    if let Some(anchor) = anchors.get(&id) {
295        return Some(*anchor);
296    }
297    let (mut anchor, bounds) = parent?;
298    let child = records(bytes).find(|r| r.1 == 0xF00F)?.2;
299    let rect: Vec<i32> = child
300        .chunks_exact(4)
301        .take(4)
302        .map(|b| i32::from_le_bytes(b.try_into().unwrap()))
303        .collect();
304    if rect.len() != 4 {
305        return None;
306    }
307    let map = |value: i32, origin: i32, end: i32, target: i32, target_end: i32| -> Option<i32> {
308        let extent = i64::from(end) - i64::from(origin);
309        if extent == 0 {
310            return None;
311        }
312        let delta = (i128::from(value) - i128::from(origin))
313            * (i128::from(target_end) - i128::from(target));
314        i32::try_from(i128::from(target) + delta / i128::from(extent)).ok()
315    };
316    let left = map(rect[0], bounds[0], bounds[2], anchor.left, anchor.right)?;
317    let right = map(rect[2], bounds[0], bounds[2], anchor.left, anchor.right)?;
318    let top = map(rect[1], bounds[1], bounds[3], anchor.top, anchor.bottom)?;
319    let bottom = map(rect[3], bounds[1], bounds[3], anchor.top, anchor.bottom)?;
320    anchor.shape_id = id;
321    anchor.left = left;
322    anchor.right = right;
323    anchor.top = top;
324    anchor.bottom = bottom;
325    anchors.insert(id, anchor);
326    Some(anchor)
327}
328
329fn group_anchors(
330    bytes: &[u8],
331    depth: usize,
332    parent: Option<(super::textboxes::Spa, [i32; 4])>,
333    anchors: &mut std::collections::HashMap<u32, super::textboxes::Spa>,
334) {
335    if depth > 16 {
336        return;
337    }
338    for (header, kind, body) in records(bytes) {
339        if kind == 0xF003 {
340            let group = records(body).find(|r| r.1 == 0xF004).map(|r| r.2);
341            let frame = group.and_then(|group| {
342                let anchor = shape_anchor(group, parent, anchors)?;
343                let bounds = records(group).find(|r| r.1 == 0xF009)?.2;
344                let mut rect = [0i32; 4];
345                for (index, value) in rect.iter_mut().enumerate() {
346                    *value = read_u32(bounds, index * 4)? as i32;
347                }
348                Some((anchor, rect))
349            });
350            group_anchors(body, depth + 1, frame, anchors);
351        } else if kind == 0xF004 {
352            shape_anchor(body, parent, anchors);
353        } else if header & 15 == 15 {
354            group_anchors(body, depth + 1, parent, anchors);
355        }
356    }
357}
358
359pub(super) fn shape_anchors(
360    doc: &super::DocFile,
361    header: bool,
362) -> std::collections::HashMap<u32, super::textboxes::Spa> {
363    use super::fib::fcidx;
364    let (index, base, len) = if header {
365        (
366            fcidx::PLCF_SPA_HDR,
367            doc.fib.ccp.hdd_start(),
368            doc.fib.ccp.hdd,
369        )
370    } else {
371        (fcidx::PLCF_SPA_MOM, 0, doc.fib.ccp.text)
372    };
373    let spas = doc
374        .fib
375        .table_slice(&doc.table, index)
376        .and_then(|plc| super::textboxes::parse_spas(plc, base, len).ok())
377        .unwrap_or_default();
378    let mut anchors = spas.into_iter().map(|spa| (spa.shape_id, spa)).collect();
379    let Some(data) = doc.fib.table_slice(&doc.table, fcidx::DGG_INFO) else {
380        return anchors;
381    };
382    let Some((_, _, global)) = records(data).next() else {
383        return anchors;
384    };
385    let mut cursor = 8 + global.len();
386    while let Some(&story) = data.get(cursor) {
387        cursor += 1;
388        let Some((_, kind, drawing)) = records(&data[cursor..]).next() else {
389            break;
390        };
391        cursor += 8 + drawing.len();
392        if kind != 0xF002 {
393            break;
394        }
395        if story == u8::from(header) {
396            group_anchors(drawing, 0, None, &mut anchors);
397        }
398    }
399    anchors
400}
401
402pub(super) fn floating_images(doc: &super::DocFile) -> Vec<FloatingImage> {
403    use super::fib::fcidx;
404    let Some(data) = doc.fib.table_slice(&doc.table, fcidx::DGG_INFO) else {
405        return Vec::new();
406    };
407    let Some((_, kind, global)) = records(data).next() else {
408        return Vec::new();
409    };
410    if kind != 0xF000 {
411        return Vec::new();
412    }
413    let mut blips = Vec::new();
414    bstore(global, &doc.word, 0, &mut blips);
415    let mut cursor = 8 + global.len();
416    let mut images = Vec::new();
417    while let Some(&story) = data.get(cursor) {
418        cursor += 1;
419        let Some((_, kind, drawing)) = records(&data[cursor..]).next() else {
420            break;
421        };
422        cursor += 8 + drawing.len();
423        if kind != 0xF002 {
424            break;
425        }
426        if story > 1 {
427            continue;
428        }
429        let anchors = shape_anchors(doc, story == 1);
430        let mut pictures = std::collections::HashMap::new();
431        picture_shapes(drawing, 0, &mut pictures);
432        for anchor in anchors.into_values() {
433            let Some(&(index, tiled)) = pictures.get(&anchor.shape_id) else {
434                continue;
435            };
436            let Some(Some((bytes, extension, content_type))) = blips.get(index) else {
437                continue;
438            };
439            let width = i64::from(anchor.right) - i64::from(anchor.left);
440            let height = i64::from(anchor.bottom) - i64::from(anchor.top);
441            if width <= 0 || height <= 0 {
442                continue;
443            }
444            images.push(FloatingImage {
445                anchor,
446                tiled,
447                image: InlineImage {
448                    bytes: bytes.clone(),
449                    extension,
450                    content_type,
451                    width_emu: width as u64 * 635,
452                    height_emu: height as u64 * 635,
453                },
454            });
455        }
456    }
457    images.sort_by_key(|image| (image.anchor.anchor_cp, image.anchor.shape_id));
458    images
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    fn build_picf_with_png(png: &[u8]) -> Vec<u8> {
466        // Escher: OfficeArtSpContainer (0xF004) wrapping a PNG blip (0xF01E).
467        let mut blip = Vec::new();
468        blip.extend_from_slice(&((0x6E0u16 << 4) | 0x0).to_le_bytes());
469        blip.extend_from_slice(&0xF01Eu16.to_le_bytes());
470        blip.extend_from_slice(&((16 + 1 + png.len()) as u32).to_le_bytes());
471        blip.extend_from_slice(&[0u8; 16]); // UID
472        blip.push(0xFF); // tag
473        blip.extend_from_slice(png);
474
475        let mut container = Vec::new();
476        container.extend_from_slice(&0x000Fu16.to_le_bytes());
477        container.extend_from_slice(&0xF004u16.to_le_bytes());
478        container.extend_from_slice(&(blip.len() as u32).to_le_bytes());
479        container.extend_from_slice(&blip);
480
481        let cb_header = 0x44usize;
482        let mut picf = vec![0u8; cb_header];
483        let lcb = (cb_header + container.len()) as u32;
484        picf[..4].copy_from_slice(&lcb.to_le_bytes());
485        picf[4..6].copy_from_slice(&(cb_header as u16).to_le_bytes());
486        picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // mm = MM_SHAPE
487        picf[28..30].copy_from_slice(&1440u16.to_le_bytes()); // dxaGoal 1 inch
488        picf[30..32].copy_from_slice(&720u16.to_le_bytes()); // dyaGoal 0.5 inch
489        picf[32..34].copy_from_slice(&1000u16.to_le_bytes()); // mx
490        picf[34..36].copy_from_slice(&500u16.to_le_bytes()); // my -> 0.25 inch
491        picf.extend_from_slice(&container);
492        picf
493    }
494
495    #[test]
496    fn extracts_png_blip_with_scaling() {
497        let png = b"\x89PNG\r\n\x1a\nfakepngdata";
498        let data_stream = build_picf_with_png(png);
499        let image = extract_image(&data_stream, 0).expect("image");
500        assert_eq!(image.extension, "png");
501        assert_eq!(image.bytes, png);
502        assert_eq!(image.width_emu, 1440 * 635);
503        assert_eq!(image.height_emu, 360 * 635);
504    }
505
506    #[test]
507    fn rejects_unsupported_storage() {
508        let mut picf = vec![0u8; 0x44];
509        picf[..4].copy_from_slice(&0x44u32.to_le_bytes());
510        picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
511        picf[6..8].copy_from_slice(&0x08u16.to_le_bytes());
512        assert!(extract_image(&picf, 0).is_none());
513    }
514}