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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-15 02:35:50 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Brandon Thomas
parent 76037a77ac
commit 5fffd4cacd
3 changed files with 162 additions and 20 deletions
+58
View File
@@ -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<u8> {
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<u8>, 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})");
}
}
+103 -19
View File
@@ -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<f64>) -> String {
let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64));
format!(
"<wp:docPr id=\"{n}\" name=\"Picture {n}\"/><wp:cNvGraphicFramePr/>\
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
<pic:blipFill><a:blip r:embed=\"rIdImg{n}\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>\
<pic:spPr><a:xfrm{rot}><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
</a:graphicData></a:graphic>"
)
}
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!("<w:r><w:pict><v:rect id=\"Image{shape_id}\" style=\"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page\" stroked=\"f\"><v:imagedata r:id=\"rIdImg{n}\"/><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>"));
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!(
"<w:r><w:drawing><wp:anchor distT=\"0\" distB=\"0\" distL=\"0\" distR=\"0\" simplePos=\"0\" relativeHeight=\"{}\" behindDoc=\"1\" locked=\"0\" layoutInCell=\"1\" allowOverlap=\"1\">\
<wp:simplePos x=\"0\" y=\"0\"/><wp:positionH relativeFrom=\"page\"><wp:posOffset>{}</wp:posOffset></wp:positionH><wp:positionV relativeFrom=\"page\"><wp:posOffset>{}</wp:posOffset></wp:positionV>\
<wp:extent cx=\"{}\" cy=\"{}\"/><wp:effectExtent l=\"0\" t=\"0\" r=\"0\" b=\"0\"/><wp:wrapNone/>",
n.saturating_add(1),
emu(x),
emu(y),
emu(w),
emu(h),
));
s.push_str(&pic);
s.push_str("</wp:anchor></w:drawing></w:r>");
media.push((name, im));
}
s.push_str("</w:p>");
s
@@ -612,21 +660,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
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!(
"<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/><wp:docPr id=\"{n}\" name=\"Picture {n}\"/>\
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
<pic:blipFill><a:blip r:embed=\"rIdImg{n}\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>\
<pic:spPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
</a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"
));
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!("<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/>"));
body.push_str(&picture_xml(n, &name, (cx, cy), None));
body.push_str("</wp:inline></w:drawing></w:r></w:p>");
media.push((name, im));
}
}
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
}
@@ -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</w:t>").next().unwrap().rsplit("<v:rect ").next().unwrap();
assert!(watermark.contains("z-index:-2"), "watermark is behind foreground graphics");
let image = xml.split("<v:imagedata ").next().unwrap().rsplit("<v:rect ").next().unwrap();
assert!(image.contains("z-index:-1"), "foreground graphics cover the watermark");
// The picture is a DrawingML anchor behind the text (a VML rectangle shows no picture in Word).
assert!(!xml.contains("<v:imagedata"), "{xml}");
assert!(xml.contains("<wp:anchor ") && xml.contains("behindDoc=\"1\"") && xml.contains("<a:blip r:embed=\"rIdImg1\"/>"), "{xml}");
}
#[test]
@@ -1165,4 +1211,42 @@ mod tests {
let h = html(&[p], "Paper");
assert!(!h.contains("<table>"), "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("<wp:anchor "), placed, "{xml}");
let rels = part(&zip, "word/_rels/document.xml.rels");
let embed = xml.split("r:embed=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
let rel = rels.split(&format!("Id=\"{embed}\"")).nth(1).unwrap();
assert!(rel.contains("relationships/image\""), "{rels}");
let target = rel.split("Target=\"").nth(1).and_then(|t| t.split('"').next()).unwrap();
assert_eq!(target, "media/image1.jpg");
assert!(zip.windows(21).any(|w| w == b"word/media/image1.jpg"));
assert!(part(&zip, "[Content_Types].xml").contains("Extension=\"jpg\" ContentType=\"image/jpeg\""));
assert!(xml.matches("<wp:docPr ").count() == 1 && !xml.contains("<v:imagedata"));
}
}
#[test]
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
let mut p = page();
let one = p.images[0].clone();
p.images = vec![Image { bytes: Vec::new(), ..one.clone() }];
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
let zip = docx(&[p], "T");
let rels = part(&zip, "word/_rels/document.xml.rels");
assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA);
}
}
+1 -1
View File
@@ -6979,7 +6979,7 @@ status = "partial"
commands = ["export.docx"]
tools = ["doc_export_office"]
evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
[[feature]]
id = "create.export-rtf"