diff --git a/Cargo.lock b/Cargo.lock index 7f9a0d3e5..c8717698a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5326,6 +5326,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", + "wordcraft-metafile", "wordcraft-proof", ] diff --git a/ROADMAP.md b/ROADMAP.md index 0ea74331d..a52820440 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,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-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme +- 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). @@ -524,6 +525,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps |---|---|---| | 2026-10-21 | trivial | Progress log: File › Recover follows the app theme (#3243) | | 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. | +| 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-17 | trivial | Progress log: #2048 entry covers anchors pushed onto the next page | 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..2bf7a2fdf 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; @@ -206,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)] @@ -264,6 +268,39 @@ 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:?}"); + } + // 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] diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 473d69baf..c63bbb12b 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, RawText}; use geom::{Op, P}; -pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout, 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 73ac10cc3..5a160c4ed 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -11,6 +11,25 @@ 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.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.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| { + 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 { @@ -96,10 +115,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 e6ffc8c34..eaa5b1674 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -891,6 +891,43 @@ 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:?}"); + } +} + +/// 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] }; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 098964694..ebc21ec7c 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1277,9 +1277,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/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 29b5aa6e3..146c36a00 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -959,6 +959,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/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4991ad574..8610dd1f9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -930,9 +930,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); @@ -1911,6 +1909,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 ad982589e..f9b44f221 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -218,6 +218,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo |---|---|---| | 2026-10-17 | trivial | A float above its anchor whose wrap pushes the anchor onto the next page settles with no text under it on any page (#2048 review) | | 2026-10-15 | trivial | A float placed above its anchor paragraph (fixed on the page, or moved up from it) has the text of the paragraphs above the anchor on its page wrap around it, as in Word, instead of running behind it (#2048) | +| 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. |