diff --git a/Cargo.lock b/Cargo.lock index 8154b2041..204e3e347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5312,6 +5312,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", + "wordcraft-metafile", "wordcraft-proof", ] diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index afb8f0a4a..172a91cf1 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -13,6 +13,7 @@ wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-proof = { workspace = true } +wordcraft-metafile = { workspace = true } kurbo = { workspace = true } unicode-bidi = { workspace = true } unicode-linebreak = { workspace = true } diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 1c245b885..9d6237180 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -184,12 +184,13 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> } let img = img.into_rgba8(); let (iw, ih) = img.dimensions(); - let frac = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; - let [cl, ct, cr, cb] = crop.map(frac); - let x0 = ((iw as f32 * cl) as u32).min(iw); - let y0 = ((ih as f32 * ct) as u32).min(ih); - let x1 = ((iw as f32 * (1.0 - cr)) as u32).clamp(x0, iw); - let y1 = ((ih as f32 * (1.0 - cb)) as u32).clamp(y0, ih); + // The part the renderers draw (#1899): a sliver keeps its partly covered pixels. + let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop); + let (fw, fh) = (f64::from(iw), f64::from(ih)); + let x0 = ((fw * cl) as u32).min(iw); + let y0 = ((fh * ct) as u32).min(ih); + let x1 = ((fw * (cl + vw)).ceil() as u32).clamp(x0, iw); + let y1 = ((fh * (ct + vh)).ceil() as u32).clamp(y0, ih); let (cw, ch) = (x1 - x0, y1 - y0); if cw == 0 || ch == 0 { return None; @@ -264,6 +265,16 @@ mod tests { assert!(half.iter().any(|(x, _)| *x > 0.99), "{half:?}"); // No transparency: the rectangle. assert!(picture_outline(&png(image::RgbImage::new(8, 8).into()), [0.0; 4]).is_none()); + // #1899: the sliver the renderers draw (0.5% of the axis, when two sides overlap or keep + // less) has an outline too. Only the bottom-right quarter is opaque, so a column or a row + // through it is opaque in its second half. + let quarter = png(image::RgbaImage::from_fn(100, 100, |x, y| image::Rgba([0, 0, 0, if x >= 50 && y >= 50 { 255 } else { 0 }])).into()); + for (crop, column) in [([0.6, 0.0, 0.6, 0.0], true), ([0.6, 0.0, 0.398, 0.0], true), ([0.0, 0.6, 0.0, 0.6], false)] { + let pts = picture_outline(&quarter, crop).unwrap_or_default(); + let across = |p: &(f32, f32)| if column { p.1 } else { p.0 }; + // The grid's cells are 3 pixels, so the half starts a little before 0.5. + assert!(pts.len() >= 3 && pts.iter().all(|p| across(p) > 0.4), "{crop:?}: {pts:?}"); + } } #[test] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d112eeb36..1253bdc88 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -875,6 +875,66 @@ fn metafile_pictures_are_drawn_as_vector_paths() { assert!(!raw.contains("0.8156"), "the grey placeholder box is not drawn"); } +/// #1899: a picture cropped to a sliver of its source (3% across here) is drawn at that sliver's +/// size, bitmap or metafile: the source spans 1/0.03 frames, not at most 20 (the old 5% floor). +#[test] +fn a_crop_keeping_a_sliver_draws_the_source_that_much_wider() { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use hayro_syntax::Pdf; + /// The widest image and the widest fill drawn, in page units. + #[derive(Default)] + struct Widest(f64, f64); + impl Device<'_> for Widest { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, _: &Paint<'_>, mode: &PathDrawMode) { + if matches!(mode, PathDrawMode::Fill(_)) { + self.1 = self.1.max(kurbo::Shape::bounding_box(&(transform * path.clone())).width()); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, transform: kurbo::Affine) { + self.0 = self.0.max(transform.as_coeffs()[0].abs()); + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let widest = |bytes: Vec, ext: &str, crop: [f32; 4]| { + let mut d = Document::new(); + let media = d.add_media(bytes, ext); + let mut p = Paragraph::new(); + p.insert_object( + 0, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop, ole: None }, + &CharProps::default(), + ) + .unwrap(); + d.body = vec![para_block(p)]; + let pdf = Pdf::new(export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap()).expect("parse"); + let cache = InterpreterCache::new(); + let mut w = Widest::default(); + for page in pdf.pages().iter() { + let mut ctx = + Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut w); + } + w + }; + let sliver = [0.47, 0.0, 0.5, 0.0]; + // The bitmap: the whole image drawn behind the frame's clip. + let (whole, cropped) = (widest(png(), "png", [0.0; 4]).0, widest(png(), "png", sliver).0); + assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "png: {whole} → {cropped}"); + // The metafile: its red right half, a filled path. + let (whole, cropped) = (widest(red_right_half_emf(), "emf", [0.0; 4]).1, widest(red_right_half_emf(), "emf", sliver).1); + assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "emf: {whole} → {cropped}"); +} + /// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback. #[test] fn inline_picture_figure_keeps_its_alt_text() { diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 377e6d7ac..d0f06efdf 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -214,7 +214,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | Date | Change | Summary | |---|---|---| -| 2026-10-20 | trivial | A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF, bitmap or metafile; it was widened to 5%, so a header bar that is 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it (#1899) | +| 2026-10-20 | trivial | A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF, bitmap or metafile, and Tight/Through wrapping follows the part drawn; it was widened to 5%, so a header bar that is 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it (#1899) | | 2026-10-18 | trivial | A word after a slash (" /word") wraps whole instead of splitting between two letters (#2963) | | 2026-10-15 | trivial | An at-least table row with less room than its minimum on a page that holds something starts the next page whole, as checked in Word (#1742); the taller-than-a-page case (it then splits from the top of that page) and repeated header rows (they go with it) follow the issue and synthetic tests, not a Word check | | 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. |