From 5fffd4cacd60d8588e70bfaf840b910fdf109602 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Thu, 15 Oct 2026 08:35:50 +0200 Subject: [PATCH] Export to Word: place pictures on positioned pages as DrawingML anchors and bound the pictures (#1936) (#1981) A VML rectangle with an image did not show the picture in Word. Pictures on positioned pages are now wp:anchor drawings behind the text, sharing the picture XML with inline pictures. At most 1000 pictures / 512 MiB are embedded; empty images are skipped. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/tests.rs | 58 +++++++++++++++++ crates/export/src/lib.rs | 122 +++++++++++++++++++++++++++++------ parity/acrobat-features.toml | 2 +- 3 files changed, 162 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b803e98b0..07a84dad4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5016,3 +5016,61 @@ fn printing_as_image_sends_pictures_instead_of_pages() { let img = image::load_from_memory(&png).unwrap().to_rgba8(); assert!(img.pixels().all(|p| p[0] == p[1] && p[1] == p[2]), "every pixel grey"); } + +/// A one-page PDF: a JPEG logo (DCTDecode) at the top and a big text title below it. +fn jpeg_cover_pdf(logo_y: i32) -> Vec { + let mut jpeg = Vec::new(); + let img = image::RgbImage::from_fn(40, 20, |x, y| image::Rgb([(x * 6) as u8, (y * 12) as u8, 90])); + image::codecs::jpeg::JpegEncoder::new(&mut jpeg).encode_image(&img).unwrap(); + let content = format!( + "q 200 0 0 100 72 {logo_y} cm /Im1 Do Q BT /F1 30 Tf 72 580 Td (Annual Report) Tj ET \ +BT /F1 11 Tf 72 500 Td (Body text that is long enough to count as the body size of this page.) Tj ET" + ); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + let mut add = |pdf: &mut Vec, n: usize, body: &[u8]| { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b"\nendobj\n"); + }; + add(&mut pdf, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + add(&mut pdf, 2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + add( + &mut pdf, + 3, + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>", + ); + add(&mut pdf, 4, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).as_bytes()); + add(&mut pdf, 5, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + let mut obj = format!("<< /Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n", jpeg.len()).into_bytes(); + obj.extend_from_slice(&jpeg); + obj.extend_from_slice(b"\nendstream"); + add(&mut pdf, 6, &obj); + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); + for o in &offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); + pdf +} + +/// #1936: a JPEG logo on a cover page reaches the Word file as the unchanged JPEG, both where it +/// sits clear of the title (inline) and where it overlaps it (a positioned page). +#[test] +fn exporting_a_jpeg_logo_to_word_keeps_the_image() { + for (logo_y, overlaps) in [(650, false), (560, true)] { + let pdf = jpeg_cover_pdf(logo_y); + let mut s = Session::new(); + let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap(); + let d = s.get(id).unwrap(); + let pages = d.export_pages(); + assert_eq!((pages[0].images.len(), pages[0].images[0].ext), (1, "jpg"), "overlap {overlaps}"); + let jpeg = pages[0].images[0].bytes.clone(); + assert!(jpeg.starts_with(&[0xFF, 0xD8])); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}"); + assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})"); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 52836a05c..16dd2b06d 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -520,6 +520,36 @@ fn layout_number(n: f64) -> f64 { if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } } +/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and +/// a 16-bit entry count; a hostile PDF must not be able to overflow them). +const MAX_MEDIA: usize = 1000; +const MAX_MEDIA_BYTES: usize = 512 << 20; + +/// Whether `im` can still be embedded: it has data, and the package stays within its limits. +fn room_for(media: &[(String, &Image)], im: &Image) -> bool { + !im.bytes.is_empty() + && media.len() < MAX_MEDIA + && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES +} + +/// Points as EMU (12700 per point); non-finite values are 0. +fn emu(pt: f64) -> i64 { + if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } +} + +/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. +fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { + let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); + format!( + "\ +\ +\ +\ +\ +" + ) +} + fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a // page-sized watermark cannot push the invoice footer onto another page. @@ -552,13 +582,31 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha )); } for im in &page.images { + if !room_for(media, im) { + continue; + } *shape_id = shape_id.saturating_add(1); let n = media.len().saturating_add(1); let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); let rotation = layout_number(-im.rotation).rem_euclid(360.0); - s.push_str(&format!("")); - media.push((format!("image{n}.{}", im.ext), im)); + let name = format!("image{n}.{}", im.ext); + let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); + // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML + // rectangle with an image does not show the picture. + s.push_str(&format!( + "\ +{}{}\ +", + n.saturating_add(1), + emu(x), + emu(y), + emu(w), + emu(h), + )); + s.push_str(&pic); + s.push_str(""); + media.push((name, im)); } s.push_str(""); s @@ -612,21 +660,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { body.push_str(&docx_table(t)); } Item::Img(im) => { - let n = media.len() + 1; - let name = format!("image{n}.{}", im.ext); - // Size on the page, in EMU (12700 per point), at most the text width. - let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (text_w / w).min(1.0); - let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64); - body.push_str(&format!( - "\ -\ -\ -\ -\ -" - )); - media.push((name, im)); + if room_for(&media, im) { + let n = media.len() + 1; + let name = format!("image{n}.{}", im.ext); + // Size on the page, at most the text width. + let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); + let k = (text_w / w).min(1.0); + let (cx, cy) = (emu(w * k), emu(h * k)); + body.push_str(&format!("")); + body.push_str(&picture_xml(n, &name, (cx, cy), None)); + body.push_str(""); + media.push((name, im)); + } } Item::PageBreak => body.push_str(""), } @@ -907,8 +952,9 @@ mod tests { assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}"); let watermark = xml.split(">DRAFT").next().unwrap().rsplit(""), "{xml}"); } #[test] @@ -1165,4 +1211,42 @@ mod tests { let h = html(&[p], "Paper"); assert!(!h.contains(""), "tall two-column text must stay paragraphs: {h}"); } + + /// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a + /// file in the package, with a content type; JPEG pictures too, inline or placed. + #[test] + fn word_pictures_resolve_to_media_in_the_package() { + let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() }; + for placed in [false, true] { + let mut p = page(); + p.images = vec![jpeg.clone()]; + if placed { + p.images[0].rect = [72.0, 640.0, 272.0, 790.0]; + p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0]; + } + let zip = docx(&[p], "T"); + let xml = part(&zip, "word/document.xml"); + assert_eq!(xml.contains("