diff --git a/crates/layout/src/balloons.rs b/crates/layout/src/balloons.rs index 36e1f9243..ec20b0db0 100644 --- a/crates/layout/src/balloons.rs +++ b/crates/layout/src/balloons.rs @@ -21,7 +21,7 @@ use wordcraft_doc::{Document, Paragraph, Pos, StoryRef}; use wordcraft_geom::Rect; use crate::display::revision_color; -use crate::{BoxContext, BoxLayout, Ctx, DocLayout, Placed, fit_box, layout_box, push_para}; +use crate::{BoxContext, BoxLayout, Ctx, DocLayout, Edges, Placed, fit_box, layout_box, push_para}; /// Width of the markup area beside each page (points): the editor's. pub const MARKUP_AREA: f32 = 216.0; @@ -158,7 +158,7 @@ pub(crate) fn add(ctx: &mut Ctx, lay: &mut DocLayout) { ); let pl = ctx.para_labelled(&head, inner, None, &[], None); let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0)); - push_para(&mut items, story, &[HEADER_PATH], &pl, 0, pl.lines.len(), x, y + lead, inner); + push_para(&mut items, story, &[HEADER_PATH], &pl, 0, pl.lines.len(), x, y + lead, inner, Edges::own(&pl, 0, pl.lines.len())); y += pl.height + 2.0; // The comment itself, as written (its own formatting). if let Some(part) = doc.parts.get(&cm.part) { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1bc00495a..e1c79ece2 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1189,6 +1189,8 @@ fn layout_box( // Word has no frames in a text box: a framed paragraph there (its style's frame, usually) // runs with the box's text (#2357). let frames = !matches!(story, StoryRef::Part(id) if ctx.doc.parts.get(&id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)); + // The bottom of the last paragraph's lines. + let mut prev_end = 0.0f32; for (i, b) in blocks.iter().enumerate() { last_top = y; let mut path = prefix.to_vec(); @@ -1211,7 +1213,7 @@ fn layout_box( }; let float = frame_float(&fr); let r = float_rect(frame, (0.0, width), y, fw, h, &float); - push_para(&mut items, story, &path, &pf, 0, pf.lines.len(), r.x, r.y, fw); + push_para(&mut items, story, &path, &pf, 0, pf.lines.len(), r.x, r.y, fw, Edges::own(&pf, 0, pf.lines.len())); excl.extend(wrap_area(r, &float, None)); float_tables.push(r); continue; @@ -1276,8 +1278,21 @@ fn layout_box( if !rel.is_empty() { pl = ctx.para_labelled(p, width, table, &rel, label); } - let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0)); - push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width); + // Bordered paragraphs: room for their borders, those of a group joined in one box. + let cell = table.map(|t| &t.para); + let (above, below) = (joined(ctx.doc, blocks, i, i.checked_sub(1), cell), joined(ctx.doc, blocks, i, Some(i + 1), cell)); + let mut edges = Edges::own(&pl, 0, pl.lines.len()); + if above { + edges.top = 0.0; + } + if below { + edges.bottom = 0.0; + } + let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0)) + edges.top; + if above { + edges.up = (y + lead - prev_end).max(0.0); + } + prev_end = push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width, edges); // Pictures, shapes and text boxes: drawn, their areas and text boxes' text. if !p.objects.is_empty() { let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) }; @@ -1289,7 +1304,7 @@ fn layout_box( } // Automatic space after: none after the last paragraph of a cell or box. let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after }; - y += pl.height + after; + y += edges.top + pl.height + edges.bottom + after; prev_after = after; prev_after_auto = pl.rp.after_auto; prev_listed = listed; @@ -1527,10 +1542,64 @@ fn fit_box(items: Vec, height: f32) -> Vec { out } -/// Paragraph lines plus their shading and borders. -fn push_para(items: &mut Vec, story: StoryRef, path: &[u32], pl: &Arc, l0: usize, l1: usize, x: f32, y: f32, width: f32) { +/// Word joins neighbouring paragraphs with the same borders and indents into one box: the top +/// border goes above the first of them only, the bottom one below the last, and the shading and +/// side borders run on through the space between them (`table`: the cell's paragraph properties). +fn border_group(doc: &Document, a: &Paragraph, b: &Paragraph, table: Option<&wordcraft_doc::props::ParaProps>) -> bool { + let (a, b) = (doc.styles.resolve_para_in(&a.props, table), doc.styles.resolve_para_in(&b.props, table)); + a.borders.is_some() && a.borders == b.borders && a.indent_left == b.indent_left && a.indent_right == b.indent_right +} + +/// Whether paragraph `i` of `blocks` and the one at `j` are of one border group. +fn joined(doc: &Document, blocks: &Blocks, i: usize, j: Option, table: Option<&wordcraft_doc::props::ParaProps>) -> bool { + match (blocks.get(i).map(|b| &**b), j.and_then(|j| blocks.get(j)).map(|b| &**b)) { + (Some(Block::Para(a)), Some(Block::Para(b))) => border_group(doc, a, b, table), + _ => false, + } +} + +/// The room a paragraph's top or bottom border takes in the text flow: its distance from the +/// text plus its width (Word). +fn border_room(b: Option) -> f32 { + b.filter(Border::is_visible).map_or(0.0, |b| b.space + b.width) +} + +/// How a piece of a paragraph meets the paragraphs around it: the room taken by its top and +/// bottom borders (0: none drawn there — the paragraph goes on from or into one of its border +/// group, or it is split there) and how far its shading and side borders reach up to join the +/// paragraph above in its group. +#[derive(Clone, Copy, Default)] +struct Edges { + top: f32, + bottom: f32, + up: f32, +} + +impl Edges { + /// A paragraph on its own: its top and bottom borders at its ends. + fn own(pl: &ParaLayout, l0: usize, l1: usize) -> Edges { + let b = pl.rp.borders.unwrap_or_default(); + Edges { top: if l0 == 0 { border_room(b.top) } else { 0.0 }, bottom: if l1 >= pl.lines.len() { border_room(b.bottom) } else { 0.0 }, up: 0.0 } + } +} + +/// Paragraph lines plus their shading and borders; returns the bottom of the lines. The +/// shading fills the box out to the borders, the room they take included (Word). +#[allow(clippy::too_many_arguments)] +fn push_para( + items: &mut Vec, + story: StoryRef, + path: &[u32], + pl: &Arc, + l0: usize, + l1: usize, + x: f32, + y: f32, + width: f32, + e: Edges, +) -> f32 { let (Some(first), Some(last)) = (pl.lines.get(l0), l1.checked_sub(1).and_then(|k| pl.lines.get(k))) else { - return; + return y; }; let h = last.top + last.height - first.top; // Indents are logical: a right-to-left paragraph's start indent (and hanging indent) is on the right. @@ -1538,32 +1607,32 @@ fn push_para(items: &mut Vec, story: StoryRef, path: &[u32], pl: &Arc| o.map(|b| b.space).unwrap_or(0.0); let (lx, rx) = (left - sp(&b.left), right + sp(&b.right)); - let (ty, by) = (y - sp(&b.top), y + h + sp(&b.bottom)); - if let Some(t) = b.top.filter(|_| l0 == 0) - && t.is_visible() - { + // The top and bottom lines lie beyond their distance from the text. + if let Some(t) = b.top.filter(|t| e.top > 0.0 && t.is_visible()) { + let ty = y - t.space - t.width / 2.0; items.push(Placed::Rule { x0: lx, y0: ty, x1: rx, y1: ty, border: t }); } - if let Some(t) = b.bottom.filter(|_| l1 >= pl.lines.len()) - && t.is_visible() - { + if let Some(t) = b.bottom.filter(|t| e.bottom > 0.0 && t.is_visible()) { + let by = y + h + t.space + t.width / 2.0; items.push(Placed::Rule { x0: lx, y0: by, x1: rx, y1: by, border: t }); } if let Some(t) = b.left.filter(Border::is_visible) { - items.push(Placed::Rule { x0: lx, y0: ty, x1: lx, y1: by, border: t }); + items.push(Placed::Rule { x0: lx, y0: top, x1: lx, y1: bottom, border: t }); } if let Some(t) = b.right.filter(Border::is_visible) { - items.push(Placed::Rule { x0: rx, y0: ty, x1: rx, y1: by, border: t }); + items.push(Placed::Rule { x0: rx, y0: top, x1: rx, y1: bottom, border: t }); } } + y + h } #[derive(Clone)] @@ -1614,6 +1683,8 @@ struct PageBuilder<'a> { page_valign: wordcraft_doc::section::PageVAlign, /// The document's own footnote separators, laid out for the section's text width. seps: NoteSeps, + /// Where the last paragraph's lines ended: page count, column and y. + para_end: Option<(usize, usize, f32)>, /// Per page: how many items were inserted at the front of its list (behind the text), so a /// [`Snap`] can take them out again. behind: Vec, @@ -2286,6 +2357,7 @@ fn layout_pass( block_marks: Vec::new(), page_valign: wordcraft_doc::section::PageVAlign::Top, seps: NoteSeps::default(), + para_end: None, behind: Vec::new(), max_y: 0.0, content_bottom: 0.0, @@ -3519,6 +3591,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let mut n = pl.lines.len(); // Worked out only for a last line on a page with notes. let mut after_on_page: Option = None; + // Bordered paragraphs: room for their top and bottom borders, those of a group joined in one box. + let above = joined(ctx.doc, &ctx.doc.body, block, block.checked_sub(1), None); + let below = joined(ctx.doc, &ctx.doc.body, block, Some(block + 1), None); + let own = Edges::own(&pl, 0, n); + let (top_room, bottom_room) = (if above { 0.0 } else { own.top }, if below { 0.0 } else { own.bottom }); let mut l0 = 0; while l0 < n { // The lines going into a new column (or page) were broken around the wrap areas where the @@ -3581,7 +3658,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: // How many lines fit? let Some(first) = pl.lines.get(l0) else { break }; // A paragraph's first line may start below its top (pushed down by a floating object). - let lead = if l0 == 0 { first.top.max(0.0) } else { 0.0 }; + let lead = if l0 == 0 { first.top.max(0.0) + top_room } else { 0.0 }; let mut l1 = l0; let mut forced_break = None; // A page break followed only by the paragraph mark: the mark's (empty) line stays on this @@ -3721,7 +3798,17 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: pb.bottom = pb.body_floor().max(placed_bottom).min(pb.orig_bottom); } let mut items = Vec::new(); - push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width); + let mut edges = Edges { top: if l0 == 0 { top_room } else { 0.0 }, bottom: if l1 >= n { bottom_room } else { 0.0 }, up: 0.0 }; + if l0 == 0 + && above + && let Some((page, col, end)) = pb.para_end + && page == pb.pages.len() + && col == pb.col + { + edges.up = (y - end).max(0.0); + } + let end = push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width, edges); + pb.para_end = Some((pb.pages.len(), pb.col, end)); // Pictures, shapes and text boxes in these lines (web view: floats placed by this piece). let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }), @@ -3817,7 +3904,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: // A mark that stayed with its page break keeps its space after on that page too: the next page // starts with the next paragraph (Word, compatibility modes 12 to 15). let space_after = if pb.after_break_mark { 0.0 } else { pl.rp.space_after }; - pb.y += space_after; + pb.y += bottom_room + space_after; pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, space_after, pl.rp.after_auto, label.is_some())); } @@ -3864,7 +3951,7 @@ fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let l1 = if o.view.first_line_only && level >= outline::BODY { n.min(1) } else { n }; let y = pb.y + pl.lines.first().map(|f| f.top.max(0.0)).unwrap_or(0.0); let mut items = Vec::new(); - push_para(&mut items, StoryRef::Body, &[block as u32], &pl, 0, l1, x, y, width); + push_para(&mut items, StoryRef::Body, &[block as u32], &pl, 0, l1, x, y, width, Edges::own(&pl, 0, l1)); let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }), col: (x, width), diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 174dde174..6e7ecd505 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1696,6 +1696,39 @@ fn section_breaks_show_with_formatting_marks() { assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2)); } +/// Shaded paragraphs with a bar on the left (a call-out box): the top border's room pushes the +/// first line down and is shaded, and paragraphs with the same borders make one box whose shading +/// and bar run on through the space between them; a paragraph whose borders differ (here the +/// first, with its top border) is a box of its own (Word). +#[test] +fn paragraphs_with_the_same_borders_join_in_one_shaded_box() { + let fill = wordcraft_doc::props::Rgb(0xFF, 0xF2, 0xCC); + let bar = Some(Border { width: 2.5, space: 8.0, ..Border::single(2.5) }); + let top = Some(Border { color: Some(fill), space: 6.0, ..Border::single(0.25) }); + let mut d = Document::from_text("Before\nTitle\nOne\nTwo\nAfter"); + for i in 1..4 { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.props.borders = Some(Borders { top: if i == 1 { top } else { None }, left: bar, ..Borders::default() }); + p.props.shading = Some(fill); + (p.props.space_before, p.props.space_after) = (Some(6.0), Some(6.0)); + } + let l = lay(&d); + let items = &l.pages[0].items; + let line_y: Vec = items.iter().filter_map(|i| if let Placed::Lines { y, .. } = i { Some(*y) } else { None }).collect(); + let fills: Vec = items.iter().filter_map(|i| if let Placed::Fill { rect, .. } = i { Some(*rect) } else { None }).collect(); + let bars: Vec<(f32, f32)> = + items.iter().filter_map(|i| if let Placed::Rule { x0, x1, y0, y1, .. } = i { (x0 == x1).then_some((*y0, *y1)) } else { None }).collect(); + assert_eq!(fills.len(), 3, "{fills:?}"); + // The title: shaded from the top border's outer edge, its line below the border's room. + assert!((line_y[1] - fills[0].y - 6.25).abs() < 0.01, "{line_y:?} {fills:?}"); + assert!((bars[0].0 - fills[0].y).abs() < 0.01, "{bars:?}"); + // Its box ends at its text: the space below it is not shaded (the next paragraph's borders differ). + assert!(fills[1].y - fills[0].bottom() > 5.9, "{fills:?}"); + // One and Two are one box: no gap between their shading or their bars. + assert!((fills[2].y - fills[1].bottom()).abs() < 0.01, "{fills:?}"); + assert!((bars[2].0 - bars[1].1).abs() < 0.01, "{bars:?}"); +} + #[test] fn space_between_paragraphs_is_the_larger_of_after_and_before() { // (space after the first, space before the second, contextual spacing on both) -> gap diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 657cf932a..331b889d2 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -277,6 +277,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | +| 2026-10-14 | trivial | Paragraph borders and shading: the top and bottom borders take their room (distance plus width) in the flow and the shading fills it; neighbouring paragraphs with the same borders and indents make one box, its shading and side borders running on through the space between them (`layout::tests::paragraphs_with_the_same_borders_join_in_one_shaded_box`) | | 2026-10-14 | trivial | A paragraph continued into the next column or page is broken again around the shapes there, not the ones where it started (#1374) | | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | | 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) |