From 056771a33a6ae81ee92c2997cd4e46abace39295 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:30:33 +0300 Subject: [PATCH] engine: store replacement images under their true format extension (#1691) * engine: store replacement images under their true format extension * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 23 +++++++++++++++-------- crates/engine/src/cmd/objects.rs | 2 +- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index f90f56291..35bc71023 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -222,6 +222,20 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or("")) } +/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it +/// (TIFF included: the `image` crate decodes it). +pub(crate) fn image_ext(bytes: &[u8]) -> &'static str { + match bytes.get(..4) { + Some([0x89, b'P', b'N', b'G']) => "png", + Some([0xFF, 0xD8, ..]) => "jpeg", + Some([b'G', b'I', b'F', _]) => "gif", + Some([b'R', b'I', b'F', b'F']) => "webp", + Some([b'B', b'M', ..]) => "bmp", + Some([b'I', b'I', 0x2A, 0x00]) | Some([b'M', b'M', 0x00, 0x2A]) => "tiff", + _ => "png", + } +} + /// Insert an encoded image at the selection as an inline picture, `width` points wide (default: /// its natural size at 96 ppi), never wider than the text column. The caret ends after it, so a /// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The @@ -232,14 +246,7 @@ fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) } // Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) { - let ext = match bytes.get(..4) { - Some([0x89, b'P', b'N', b'G']) => "png", - Some([0xFF, 0xD8, ..]) => "jpeg", - Some([b'G', b'I', b'F', _]) => "gif", - Some([b'R', b'I', b'F', b'F']) => "webp", - Some([b'B', b'M', ..]) => "bmp", - _ => "png", - }; + let ext = image_ext(&bytes); (pw as f32 * 0.75, ph as f32 * 0.75, ext) } else if let Some(size) = wordcraft_render::metafile_size(&bytes) { size diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index c0552d18d..dedcee062 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -220,7 +220,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params("`path` or `data` required".into())); }; wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?; - let key = s.doc.add_media(bytes, "png"); + let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes)); with_obj(s, |o| { if let InlineObject::Image { media, .. } = o { *media = key.clone(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 936f33608..b96e7f512 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5984,6 +5984,35 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +/// Change Picture must keep the replacement's own format: the part's name and content type +/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word +/// and LibreOffice then show a broken picture). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picture_change_keeps_the_replacement_format() { + let dir = scratch_dir("picture-change-format"); + let first = dir.join("first.png"); + std::fs::write(&first, tiny_png()).unwrap(); + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let second = dir.join("second.jpg"); + std::fs::write(&second, &jpeg).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); + run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); + let mut media = String::new(); + s.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::para::InlineObject::Image { media: m, .. } = o { + media = m.clone(); + } + }); + assert!(media.ends_with(".jpeg") || media.ends_with(".jpg"), "the JPEG keeps its format: {media}"); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.media.get(&media).map(|b| b.as_slice()), Some(jpeg.as_slice()), "the saved part is the JPEG"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn json_open_repairs_hostile_run_lengths() { use wordcraft_doc::{Block, Document};