Show/Hide ¶: paragraph mark no longer overlaps text in mixed-direction paragraphs (#278)

* Show section breaks with Show/Hide ¶ (#42)

With formatting marks on, the last paragraph of a section now ends in a
dotted double rule with the break's name centred in it (Section Break
(Next Page), (Continuous), (Even Page), (Odd Page)) instead of a plain ¶.
The type shown is the start type of the section that follows. The mark
is screen-only: PDF export skips it, and print/PNG never turn marks on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show/Hide ¶: put the paragraph mark past mixed-direction text (#274)

The paragraph mark (and the section-break mark) was anchored at the caret
position after the paragraph's logically last character. When that text
runs against the paragraph's direction (Hebrew ending a left-to-right
paragraph, Latin ending a right-to-left one) that position is inside the
line, so the mark was drawn on top of a letter. The mark now sits at the
line's visual end for the paragraph's direction: right of the rightmost
glyph in a left-to-right paragraph, left of the leftmost in a right-to-left
one. Pure left-to-right and pure right-to-left lines are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-10 13:56:32 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent da18ae8933
commit a99dddcbdc
5 changed files with 161 additions and 4 deletions
+76 -3
View File
@@ -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<Draw>,
},
/// 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<SectionStart> {
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<Draw>) {
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<Draw>) {
for it in items {
+8
View File
@@ -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() {
+57
View File
@@ -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<String>, 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))
}
+1 -1
View File
@@ -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;
+19
View File
@@ -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) {