mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
#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:
Generated
+1
@@ -5312,6 +5312,7 @@ dependencies = [
|
||||
"wordcraft-doc",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"wordcraft-proof",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user