diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 801c0fbf4..8cdf8ee53 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -2,7 +2,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind}; use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline}; -use wordcraft_doc::{Document, Path, StoryRef}; +use wordcraft_doc::section::SectionStart; +use wordcraft_doc::{Block, Document, Path, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; @@ -82,6 +83,14 @@ pub enum Draw { turn: TextDirection, items: Vec, }, + /// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at + /// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark. + MarkText { + x: f32, + baseline: f32, + size: f32, + text: String, + }, } impl Draw { @@ -568,7 +577,9 @@ fn lines( } } if line.end == LineEnd::Para { - let ex = x + line.end_x(); + // The mark follows the line's visual end, past every glyph, even where the + // logically last text runs against the paragraph's direction. + let ex = x + line.visual_end_x(); let size = pl.lines.first().map(|_| msize).unwrap_or(msize); // A right-to-left paragraph's mark sits at its end, on the left. let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 }; @@ -577,13 +588,75 @@ fn lines( .and_then(|p| p.mark.ins.or(p.mark.del)) .filter(|_| opts.markup) .map(|r| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))); - out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color }); + // The last paragraph of a section ends in a section break instead of a plain ¶. + if let Some(start) = section_break_after(doc, story, path) { + let (x0, x1) = if line.rtl { (x + line.left, ex - 2.0) } else { (ex + 2.0, x + line.right) }; + section_break_mark(start, x0, x1, base, size, alpha, out); + } else { + out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color }); + } } } let _ = bottom; } } +/// The kind of section break that ends at body paragraph `path`: the start type of the section +/// that follows it. `None` for any other paragraph, and for the document's last section. +fn section_break_after(doc: &Document, story: StoryRef, path: &Path) -> Option { + if story != StoryRef::Body || path.0.len() != 1 { + return None; + } + let i = *path.0.first()? as usize; + match doc.body.get(i).map(|b| &**b) { + Some(Block::Para(p)) if p.section.is_some() => {} + _ => return None, + } + let secs = doc.sections(); + let k = secs.iter().position(|(end, _)| *end == i)?; + secs.get(k + 1).map(|(_, s)| s.start) +} + +/// On-screen name of a section break. +pub fn section_break_label(start: SectionStart) -> &'static str { + match start { + SectionStart::NextPage => "Section Break (Next Page)", + SectionStart::Continuous => "Section Break (Continuous)", + SectionStart::EvenPage => "Section Break (Even Page)", + SectionStart::OddPage => "Section Break (Odd Page)", + SectionStart::NextColumn => "Section Break (Next Column)", + } +} + +/// Width of a [`Draw::MarkText`] label. +pub fn mark_text_width(text: &str, size: f32) -> f32 { + let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face; + let k = size / face.upem.max(1.0) as f32; + text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum() +} + +/// A section break mark between `x0` and `x1`: a dotted double rule with the break's name centred +/// in it (just the name when there is no room for the rule). +fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f32, alpha: f32, out: &mut Vec) { + let text = section_break_label(start); + let lsize = (size * 0.8).clamp(6.0, 11.0); + let w = mark_text_width(text, lsize); + let gap = lsize * 0.4; + let mid = base - size * 0.3; + let color = Rgb(0x60, 0x60, 0x60); + if x1 - x0 <= w + 4.0 * gap { + out.push(Draw::MarkText { x: x0, baseline: base, size: lsize, text: text.into() }); + return; + } + let tx = (x0 + x1 - w) / 2.0; + for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] { + for y in [mid - 1.2, mid + 1.2] { + out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha }); + } + } + out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() }); +} + /// An equation's glyphs and rules with its origin at (`x`, `base`). fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec) { for it in items { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 995a04e5b..171b138cf 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -168,6 +168,14 @@ impl Line { None => self.xs.last().copied().unwrap_or(self.left), } } + /// x of the line's visual end for the paragraph's direction, where its paragraph mark goes: + /// the right edge of the rightmost cluster in a left-to-right paragraph, the left edge of the + /// leftmost in a right-to-left one. Differs from [`Line::end_x`] when the logically last text + /// runs the other way (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one). + pub fn visual_end_x(&self) -> f32 { + let end = self.end_x(); + if self.rtl { self.vis.iter().map(|v| v.x).fold(end, f32::min) } else { self.vis.iter().map(|v| v.x + v.w).fold(end, f32::max) } + } /// x where the hyphen of a hyphenated line (advance `adv`) is drawn. pub fn hyphen_x(&self, adv: f32) -> f32 { match self.vis.last() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ede55d3c1..ad198c519 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -703,6 +703,63 @@ fn a_tracked_paragraph_mark_shows_in_its_authors_colour() { assert_eq!(marks(false), [None, None]); } +#[test] +fn section_breaks_show_with_formatting_marks() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + // Two sections: "One" ends the first; the second starts as `start`. + let doc = |start: SectionStart| { + let mut d = Document::from_text("One\nTwo"); + d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default())); + d.last_section.start = start; + d + }; + let labels = |d: &Document, marks: bool| -> (Vec, usize) { + let l = lay(d); + let items = display::page_display(d, &l.pages[0], &display::DisplayOptions { marks, ..Default::default() }); + let texts = items.iter().filter_map(|i| if let display::Draw::MarkText { text, .. } = i { Some(text.clone()) } else { None }).collect(); + let pilcrows = items.iter().filter(|i| matches!(i, display::Draw::Mark { ch: '¶', .. })).count(); + (texts, pilcrows) + }; + let d = doc(SectionStart::Continuous); + // The break replaces the first paragraph's ¶; the last paragraph keeps its own. + assert_eq!(labels(&d, true), (vec!["Section Break (Continuous)".to_string()], 1)); + assert_eq!(labels(&d, false), (vec![], 0)); + assert_eq!(labels(&doc(SectionStart::NextPage), true).0, ["Section Break (Next Page)"]); + assert_eq!(labels(&doc(SectionStart::EvenPage), true).0, ["Section Break (Even Page)"]); + assert_eq!(labels(&doc(SectionStart::OddPage), true).0, ["Section Break (Odd Page)"]); + // A document with one section has no break. + assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2)); +} + +#[test] +fn paragraph_marks_sit_past_text_running_the_other_way() { + // A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the + // logically last letters are at the wrong edge, but the ¶ still goes past every glyph. + let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc"); + let at = Pos::body(1, 0); + d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap(); + let l = lay(&d); + // Leftmost and rightmost glyph edges (page x) of each paragraph's single line. + let extents: Vec<(f32, f32)> = l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para, *x)) } else { None }) + .map(|(pl, x)| { + let ln = &pl.lines[0]; + let edges = (ln.c0..ln.c1).filter_map(|k| Some((ln.cl_left(k)?, ln.cl_right(k)?))); + edges.fold((f32::MAX, f32::MIN), |(a, b), (l, r)| (a.min(x + l), b.max(x + r))) + }) + .collect(); + let marks: Vec<(f32, f32)> = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() }) + .into_iter() + .filter_map(|i| if let display::Draw::Mark { ch: '¶', x, size, .. } = i { Some((x, size)) } else { None }) + .collect(); + assert_eq!((extents.len(), marks.len()), (2, 2)); + let ((ltr_x, _), (rtl_x, size)) = (marks[0], marks[1]); + assert!(ltr_x >= extents[0].1, "LTR ¶ at {ltr_x} should be right of all glyphs {:?}", extents[0]); + assert!(rtl_x + size * 0.5 <= extents[1].0, "RTL ¶ at {rtl_x} should be left of all glyphs {:?}", extents[1]); +} + fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> { border_lines_in(d, &lay(d)) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3a79b9262..b191412b9 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -650,7 +650,7 @@ impl Exporter<'_> { s.set_fill(None); s.set_stroke(None); } - Draw::Mark { .. } => {} + Draw::Mark { .. } | Draw::MarkText { .. } => {} Draw::Turned { x, y, turn, items } => { if !ok(*x) || !ok(*y) { return; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ac1179395..de4d47239 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -382,6 +382,25 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k)); ctx.fill_path(&o); } + Draw::MarkText { x, baseline, size, text } => { + let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face; + ctx.set_paint(color(opts.ink(opts.mark_color), 0.9)); + let mut gx = *x as f64; + for ch in text.chars() { + let mut face = base_face; + if face.glyph_for(ch) == 0 + && let Some(f) = FontDb::global().fallback_for(ch, face.id()) + { + face = wordcraft_fonts::FaceRef::of(&f); + } + let gid = face.glyph_for(ch); + let k = *size as f64 / face.upem.max(1.0); + let o = FontDb::global().outline(&face, gid); + ctx.set_transform(view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k)); + ctx.fill_path(&o); + gx += face.advance(gid) * k; + } + } Draw::Image { rect, media, crop, alpha } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.overlaps(*visible) {