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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
midasmatterhorn
2026-10-14 21:30:33 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 67b0428657
commit 056771a33a
3 changed files with 45 additions and 9 deletions
+15 -8
View File
@@ -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<u8>, width: Option<f32>, 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
+1 -1
View File
@@ -220,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
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();
+29
View File
@@ -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};