mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
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:
co-authored by
Claude Opus 5.5
parent
da18ae8933
commit
a99dddcbdc
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user