From 0cca2f7d210c6808cb2fc0b258b6c2cdadfed155 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:29:15 +0200 Subject: [PATCH 1/4] #1899: a picture cropped to a sliver is drawn at the size the file gives A crop that kept under 5% of a picture's axis was widened to 5%, so a header bar cropped to 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it. The rasteriser, the PDF writer and the metafile placement now share wordcraft_metafile::crop_window, which keeps down to 0.1% of an axis and keeps junk crops finite. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/metafile/src/lib.rs | 2 +- crates/metafile/src/place.rs | 23 ++++++++++++++--- crates/metafile/src/tests.rs | 18 ++++++++++++++ crates/pdf/src/lib.rs | 4 +-- crates/render/src/lib.rs | 48 +++++++++++++++++++++++++++++++++--- docs/layout-parity.md | 3 ++- 7 files changed, 90 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..6a6fc157b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (progress log: a picture cropped to a sliver is drawn at the size the file gives, #1899; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. +- 2026-10-20: 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: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899). - 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. - 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey. - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). @@ -506,6 +507,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index cc2f64e5c..5dacfb153 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -23,7 +23,7 @@ mod tests; use bytes::Bytes; use canvas::Raw; use geom::{Op, P}; -pub use place::{MAX_BITMAP_SIDE, PlacedItem}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem, crop_window}; /// Items kept from one file. Past the cap the parse stops and keeps what it has. pub(crate) const MAX_ITEMS: usize = 200_000; diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f079553..aad4b7393 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -11,6 +11,23 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000; const MAX_COORD: f64 = 1e6; /// Smallest placed bitmap side (target units), so the stretch onto it is never singular. const MIN_BITMAP_SIDE: f64 = 1e-6; +/// The least of a source axis a crop keeps (0.1%). Files crop pictures to slivers (a header bar +/// that is 2.7% of a page-sized metafile, #1899); this only keeps the stretch finite. +const MIN_VISIBLE: f64 = 0.001; + +/// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) +/// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each +/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.1% of it, +/// the left (top) side stands and 0.1% shows. +pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); + let axis = |lo: f64, hi: f64| { + let lo = lo.min(1.0 - MIN_VISIBLE); + (lo, (1.0 - lo - hi).max(MIN_VISIBLE)) + }; + let ((x, w), (y, h)) = (axis(l, r), axis(t, b)); + [x, y, w, h] +} /// One item of a placed picture, in target units. pub enum PlacedItem { @@ -30,10 +47,10 @@ impl Picture { /// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right, /// bottom) removed first: the visible part fills `r`. None for a degenerate size. pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option { - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 }); + let [cl, ct, vw, vh] = crop_window(crop); let (w, h) = (f64::from(self.width), f64::from(self.height)); - let sx = r.width() / (w * (1.0 - cl - cr).max(0.05)); - let sy = r.height() / (h * (1.0 - ct - cb).max(0.05)); + let sx = r.width() / (w * vw); + let sy = r.height() / (h * vh); (sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0) .then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy)) } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 8a93e1411..9295d4c1b 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -891,6 +891,24 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { assert_eq!(stroke.map(|s| s.1), Some(8.0)); } +/// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized +/// metafile), and junk sides give a finite part of the source with some area. +#[test] +fn crop_window_keeps_a_sliver_and_stays_finite() { + let [x, y, w, h] = crate::crop_window([0.0, 0.51771, 0.0, 0.45485]); + assert!(x == 0.0 && w == 1.0 && (y - 0.51771).abs() < 1e-6 && (h - 0.02744).abs() < 1e-5, "{y} {h}"); + let pic = crate::Picture { width: 100.0, height: 1000.0, items: Vec::new() }; + let tf = pic.fit(kurbo::Rect::new(0.0, 0.0, 100.0, 27.44), [0.0, 0.51771, 0.0, 0.45485]).unwrap(); + // The kept band of the source (517.71..545.15) is the frame (0..27.44). + let top = tf * kurbo::Point::new(0.0, 517.71); + let bottom = tf * kurbo::Point::new(0.0, 545.15); + assert!(top.y.abs() < 1e-3 && (bottom.y - 27.44).abs() < 1e-3, "{top:?} {bottom:?}"); + for c in [[f32::NAN, f32::INFINITY, -1.0, 2.0], [0.9; 4], [1.0, 1.0, 0.0, 0.0], [f32::MAX, f32::MIN, f32::NEG_INFINITY, 0.5]] { + let [x, y, w, h] = crate::crop_window(c); + assert!([x, y, w, h].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0 && x + w <= 1.0 + 1e-9 && y + h <= 1.0 + 1e-9, "{c:?}"); + } +} + #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7e853242e..4cfac041d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1245,9 +1245,7 @@ impl Exporter<'_> { s.set_fill(None); return; }; - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) } else { 0.0 }); - let vis_w = (1.0 - cl - cr).max(0.05); - let vis_h = (1.0 - ct - cb).max(0.05); + let [cl, ct, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop).map(|v| v as f32); let (fw, fh) = (rect.w / vis_w, rect.h / vis_h); let Some(size) = Size::from_wh(fw.max(0.01), fh.max(0.01)) else { return }; let fade = alpha < 0.999; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b0416c11e..d5aa6c1c1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -874,9 +874,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl return; }; let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64); - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) as f64 } else { 0.0 }); - let vis_w = (1.0 - cl - cr).max(0.05); - let vis_h = (1.0 - ct - cb).max(0.05); + let [cl, ct, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop); let sx = r.width() / (pw * vis_w); let sy = r.height() / (ph * vis_h); ctx.set_transform(view); @@ -1809,6 +1807,50 @@ mod tests { assert!(left[..3].iter().all(|c| *c > 200), "left half should be paper, not the grey box: {left:?}"); } + /// #1899: a crop that keeps a sliver of the picture (3% here; a header's bar can be 2.7% of a + /// page-sized metafile) stretches that sliver over the frame, as the file says, for vector and + /// bitmap pictures alike. Both pictures are paper on the left half and red on the right. + #[test] + fn a_crop_keeping_a_sliver_fills_the_frame_with_it() { + let mut png = Vec::new(); + image::RgbaImage::from_fn(100, 100, |x, _| image::Rgba(if x >= 50 { [255, 0, 0, 255] } else { [255; 4] })) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + for (bytes, ext) in [(red_right_half_emf(), "emf"), (png, "png")] { + // (crop, where in the frame, red?): 47–50% of the source is all paper; 50–53% all red. + for (crop, at, red) in [([0.47, 0.47, 0.5, 0.5], 0.7, false), ([0.5, 0.47, 0.47, 0.5], 0.3, true)] { + let mut d = Document::new(); + let media = d.add_media(bytes.clone(), ext); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop, + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let opts = RenderOptions::default(); + let rect = page_display(&d, &l.pages[0], &opts.display) + .iter() + .find_map(|i| match i { + Draw::Image { rect, .. } => Some(*rect), + _ => None, + }) + .expect("a picture on the page"); + let img = render_page(&d, &l.pages[0], 1.0, &opts); + let px = img.pixel((rect.x + rect.w * at) as u32, (rect.y + rect.h * at) as u32); + let is_red = px[0] > 200 && px[1] < 60 && px[2] < 60; + let is_paper = px[..3].iter().all(|c| *c > 200); + assert!(if red { is_red } else { is_paper }, "{ext} crop {crop:?} at {at}: {px:?}"); + } + } + } + #[test] fn cosmetic_metafile_pen_is_one_device_pixel_wide() { // MOVETO (0, 50), LINETO (100, 50) with the default cosmetic black pen: a hairline across the middle. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 4873f4481..377e6d7ac 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (a picture cropped to a sliver of its source, under 5%, is drawn at the size the file gives, on screen and in PDF: a header bar that is 2.7% of a page-sized WMF, #1899; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -214,6 +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-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. | From fc5063c38dd7632cd9be592d71dfa167cb046bb6 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:10:12 +0200 Subject: [PATCH 2/4] #1899: keep at least 0.5% of a cropped axis, so nothing shown is culled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With 0.1% the whole source of a 4000 pt frame spanned up to 4,000,000 pt, past the ±1,000,000 pt limit Picture::place uses to leave out items far off the page, so a metafile cropped that narrow lost what the crop shows. At 0.5% it spans at most 800,000 pt (CodeRabbit on #3247). Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/place.rs | 12 +++++++----- crates/metafile/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index aad4b7393..d1f4d1983 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -11,14 +11,16 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000; const MAX_COORD: f64 = 1e6; /// Smallest placed bitmap side (target units), so the stretch onto it is never singular. const MIN_BITMAP_SIDE: f64 = 1e-6; -/// The least of a source axis a crop keeps (0.1%). Files crop pictures to slivers (a header bar -/// that is 2.7% of a page-sized metafile, #1899); this only keeps the stretch finite. -const MIN_VISIBLE: f64 = 0.001; +/// The least of a source axis a crop keeps (0.5%). Files crop pictures to slivers (a header bar +/// that is 2.7% of a page-sized metafile, #1899). Layout makes a picture's frame at most 4000 pt +/// a side, so the whole source then spans at most 800,000 pt: what it covers stays inside +/// `MAX_COORD` and is never left out as far off the page. +const MIN_VISIBLE: f64 = 0.005; /// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) /// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each -/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.1% of it, -/// the left (top) side stands and 0.1% shows. +/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.5% of it, +/// the left (top) side stands and 0.5% shows. pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); let axis = |lo: f64, hi: f64| { diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 9295d4c1b..f67840f88 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -909,6 +909,25 @@ fn crop_window_keeps_a_sliver_and_stays_finite() { } } +/// The narrowest crop on the largest frame layout makes (4000 pt, far down a 22-inch page) still +/// places the source's content: none of it is left out as far off the page. +#[test] +fn the_narrowest_crop_on_the_largest_frame_keeps_its_content() { + let pic = crate::Picture { + width: 100.0, + height: 100.0, + items: vec![ + Item::Bitmap { rect: [0.0, 0.0, 100.0, 100.0], width: 1, height: 1, rgba: vec![255; 4] }, + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)], fill: None, stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false }, + ], + }; + let frame = kurbo::Rect::new(1584.0, 1584.0, 5584.0, 5584.0); + // The last sliver, the first sliver, and sides that overlap. + for crop in [[1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 1.0], [0.9999, 0.6, 0.5, 0.9999]] { + assert_eq!(pic.place(frame, crop).len(), 2, "{crop:?}"); + } +} + #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; From 8e799be97ebb9f6ef4dbb53e93ab5f47aa27e827 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:54:17 +0200 Subject: [PATCH 3/4] #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 --- Cargo.lock | 1 + crates/layout/Cargo.toml | 1 + crates/layout/src/contour.rs | 23 ++++++++++---- crates/pdf/src/tests.rs | 60 ++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) 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. | From bfb9ecf948a60f3a182adcc2a5c22589c624e374 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:11:12 +0200 Subject: [PATCH 4/4] #1899: a picture's wrap outline is placed where its crop window is drawn The renderers draw a crop window whose edges fall within pixels (crop fractions are f32 too: 0.7 is 0.69999...). The outline stretched the whole pixels it covered over the frame instead, so a narrow window came out up to a pixel too wide at each edge (a 2.5-5.5 px window put pixel 2 over a quarter of the picture, not a sixth), and a crop of 0.7 took in the neighbouring column. Grid points now map through the window itself. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/contour.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 9d6237180..2bf7a2fdf 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -207,9 +207,12 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> .any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128))) }; let pts = alpha_outline(gw as usize, gh as usize, opaque)?; - // The grid may reach a little past the picture (its last cells are partial). - let (sx, sy) = ((gw * cell) as f32 / cw as f32, (gh * cell) as f32 / ch as f32); - Some(pts.into_iter().map(|(x, y)| ((x * sx).min(1.0), (y * sy).min(1.0))).collect()) + // Grid points onto the picture as the renderers draw the window, whose edges fall within + // pixels (crop fractions are f32 too: 0.7 is 0.69999…). The grid may reach a little past the + // pixels (its last cells are partial). + let place = + |g: f32, cells: u32, p0: u32, lo: f64, len: f64| ((f64::from(p0) + f64::from(g) * f64::from(cells * cell) - lo) / len).clamp(0.0, 1.0) as f32; + Some(pts.into_iter().map(|(x, y)| (place(x, gw, x0, fw * cl, fw * vw), place(y, gh, y0, fh * ct, fh * vh))).collect()) } #[cfg(test)] @@ -275,6 +278,29 @@ mod tests { // 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:?}"); } + // Crop fractions are f32 (0.7 is 0.69999…), so an edge on a whole pixel comes out a hair + // off it: the outline neither takes in the neighbouring column nor drops the last one. Only + // the top half of the 30 columns each crop keeps (of 100) is opaque, so it spans 0–1 across. + for (crop, kept) in [([0.7, 0.0, 0.0, 0.0], 70..100), ([0.0, 0.0, 0.7, 0.0], 0..30)] { + let img = image::RgbaImage::from_fn(100, 20, |x, y| image::Rgba([0, 0, 0, if kept.contains(&x) && y < 10 { 255 } else { 0 }])); + let pts = picture_outline(&png(img.into()), crop).unwrap_or_default(); + let xs = pts.iter().map(|p| p.0); + let (lo, hi) = (xs.clone().fold(f32::MAX, f32::min), xs.fold(f32::MIN, f32::max)); + assert!(lo < 1e-6 && hi > 1.0 - 1e-6, "{crop:?}: {pts:?}"); + } + // A window whose edges fall within pixels (2.5–5.5 of 10) is placed as the renderers draw + // it: pixel 2, the only opaque one (in half the rows), covers the first sixth of the + // picture, not a quarter (whole pixels 2–5 stretched over it). Across and down alike. + for down in [false, true] { + let img = image::RgbaImage::from_fn(10, 10, |x, y| { + let (a, b) = if down { (y, x) } else { (x, y) }; + image::Rgba([0, 0, 0, if a == 2 && b < 5 { 255 } else { 0 }]) + }); + let crop = if down { [0.0, 0.25, 0.0, 0.45] } else { [0.25, 0.0, 0.45, 0.0] }; + let pts = picture_outline(&png(img.into()), crop).unwrap_or_default(); + let far = pts.iter().map(|p| if down { p.1 } else { p.0 }).fold(f32::MIN, f32::max); + assert!(pts.len() >= 3 && (far - 1.0 / 6.0).abs() < 1e-3, "{crop:?}: {pts:?}"); + } } #[test]