diff --git a/assets/bugfixes/issue-1366/after.jpg b/assets/bugfixes/issue-1366/after.jpg new file mode 100644 index 00000000..f1f1fd37 Binary files /dev/null and b/assets/bugfixes/issue-1366/after.jpg differ diff --git a/assets/bugfixes/issue-1366/before.jpg b/assets/bugfixes/issue-1366/before.jpg new file mode 100644 index 00000000..e650ea43 Binary files /dev/null and b/assets/bugfixes/issue-1366/before.jpg differ diff --git a/assets/bugfixes/issue-1366/gt.jpg b/assets/bugfixes/issue-1366/gt.jpg new file mode 100644 index 00000000..d7713de2 Binary files /dev/null and b/assets/bugfixes/issue-1366/gt.jpg differ diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs index 7c7dae9a..64e2a466 100644 --- a/crates/office2pdf/src/ir/elements.rs +++ b/crates/office2pdf/src/ir/elements.rs @@ -1314,8 +1314,8 @@ impl ImageCrop { pub struct ImageData { pub data: Vec, /// Clockwise rotation in degrees about the image's centre: the picture's - /// own `a:xfrm/@rot` composed with the angle of any rotated ancestor - /// ``. `None` means upright (issues #682, #895). + /// own `a:xfrm/@rot`, composed with the angle of any rotated ancestor + /// `` in PPTX. `None` means upright (issues #682, #895, #1366). pub rotation_deg: Option, /// Mirror a fixed PPTX picture left-to-right across its frame's vertical /// axis. PPTX stores this as `a:xfrm/@flipH`; the frame is flipped after diff --git a/crates/office2pdf/src/parser/docx_image_tests.rs b/crates/office2pdf/src/parser/docx_image_tests.rs index f255d53d..8b8051c8 100644 --- a/crates/office2pdf/src/parser/docx_image_tests.rs +++ b/crates/office2pdf/src/parser/docx_image_tests.rs @@ -304,6 +304,59 @@ fn test_docx_floating_image_square_wrap() { assert!((floating[0].image.height.expect("Expected height") - 100.0).abs() < 0.5); } +#[test] +fn docx_floating_picture_rotation_reaches_ir_and_typst() { + let document_xml = r#" + + + + + + 914400 + 457200 + + + + + + + + + + + + + + + + + +"#; + + let data = build_docx_with_custom_image_document(document_xml); + let parser = DocxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + let floating = find_floating_images(&doc); + + assert_eq!(floating.len(), 1); + assert_eq!(floating[0].image.rotation_deg, Some(349.0)); + + let source = crate::render::typst_gen::generate_typst(&doc) + .unwrap() + .source; + assert!( + source.contains("#move(dx:") + && source.contains("#rotate(349deg, origin: top + left)[#image("), + "floating picture rotation was not emitted: {source}" + ); +} + #[test] fn test_docx_floating_image_top_and_bottom_wrap() { let bmp_data = make_test_bmp(); diff --git a/crates/office2pdf/src/parser/docx_media.rs b/crates/office2pdf/src/parser/docx_media.rs index e6ad6d8d..9937a4d3 100644 --- a/crates/office2pdf/src/parser/docx_media.rs +++ b/crates/office2pdf/src/parser/docx_media.rs @@ -30,7 +30,7 @@ pub(super) fn extract_drawing_image( }; let image_data = ImageData { - rotation_deg: None, + rotation_deg: (pic.rot != 0).then_some(f64::from(pic.rot)), flip_h: false, flip_v: false, data: asset.data.clone(), diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index af3c61c7..c5797250 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -3444,16 +3444,8 @@ fn generate_floating_image(out: &mut String, fi: &FloatingImage, ctx: &mut GenCt " #place(top + left, dx: {}pt, dy: 0pt)[", format_f64(fi.offset_x) ); - out.push_str("#image(\""); - out.push_str(&path); - out.push('"'); - if let Some(w) = fi.image.width { - let _ = write!(out, ", width: {}pt", format_f64(w)); - } - if let Some(h) = fi.image.height { - let _ = write!(out, ", height: {}pt", format_f64(h)); - } - out.push_str(")]\n"); + generate_floating_image_content(out, &fi.image, &path); + out.push_str("]\n"); // Reserve vertical space equal to image height if let Some(h) = fi.image.height { let _ = writeln!(out, " #v({}pt)", format_f64(h)); @@ -3468,16 +3460,8 @@ fn generate_floating_image(out: &mut String, fi: &FloatingImage, ctx: &mut GenCt format_f64(fi.offset_x), format_f64(fi.offset_y) ); - out.push_str("#image(\""); - out.push_str(&path); - out.push('"'); - if let Some(w) = fi.image.width { - let _ = write!(out, ", width: {}pt", format_f64(w)); - } - if let Some(h) = fi.image.height { - let _ = write!(out, ", height: {}pt", format_f64(h)); - } - out.push_str(")]\n"); + generate_floating_image_content(out, &fi.image, &path); + out.push_str("]\n"); } WrapMode::Square | WrapMode::Tight => { // Best-effort text wrapping: use #place with float: true @@ -3487,18 +3471,60 @@ fn generate_floating_image(out: &mut String, fi: &FloatingImage, ctx: &mut GenCt format_f64(fi.offset_x), format_f64(fi.offset_y) ); - out.push_str("#image(\""); - out.push_str(&path); - out.push('"'); - if let Some(w) = fi.image.width { - let _ = write!(out, ", width: {}pt", format_f64(w)); - } - if let Some(h) = fi.image.height { - let _ = write!(out, ", height: {}pt", format_f64(h)); - } - out.push_str(")]\n"); + generate_floating_image_content(out, &fi.image, &path); + out.push_str("]\n"); + } + } +} + +/// Emit a floating picture while preserving its unrotated frame dimensions +/// and centre. +/// +/// Word turns `a:xfrm/@rot` clockwise around the picture centre. The image is +/// already inside an absolute `#place`, so rotation changes only its painted +/// extent and does not affect document flow. As with oversized fixed elements, +/// Typst can clamp the body frame before resolving `origin: center`; pivot on +/// the unclamped top-left corner and translate it back to Word's centre instead +/// (issues #1032, #1366). +fn generate_floating_image_content(out: &mut String, image: &ImageData, path: &str) { + let rotation = image.rotation_deg.filter(|degrees| *degrees != 0.0); + let pivot_shift = rotation.and_then(|degrees| { + image + .width + .zip(image.height) + .map(|(width, height)| centre_pivot_shift(width, height, degrees, false, false)) + }); + if let Some(degrees) = rotation { + if let Some((dx, dy)) = pivot_shift { + let _ = write!( + out, + "#move(dx: {}pt, dy: {}pt)[#rotate({}deg, origin: top + left)[", + format_f64(dx), + format_f64(dy), + format_f64(degrees) + ); + } else { + let _ = write!(out, "#rotate({}deg, origin: center)[", format_f64(degrees)); } } + + out.push_str("#image(\""); + out.push_str(path); + out.push('"'); + if let Some(width) = image.width { + let _ = write!(out, ", width: {}pt", format_f64(width)); + } + if let Some(height) = image.height { + let _ = write!(out, ", height: {}pt", format_f64(height)); + } + out.push(')'); + + if rotation.is_some() { + out.push(']'); + } + if pivot_shift.is_some() { + out.push(']'); + } } fn generate_floating_text_box(