Files
figcraft/crates/render/src/rtl_list_tests.rs
BFlatandClaude Opus 5.5 be7d12f00c M4: rotated frame labels; right-to-left list markers (#95)
- Frame and section name labels follow a rotated frame's top edge as shown (turned with it,
  cut to the edge's length; a mirrored frame's label stays upright, a frame turned past 90
  degrees takes its label along). Component icons, section pills, eyes and status badges turn too.
- The canvas publishes each label's area to the engine (ToolState::labels): with the Move and
  Scale tools, hovering a label highlights its frame and pressing it selects and drags the frame
  (one undo step); labels of locked, hidden, deleted or other pages' frames do nothing.
- Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on the right,
  wrapped lines hanging from the right, the paragraph indent on the right; trailing spaces of a
  right-to-left line hang outside it instead of pushing its text out of a right-aligned box.
  Canvas, PNG, PDF and SVG all draw the shared layout.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:58:01 +07:00

64 lines
3.1 KiB
Rust

//! List markers of right-to-left paragraphs on the right, in what the canvas and PNG export draw
//! and in the outlines the SVG export writes: the bullet ends at the box's right edge with a clear
//! gap between it and the text on its left. (The PDF export draws the same laid-out glyphs.)
use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextData};
use super::*;
const W: f64 = 300.0;
/// A 300-wide right-aligned bulleted Hebrew paragraph at the origin.
fn doc() -> (Document, NodeId, TextData) {
let mut td = TextData::new("\u{05E9}\u{05DC}\u{05D5}\u{05DD} \u{05E2}\u{05D5}\u{05DC}\u{05DD}");
td.style.font_size = 20.0;
td.list = ListStyle::Bulleted;
td.align_h = TextAlignH::Right;
td.auto_resize = TextAutoResize::Height;
let mut d = Document::new("rtl");
let p = d.first_page().unwrap();
let id = d.create(p, NodeKind::Text(td.clone()), "T", 0.0, 0.0, W, 40.0).unwrap();
(d, id, td)
}
/// The bullet's span and where the text ends, from the layout; None without Hebrew fonts.
fn spans(td: &TextData) -> Option<((f64, f64), f64)> {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(figcraft_text::scan_system_fonts);
let l = figcraft_text::layout(td, Some(W), None);
if l.glyphs.is_empty() || l.glyphs.iter().any(|g| g.gid == 0) {
eprintln!("no font for Hebrew; skipping");
return None;
}
let m = l.glyphs.iter().find(|g| g.char_start == g.char_end)?;
let text_right = l.glyphs.iter().filter(|g| g.char_end > g.char_start).map(|g| g.x + g.advance).fold(f64::MIN, f64::max);
Some(((m.x, m.x + m.advance), text_right))
}
#[test]
fn the_bullet_of_a_right_to_left_paragraph_draws_on_the_right() {
let (d, id, td) = doc();
let Some(((m0, m1), text_right)) = spans(&td) else { return };
assert!(m0 > text_right + 8.0 && (m1 - W).abs() < 0.5, "{m0}..{m1}, text to {text_right}");
let mut pix = Pixmap::new(W as u32, 40).unwrap();
render_nodes_into(&d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: W as u32, height: 40 }, &mut pix);
let inked = |x: u32| (0..40).any(|y| pix.pixel(x, y).is_some_and(|p| p.alpha() > 64));
assert!((m0.ceil() as u32..m1.floor() as u32).any(inked), "the bullet is drawn");
assert!(!(text_right.ceil() as u32 + 2..m0.floor() as u32 - 1).any(inked), "nothing between the text and the bullet");
assert!((0..text_right as u32).any(inked), "the text is drawn");
// The outline the SVG export writes: the same gap.
let (path, _) = draw::node_outline(&d, d.node(id).unwrap()).unwrap();
let xs: Vec<f64> = path
.elements()
.iter()
.flat_map(|e| match e {
figcraft_geom::kurbo::PathEl::MoveTo(p) | figcraft_geom::kurbo::PathEl::LineTo(p) => vec![p.x],
figcraft_geom::kurbo::PathEl::QuadTo(a, p) => vec![a.x, p.x],
figcraft_geom::kurbo::PathEl::CurveTo(a, b, p) => vec![a.x, b.x, p.x],
figcraft_geom::kurbo::PathEl::ClosePath => vec![],
})
.collect();
assert!(xs.iter().any(|x| (m0..=m1).contains(x)));
assert!(!xs.iter().any(|x| *x > text_right + 1.0 && *x < m0 - 1.0), "{xs:?}");
}