From e75ff362518ff9858d27857213581b8c111eb3b4 Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 12:56:54 +0200 Subject: [PATCH] Layout: an inline picture wider than the text keeps its size, like Word (#745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layout: an inline picture wider than the text keeps its size, like Word WordCraft shrank an inline picture, chart or shape that is wider than its line to the line's width, height with it. Word draws it at its own size: it runs past the margin and the page edge clips it, and the line is as tall as the picture. In docxide-pdf's fixtures that moved the rest of the page up (case179: a 1014 x 100 pt picture became 468 x 46 pt) and shrank a header's 1014 pt logo strip to fit (alfies_arc). All page outputs already clip at the page: the PDF page box, and the page bitmaps the rasteriser and the editor draw. Text boxes don't clip what runs past their sides yet, so a picture wider than a text box is still fitted to the box rather than spilling over the page (ParaEnv::fit_objects, part of the paragraph cache key). On the 390 fixtures: case179 SSIM 70.9 -> 99.9%, alfies_arc 18.4 -> 74.8%, case178 98.0 -> 99.9%, nabl_lab 33.2 -> 36.8%; no fixture got worse. Co-Authored-By: Claude Opus 5.5 * Review fixes for #745: fit imported and pasted pictures to the text width Layout no longer shrinks oversized inline pictures, so pictures that come at their pixel size are fitted when they enter the document, as Insert › Pictures already does: - opening HTML (also from a path), Markdown, ODT, RTF and other formats-crate imports (io::fit_pictures, against the narrowest section text width); - pasting HTML/RTF (paste::adopt, against our text width). Only inline pictures wider than the text are scaled, proportionally. DOCX keeps its sizes. Test: tests::imported_pictures_fit_the_text_width. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 5 +++ crates/engine/src/cmd/paste.rs | 7 +++- crates/engine/src/io.rs | 38 +++++++++++++++++++++ crates/engine/src/io_ext.rs | 8 ++++- crates/engine/src/tests.rs | 42 +++++++++++++++++++++++ crates/layout/src/display.rs | 2 +- crates/layout/src/lib.rs | 5 +++ crates/layout/src/para.rs | 7 +++- crates/layout/src/table.rs | 1 + crates/layout/src/tests.rs | 62 ++++++++++++++++++++++++++++++---- 10 files changed, 167 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a0f2440..f4b4694 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -136,6 +136,11 @@ impl BoxBudget { self.open.pop(); } + /// Whether a text box is being expanded now. + pub fn inside(&self) -> bool { + !self.open.is_empty() + } + /// Boxes expanded so far. pub fn total(&self) -> usize { self.total diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index dcb3d36..6044537 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -171,7 +171,7 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { } /// Every paragraph in a block, tables included (bounded depth). -fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { +pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { if depth > MAX_DEPTH { return; } @@ -218,6 +218,11 @@ fn adopt(s: &mut Session, src: Document) -> Fragment { } } let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + // Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size). + let max_w = crate::io::text_width(&s.doc); + for b in &mut blocks { + crate::io::fit_block_pictures(b, max_w); + } for b in &mut blocks { each_para(b, 0, &mut |p| { if let Some(n) = p.props.numbering.as_mut() diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index f3fc258..defc29d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -126,6 +126,43 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu Ok((doc, false)) } +/// The narrowest text width of the document's sections, points: what a picture must fit. +pub(crate) fn text_width(doc: &Document) -> f32 { + doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) +} + +/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a +/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML, +/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does. +pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) { + crate::cmd::paste::each_para(b, 0, &mut |p| { + let mut changed = false; + for o in &mut p.objects { + if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o + && float.wrap == wordcraft_doc::para::Wrap::Inline + && w.is_finite() + && *w > max_w + { + let k = max_w / *w; + *w = max_w; + *h = (*h * k).max(1.0); + changed = true; + } + } + if changed { + p.touch(); + } + }); +} + +/// [`fit_block_pictures`] for a whole document read from a file, against its own text width. +pub(crate) fn fit_pictures(doc: &mut Document) { + let max_w = text_width(doc); + for b in &mut doc.body { + fit_block_pictures(std::sync::Arc::make_mut(b), max_w); + } +} + /// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…), /// opened by what it holds, as Word does: a Word document or RTF file by its signature, any /// other text through the plain-text import with encoding detection (#569). Anything else is @@ -201,6 +238,7 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result< // Pictures referenced by a relative path load from beside the HTML file. let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src)); + fit_pictures(&mut doc); doc.ensure_nonempty(); return Ok((doc, false)); } diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index a0693d4..04bd7e4 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -9,7 +9,13 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option> { "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), - other => wordcraft_formats::import(other, bytes), + // HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`). + other => wordcraft_formats::import(other, bytes).map(|r| { + r.map(|mut doc| { + crate::io::fit_pictures(&mut doc); + doc + }) + }), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0cdf402..f9b496a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4211,6 +4211,48 @@ fn outline_collapse_hides_descendants_and_expand_restores() { assert_eq!(s.selected_text(), "One\na\nTwo\nb"); } +/// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at +/// their pixel size are fitted to the text width when the file is opened (and when pasted), +/// keeping their proportions; small pictures keep their size. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn imported_pictures_fit_the_text_width() { + let png = |w: u32, h: u32| { + let mut b = Vec::new(); + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png) + .unwrap(); + cmd::insert::base64_encode(&b) + }; + let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> { + doc.body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + wordcraft_doc::InlineObject::Image { w, h, .. } => Some((*w, *h)), + _ => None, + }) + .collect() + }; + let md = format!("![wide](data:image/png;base64,{})\n\n![small](data:image/png;base64,{})\n", png(1200, 300), png(40, 20)); + let doc = crate::io::open_bytes("p.md", md.as_bytes()).unwrap(); + let max_w = crate::io::text_width(&doc); + let got = sizes(&doc); + assert_eq!(got.len(), 2, "{got:?}"); + assert!((got[0].0 - max_w).abs() < 0.01, "wide picture fits: {got:?} (text width {max_w})"); + assert!((got[0].1 - max_w / 4.0).abs() < 0.01, "and keeps its proportions: {got:?}"); + assert!((got[1].0 - 30.0).abs() < 0.01 && (got[1].1 - 15.0).abs() < 0.01, "small picture keeps its size: {got:?}"); + + // Pasting HTML fits our text width too. + let mut s = Session::new(wordcraft_doc::Document::new()); + let html = format!("

", png(1200, 300)); + s.run("edit.pasteSpecial", &json!({"as": "html", "html": html})).unwrap(); + let max_w = crate::io::text_width(&s.doc); + let got = sizes(&s.doc); + assert!(got.iter().any(|(w, _)| (w - max_w).abs() < 0.01), "pasted picture fits: {got:?}"); +} + #[test] fn format_font_sets_the_asian_and_latin_fonts_separately() { // #505: Word's Font dialog sets an Asian text font and a Latin text font of their own. diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 48d1a50..d9d3493 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -1487,7 +1487,7 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent()); let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0)); let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room }; - // The room is the rotated bounds of the (possibly scaled-down) frame: undo the rotation. + // The room is the rotated bounds of the frame: undo the rotation. let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0)); let (bw, _) = float.spin().extent(w, h); let k = if bw > 0.0 { room.w / bw } else { 1.0 }; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3bff87d..0178337 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -453,6 +453,7 @@ struct Key { eq: u32, read_spacing: Option, grid: (u32, u32, u32, bool), + fit: bool, hide_deleted: bool, } @@ -647,6 +648,7 @@ impl Ctx<'_> { eq_number: 0, read_line_spacing: None, grid: para::Grid::default(), + fit_objects: false, }; let pl = Arc::new(para::layout_para(&p, &env)); self.numbers.insert(n, pl.clone()); @@ -696,6 +698,7 @@ impl Ctx<'_> { eq: if eq_here > 0 { eq_number } else { 0 }, read_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing.map(f32::to_bits) } else { None }, grid: self.grid.key(), + fit: self.boxes.inside(), hide_deleted: self.hide_deleted, }; self.cache.used.insert(key.clone()); @@ -719,6 +722,7 @@ impl Ctx<'_> { eq_number, read_line_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing } else { None }, grid: self.grid, + fit_objects: self.boxes.inside(), }; let pl = Arc::new(para::layout_para(p, &env)); self.cache.paras.insert(key, pl.clone()); @@ -2827,6 +2831,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { eq_number: ctx.eq_count, read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None }, grid: ctx.grid, + fit_objects: false, }; let _ = rp; let pl = para::layout_para(p, &env); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 2b3c1db..11aba54 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -345,6 +345,9 @@ pub struct ParaEnv<'a> { pub read_line_spacing: Option, /// The section's document grid (`Grid::default()`: none). pub grid: Grid, + /// Shrink inline drawings wider than the line to fit it: in text boxes, which don't clip what + /// runs past their sides. Elsewhere such a drawing keeps its size and the page edge clips it. + pub fit_objects: bool, } /// The section's document grid as layout applies it (`w:docGrid`, ECMA-376 §17.6.5). @@ -1208,9 +1211,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { if let Some((w, h, float)) = o.frame() && float.wrap == wordcraft_doc::para::Wrap::Inline { + // A picture wider than the line keeps its size, as in Word: it runs past + // the margin and the page edge clips it (see `ParaEnv::fit_objects`). let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0); let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); - let s = if w > maxw { maxw / w } else { 1.0 }; + let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 }; // The line makes room for effects (shadows) around the picture too, and // for a rotated one's bounds, as Word does. let [el, et, er, eb] = float.effect_extent(); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 56373a6..add09c6 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -174,6 +174,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f eq_number: 0, read_line_spacing: None, grid: crate::para::Grid::default(), + fit_objects: false, }; let (mut min, mut max) = (EMPTY_TEXT, EMPTY_TEXT); if cell.props.vmerge != VMerge::Continue && !cell.props.text_direction.is_turned() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 8e9b3f3..437758e 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2847,10 +2847,10 @@ fn graphic_paths_drop_segments_that_cannot_be_drawn() { } #[test] -fn oversized_graphic_draws_shrunk_with_its_items() { +fn graphic_items_scale_with_their_frame() { use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::props::Rgb; - // A 1000 x 500 pt chart is wider than the text: layout shrinks it, and its items with it. + // A chart drawn for 1000 x 500 pt in a 500 x 250 pt frame: its items and strokes scale by half. let items = vec![GraphicItem::Shape { rect: [0.0, 0.0, 1000.0, 500.0], kind: wordcraft_doc::para::ShapeKind::Rectangle, @@ -2859,7 +2859,7 @@ fn oversized_graphic_draws_shrunk_with_its_items() { stroke_width: 4.0, }]; let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None, chart: None, smart_art: None }; - let obj = InlineObject::Graphic { w: 1000.0, h: 500.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; + let obj = InlineObject::Graphic { w: 500.0, h: 250.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; let mut d = Document::from_text("Chart"); d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); @@ -2867,9 +2867,59 @@ fn oversized_graphic_draws_shrunk_with_its_items() { .iter() .find_map(|x| if let display::Draw::Shape { rect, stroke_width, .. } = x { Some((*rect, *stroke_width)) } else { None }) .expect("a shape"); - assert!(r.w < 1000.0, "{r:?}"); - assert!((r.h / r.w - 0.5).abs() < 1e-3, "{r:?}"); - assert!((stroke / r.w - 4.0 / 1000.0).abs() < 1e-5, "stroke {stroke} at width {}", r.w); + assert!((r.w - 500.0).abs() < 1e-3 && (r.h - 250.0).abs() < 1e-3, "{r:?}"); + assert!((stroke - 2.0).abs() < 1e-5, "stroke {stroke}"); +} + +#[test] +fn inline_picture_wider_than_a_text_box_is_fitted_to_it() { + // Text boxes don't clip what runs past their sides, so a 400 x 100 pt picture in a 144 pt box + // is fitted to the box's 129.6 pt inside its margins instead of spilling over the page. + let mut d = Document::from_text("Before"); + let mut p = wordcraft_doc::Paragraph::with_text("", Default::default()); + p.insert_object(0, picture(400.0, 100.0, Default::default()), &Default::default()).unwrap(); + let id = d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(p)]); + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story: Some(id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); + let l = lay(&d); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + let r = draws + .iter() + .find_map(|x| match x { + display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } => Some(*rect), + _ => None, + }) + .expect("the picture"); + assert!((r.w - 129.6).abs() < 0.1 && (r.h - 32.4).abs() < 0.1, "{r:?}"); +} + +#[test] +fn inline_picture_wider_than_the_text_keeps_its_size() { + // A 1000 x 100 pt picture in 468 pt of text: Word draws it at its size, past the margin, and the + // page edge clips it; the line is as tall as the picture (docxide-pdf's case179). + let mut d = Document::from_text(""); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.insert_object(0, picture(1000.0, 100.0, Default::default()), &Default::default()).unwrap(); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + let line = ¶.lines[0]; + assert!((line.baseline - line.top - 100.0).abs() < 0.01, "the picture keeps its height: {line:?}"); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + let r = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("the picture"); + assert!((r.w - 1000.0).abs() < 0.01 && (r.h - 100.0).abs() < 0.01, "{r:?}"); } /// The paragraph at `path` on page 0: its first line's x, its y and its line count.