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.