#1899: wrap outlines and PDF bitmaps follow the same crop window

contour::picture_outline turned the crop into a pixel window on its own,
so a sliver or overlapping crop gave an empty window and Tight/Through
wrapping fell back to the frame while the renderers draw a 0.5% sliver.
It now uses wordcraft_metafile::crop_window, with the upper pixel edges
rounded up. A PDF test covers the bitmap and metafile paths of the export
(CodeRabbit on #3247).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rabis
2026-10-20 11:54:17 +02:00
co-authored by Claude Opus 5.5
parent fc5063c38d
commit 8e799be97e
5 changed files with 80 additions and 7 deletions
Generated
+1
View File
@@ -5312,6 +5312,7 @@ dependencies = [
"wordcraft-doc",
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-metafile",
"wordcraft-proof",
]
+1
View File
@@ -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 }
+17 -6
View File
@@ -184,12 +184,13 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>>
}
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]
+60
View File
@@ -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<SoftMask<'_>>) {}
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<SoftMask<'_>>, _: 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<u8>, 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() {
+1 -1
View File
@@ -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. |