mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 14:26:49 -06:00
- crates/mcp: JSON-RPC MCP server (18 tools, resources, prompts), headless and attached backends, end-to-end agent tests
- apps/figcraft-cli: info/export/convert/render/run/commands/mcp/perf
- crates/pdf: vector PDF export via krilla with embedded text; file.exportFramesPdf
- apps/figcraft-web + packaging/{macos,windows,linux,freebsd,web} + release/ci workflows
- engine vectoredit: edit points/handles/bend/add/delete-heal; canvas overlay and edit strip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
651 lines
24 KiB
Rust
651 lines
24 KiB
Rust
//! Text layout: shaping styled spans, fallback fonts, line breaking, alignment and decorations.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use figcraft_doc::{LineHeight, ListStyle, TextAlignH, TextAutoResize, TextData, TextDecoration, TextStyle};
|
|
use figcraft_geom::{Affine, BezPath, Rect};
|
|
use harfrust::{Direction, ShapeOptions, UnicodeBuffer};
|
|
|
|
use crate::fontdb::{Face, db};
|
|
|
|
/// A positioned glyph (pen position on the baseline, layout coordinates, y down).
|
|
#[derive(Clone, Debug)]
|
|
pub struct Glyph {
|
|
pub face: Arc<Face>,
|
|
pub gid: u32,
|
|
pub x: f64,
|
|
pub y: f64,
|
|
pub size: f64,
|
|
pub advance: f64,
|
|
/// Source char range of the glyph's cluster.
|
|
pub char_start: usize,
|
|
pub char_end: usize,
|
|
/// Index into [`TextLayout::styles`].
|
|
pub style: usize,
|
|
}
|
|
|
|
impl Glyph {
|
|
/// The glyph outline placed in layout coordinates.
|
|
pub fn path(&self) -> BezPath {
|
|
let s = self.size / self.face.upem;
|
|
let mut p = (*self.face.outline(self.gid)).clone();
|
|
p.apply_affine(Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
|
|
p
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Line {
|
|
/// Glyph index range.
|
|
pub glyph_start: usize,
|
|
pub glyph_end: usize,
|
|
pub char_start: usize,
|
|
pub char_end: usize,
|
|
pub top: f64,
|
|
pub height: f64,
|
|
pub baseline: f64,
|
|
pub width: f64,
|
|
pub x: f64,
|
|
/// The line ends a paragraph (hard break or end of text).
|
|
pub para_end: bool,
|
|
}
|
|
|
|
/// A decoration line (underline/strikethrough) to paint with the style's fills.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Decoration {
|
|
pub rect: Rect,
|
|
pub style: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct TextLayout {
|
|
pub glyphs: Vec<Glyph>,
|
|
pub lines: Vec<Line>,
|
|
pub decorations: Vec<Decoration>,
|
|
/// The distinct styles the glyphs refer to.
|
|
pub styles: Vec<TextStyle>,
|
|
/// Size of the laid-out content.
|
|
pub width: f64,
|
|
pub height: f64,
|
|
/// Whether text was cut off (truncation).
|
|
pub truncated: bool,
|
|
}
|
|
|
|
/// Ratio of Figma's "Auto" line height to the font size (font ascent+descent+gap, per face).
|
|
fn natural_ratio(face: &Face) -> f64 {
|
|
((face.ascent + face.descent + face.line_gap.max(0.0)) / face.upem).max(0.5)
|
|
}
|
|
|
|
struct Item {
|
|
face: Arc<Face>,
|
|
gid: u32,
|
|
adv: f64,
|
|
dx: f64,
|
|
dy: f64,
|
|
char_start: usize,
|
|
char_end: usize,
|
|
style: usize,
|
|
ch: char,
|
|
}
|
|
|
|
impl Item {
|
|
fn is_space(&self) -> bool {
|
|
matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}')
|
|
}
|
|
fn break_after(&self) -> bool {
|
|
self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || is_cjk(self.ch)
|
|
}
|
|
}
|
|
|
|
fn is_cjk(c: char) -> bool {
|
|
matches!(c as u32, 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF)
|
|
}
|
|
|
|
/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
|
|
/// pass `None`). `box_height` is used for truncation only.
|
|
pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) -> TextLayout {
|
|
let chars: Vec<char> = text.characters.chars().collect();
|
|
let spans = text.spans();
|
|
let mut styles: Vec<TextStyle> = Vec::new();
|
|
let mut span_style: Vec<usize> = Vec::with_capacity(spans.len());
|
|
for (_, _, s) in &spans {
|
|
let idx = match styles.iter().position(|x| x == *s) {
|
|
Some(i) => i,
|
|
None => {
|
|
styles.push((*s).clone());
|
|
styles.len() - 1
|
|
}
|
|
};
|
|
span_style.push(idx);
|
|
}
|
|
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let fallbacks = dbw.fallbacks();
|
|
let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
|
|
for s in &styles {
|
|
faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
|
|
}
|
|
drop(dbw);
|
|
|
|
// Shape each span, split by paragraph, falling back per char.
|
|
let mut items: Vec<Item> = Vec::with_capacity(chars.len());
|
|
for (si, (start, end, _)) in spans.iter().enumerate() {
|
|
let st_idx = span_style.get(si).copied().unwrap_or(0);
|
|
let Some(style) = styles.get(st_idx) else { continue };
|
|
let Some(Some(primary)) = faces.get(st_idx) else { continue };
|
|
let size = sanitize_size(style.font_size);
|
|
let spacing = style.letter_spacing.resolve(size);
|
|
// Segment by face.
|
|
let mut seg_start = *start;
|
|
while seg_start < *end {
|
|
let face_for = |c: char| -> Arc<Face> {
|
|
if c == '\n' || primary.has_char(c) {
|
|
return primary.clone();
|
|
}
|
|
fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
|
|
};
|
|
let first = chars.get(seg_start).copied().unwrap_or(' ');
|
|
let face = face_for(first);
|
|
let mut seg_end = seg_start + 1;
|
|
while seg_end < *end && chars.get(seg_end).is_some_and(|c| *c != '\n' && first != '\n' && face_for(*c).id == face.id) {
|
|
seg_end += 1;
|
|
}
|
|
if first == '\n' {
|
|
items.push(Item {
|
|
face: face.clone(),
|
|
gid: 0,
|
|
adv: 0.0,
|
|
dx: 0.0,
|
|
dy: 0.0,
|
|
char_start: seg_start,
|
|
char_end: seg_start + 1,
|
|
style: st_idx,
|
|
ch: '\n',
|
|
});
|
|
} else {
|
|
shape_segment(&chars, seg_start, seg_end, &face, style, size, spacing, st_idx, &mut items);
|
|
}
|
|
seg_start = seg_end;
|
|
}
|
|
}
|
|
|
|
// List markers.
|
|
let marker_width = match text.list {
|
|
ListStyle::None => 0.0,
|
|
_ => styles.first().map(|s| sanitize_size(s.font_size) * 1.5).unwrap_or(18.0),
|
|
};
|
|
let indent = text.paragraph_indent.max(0.0);
|
|
|
|
// Line breaking.
|
|
let wrap = match text.auto_resize {
|
|
TextAutoResize::WidthAndHeight => None,
|
|
_ => box_width.filter(|w| w.is_finite() && *w > 0.0),
|
|
};
|
|
let mut lines: Vec<(usize, usize, bool)> = Vec::new(); // item range, paragraph end
|
|
let mut line_start = 0;
|
|
let mut i = 0;
|
|
let mut x = 0.0;
|
|
let mut last_break: Option<usize> = None;
|
|
let mut para_first_line = true;
|
|
while i < items.len() {
|
|
let Some(it) = items.get(i) else { break };
|
|
if it.ch == '\n' {
|
|
lines.push((line_start, i + 1, true));
|
|
i += 1;
|
|
line_start = i;
|
|
x = 0.0;
|
|
last_break = None;
|
|
para_first_line = true;
|
|
continue;
|
|
}
|
|
let line_indent = marker_width + if para_first_line { indent } else { 0.0 };
|
|
let w = it.adv;
|
|
if let Some(maxw) = wrap
|
|
&& x + w > maxw - line_indent + 1e-6
|
|
&& i > line_start
|
|
&& !it.is_space()
|
|
{
|
|
let brk = last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i);
|
|
lines.push((line_start, brk, false));
|
|
line_start = brk;
|
|
i = brk;
|
|
x = 0.0;
|
|
last_break = None;
|
|
para_first_line = false;
|
|
continue;
|
|
}
|
|
x += w;
|
|
if it.break_after() {
|
|
last_break = Some(i);
|
|
}
|
|
i += 1;
|
|
}
|
|
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) {
|
|
lines.push((line_start, items.len(), true));
|
|
}
|
|
|
|
// Line metrics and positions.
|
|
let default_style = styles.first().cloned().unwrap_or_default();
|
|
let default_face = faces.first().cloned().flatten();
|
|
let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
|
|
let mut y = 0.0;
|
|
let mut para_start_line = true;
|
|
let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX };
|
|
for (li, (s, e, para_end)) in lines.iter().enumerate() {
|
|
if li >= max_lines {
|
|
out.truncated = true;
|
|
break;
|
|
}
|
|
// Metrics from the styles present on the line (or the style at the break for empty lines).
|
|
let mut asc: f64 = 0.0;
|
|
let mut desc: f64 = 0.0;
|
|
let mut lh: f64 = 0.0;
|
|
let mut any = false;
|
|
for it in items.get(*s..*e).unwrap_or(&[]) {
|
|
let Some(st) = styles.get(it.style) else { continue };
|
|
let size = sanitize_size(st.font_size);
|
|
let k = size / it.face.upem;
|
|
asc = asc.max(it.face.ascent * k);
|
|
desc = desc.max(it.face.descent * k);
|
|
lh = lh.max(st.line_height.resolve(size, natural_ratio(&it.face)));
|
|
any = true;
|
|
}
|
|
if !any {
|
|
let st = items.get(s.saturating_sub(1)).and_then(|it| styles.get(it.style)).unwrap_or(&default_style);
|
|
let size = sanitize_size(st.font_size);
|
|
if let Some(f) = items.get(s.saturating_sub(1)).map(|it| it.face.clone()).or_else(|| default_face.clone()) {
|
|
let k = size / f.upem;
|
|
asc = f.ascent * k;
|
|
desc = f.descent * k;
|
|
lh = st.line_height.resolve(size, natural_ratio(&f));
|
|
} else {
|
|
asc = size * 0.8;
|
|
desc = size * 0.2;
|
|
lh = size * 1.2;
|
|
}
|
|
}
|
|
if para_start_line && li > 0 {
|
|
y += text.paragraph_spacing.max(0.0);
|
|
}
|
|
let baseline = y + (lh - (asc + desc)) / 2.0 + asc;
|
|
// Trailing spaces don't count for width/alignment.
|
|
let mut vis_end = *e;
|
|
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') {
|
|
vis_end -= 1;
|
|
}
|
|
let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum();
|
|
let line_indent = marker_width + if para_start_line { indent } else { 0.0 };
|
|
let gstart = out.glyphs.len();
|
|
let mut pen = line_indent;
|
|
// Justify: extra space per gap.
|
|
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count();
|
|
let justify_extra = match (text.align_h, wrap) {
|
|
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent - width) / gaps as f64).max(0.0),
|
|
_ => 0.0,
|
|
};
|
|
for it in items.get(*s..*e).unwrap_or(&[]) {
|
|
if it.ch == '\n' {
|
|
continue;
|
|
}
|
|
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0);
|
|
if it.gid != 0 || !it.is_space() {
|
|
out.glyphs.push(Glyph {
|
|
face: it.face.clone(),
|
|
gid: it.gid,
|
|
x: pen + it.dx,
|
|
y: baseline - it.dy,
|
|
size,
|
|
advance: it.adv,
|
|
char_start: it.char_start,
|
|
char_end: it.char_end,
|
|
style: it.style,
|
|
});
|
|
}
|
|
pen += it.adv;
|
|
if it.is_space() {
|
|
pen += justify_extra;
|
|
}
|
|
}
|
|
// List marker.
|
|
if para_start_line && text.list != ListStyle::None {
|
|
let marker = match text.list {
|
|
ListStyle::Bulleted => "•".to_string(),
|
|
_ => format!("{}.", count_paragraphs_before(&chars, items.get(*s).map(|it| it.char_start).unwrap_or(0)) + 1),
|
|
};
|
|
let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0);
|
|
if let (Some(st), Some(face)) = (styles.get(st_idx), items.get(*s).map(|it| it.face.clone()).or_else(|| default_face.clone())) {
|
|
let size = sanitize_size(st.font_size);
|
|
let k = size / face.upem;
|
|
let mut mx = indent;
|
|
for c in marker.chars() {
|
|
let gid = face.glyph_for(c);
|
|
let adv = face.advance(gid) * k;
|
|
out.glyphs.push(Glyph {
|
|
face: face.clone(),
|
|
gid,
|
|
x: mx,
|
|
y: baseline,
|
|
size,
|
|
advance: adv,
|
|
char_start: 0,
|
|
char_end: 0,
|
|
style: st_idx,
|
|
});
|
|
mx += adv;
|
|
}
|
|
}
|
|
}
|
|
let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len());
|
|
let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start);
|
|
out.lines.push(Line {
|
|
glyph_start: gstart,
|
|
glyph_end: out.glyphs.len(),
|
|
char_start,
|
|
char_end,
|
|
top: y,
|
|
height: lh,
|
|
baseline,
|
|
width: width + line_indent,
|
|
x: 0.0,
|
|
para_end: *para_end,
|
|
});
|
|
y += lh;
|
|
para_start_line = *para_end;
|
|
}
|
|
out.height = y;
|
|
out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max);
|
|
|
|
// Truncation by height.
|
|
if matches!(text.auto_resize, TextAutoResize::Truncate)
|
|
&& let Some(h) = box_height
|
|
&& out.height > h + 0.5
|
|
{
|
|
let keep = out.lines.iter().take_while(|l| l.top + l.height <= h + 0.5).count().max(1);
|
|
if keep < out.lines.len() {
|
|
out.lines.truncate(keep);
|
|
let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0);
|
|
out.glyphs.truncate(gend);
|
|
out.truncated = true;
|
|
out.height = out.lines.last().map(|l| l.top + l.height).unwrap_or(0.0);
|
|
}
|
|
}
|
|
if out.truncated {
|
|
add_ellipsis(&mut out, wrap);
|
|
}
|
|
|
|
// Horizontal alignment within the box.
|
|
let align_width = match text.auto_resize {
|
|
TextAutoResize::WidthAndHeight => out.width,
|
|
_ => box_width.unwrap_or(out.width),
|
|
};
|
|
for li in 0..out.lines.len() {
|
|
let Some(line) = out.lines.get(li) else { continue };
|
|
let dx = match text.align_h {
|
|
TextAlignH::Left | TextAlignH::Justified => 0.0,
|
|
TextAlignH::Center => (align_width - line.width) / 2.0,
|
|
TextAlignH::Right => align_width - line.width,
|
|
};
|
|
if dx != 0.0 {
|
|
let (gs, ge) = (line.glyph_start, line.glyph_end);
|
|
for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {
|
|
g.x += dx;
|
|
}
|
|
if let Some(l) = out.lines.get_mut(li) {
|
|
l.x = dx;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decorations per contiguous glyph run of the same style on a line.
|
|
let mut decos = Vec::new();
|
|
for line in &out.lines {
|
|
let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
|
let mut j = 0;
|
|
while j < gl.len() {
|
|
let Some(g0) = gl.get(j) else { break };
|
|
let st = out.styles.get(g0.style).cloned().unwrap_or_default();
|
|
let mut k = j + 1;
|
|
while gl.get(k).is_some_and(|g| g.style == g0.style) {
|
|
k += 1;
|
|
}
|
|
if st.decoration != TextDecoration::None
|
|
&& let Some(last) = gl.get(k - 1)
|
|
{
|
|
let x0 = g0.x;
|
|
let x1 = last.x + last.advance;
|
|
let f = &g0.face;
|
|
let s = g0.size / f.upem;
|
|
let th = (f.underline_thickness * s).max(0.5);
|
|
let yy = match st.decoration {
|
|
TextDecoration::Underline => line.baseline - f.underline_pos * s,
|
|
_ => line.baseline - f.strike_pos * s,
|
|
};
|
|
decos.push(Decoration { rect: Rect::new(x0, yy - th / 2.0, x1, yy + th / 2.0), style: g0.style });
|
|
}
|
|
j = k;
|
|
}
|
|
}
|
|
out.decorations = decos;
|
|
let _ = LineHeight::Auto;
|
|
out
|
|
}
|
|
|
|
fn count_paragraphs_before(chars: &[char], pos: usize) -> usize {
|
|
chars.iter().take(pos).filter(|c| **c == '\n').count()
|
|
}
|
|
|
|
fn add_ellipsis(out: &mut TextLayout, wrap: Option<f64>) {
|
|
let Some(line) = out.lines.last().cloned() else { return };
|
|
let Some(last) = out.glyphs.get(line.glyph_end.saturating_sub(1)).cloned() else { return };
|
|
let face = last.face.clone();
|
|
let gid = face.glyph_for('…');
|
|
let gid = if gid == 0 { face.glyph_for('.') } else { gid };
|
|
let k = last.size / face.upem;
|
|
let adv = face.advance(gid) * k;
|
|
let limit = wrap.unwrap_or(f64::INFINITY);
|
|
// Drop glyphs until the ellipsis fits.
|
|
while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.x + g.advance + adv > limit) {
|
|
out.glyphs.pop();
|
|
}
|
|
let x = out.glyphs.last().filter(|_| out.glyphs.len() > line.glyph_start).map(|g| g.x + g.advance).unwrap_or(0.0);
|
|
out.glyphs.push(Glyph {
|
|
face,
|
|
gid,
|
|
x,
|
|
y: line.baseline,
|
|
size: last.size,
|
|
advance: adv,
|
|
char_start: last.char_end,
|
|
char_end: last.char_end,
|
|
style: last.style,
|
|
});
|
|
if let Some(l) = out.lines.last_mut() {
|
|
l.glyph_end = out.glyphs.len();
|
|
l.width = x + adv;
|
|
}
|
|
}
|
|
|
|
fn sanitize_size(s: f64) -> f64 {
|
|
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn shape_segment(
|
|
chars: &[char],
|
|
start: usize,
|
|
end: usize,
|
|
face: &Arc<Face>,
|
|
style: &TextStyle,
|
|
size: f64,
|
|
spacing: f64,
|
|
st_idx: usize,
|
|
items: &mut Vec<Item>,
|
|
) {
|
|
let k = size / face.upem;
|
|
let seg: String = chars.get(start..end).unwrap_or(&[]).iter().collect();
|
|
let cased = style.case.apply(&seg);
|
|
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new();
|
|
if let Some(hb) = face.hb() {
|
|
let shaper = face.shaper.shaper(&hb).build();
|
|
let mut buf = UnicodeBuffer::new();
|
|
if cased.chars().count() == seg.chars().count() {
|
|
for (i, c) in cased.chars().enumerate() {
|
|
buf.add(c, (start + i) as u32);
|
|
}
|
|
} else {
|
|
// Case mapping changed the length: map every produced char to its source char.
|
|
for (i, c) in seg.chars().enumerate() {
|
|
let mapped = style.case.apply(&c.to_string());
|
|
for u in mapped.chars() {
|
|
buf.add(u, (start + i) as u32);
|
|
}
|
|
}
|
|
}
|
|
buf.set_direction(Direction::LeftToRight);
|
|
buf.guess_segment_properties();
|
|
let gb = shaper.shape(buf, ShapeOptions::new());
|
|
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
|
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
|
}
|
|
} else {
|
|
for (i, c) in seg.chars().enumerate() {
|
|
let gid = face.glyph_for(c);
|
|
raw.push((gid, (start + i) as u32, face.advance(gid) as i32, 0, 0));
|
|
}
|
|
}
|
|
let n = raw.len();
|
|
for gi in 0..n {
|
|
let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue };
|
|
let cl = cl as usize;
|
|
// Cluster end: the next larger cluster value, else the segment end.
|
|
let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1);
|
|
let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl);
|
|
let ch = chars.get(cl).copied().unwrap_or(' ');
|
|
let adv = f64::from(xa) * k + if last_in_cluster { spacing * (cend - cl) as f64 } else { 0.0 };
|
|
items.push(Item {
|
|
face: face.clone(),
|
|
gid,
|
|
adv,
|
|
dx: f64::from(xo) * k,
|
|
dy: f64::from(yo) * k,
|
|
char_start: cl,
|
|
char_end: cend,
|
|
style: st_idx,
|
|
ch,
|
|
});
|
|
}
|
|
}
|
|
|
|
impl TextLayout {
|
|
/// The caret position (x, line index) before char `index`.
|
|
pub fn caret(&self, index: usize) -> (f64, usize) {
|
|
for (li, line) in self.lines.iter().enumerate() {
|
|
let last_line = li + 1 == self.lines.len();
|
|
let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end));
|
|
// A caret at the end of a soft-wrapped line belongs to the next line.
|
|
if !in_line && !(index == line.char_end && (line.para_end && !last_line)) {
|
|
continue;
|
|
}
|
|
if index == line.char_end && line.para_end && !last_line {
|
|
// Before the newline: end of this line.
|
|
let x = self.line_end_x(line);
|
|
return (x, li);
|
|
}
|
|
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
|
for g in gl {
|
|
if g.char_end == g.char_start {
|
|
continue;
|
|
}
|
|
if index >= g.char_start && index < g.char_end {
|
|
let frac = (index - g.char_start) as f64 / (g.char_end - g.char_start) as f64;
|
|
return (g.x + g.advance * frac, li);
|
|
}
|
|
}
|
|
return (self.line_end_x(line), li);
|
|
}
|
|
let li = self.lines.len().saturating_sub(1);
|
|
(self.lines.last().map(|l| self.line_end_x(l)).unwrap_or(0.0), li)
|
|
}
|
|
|
|
fn line_end_x(&self, line: &Line) -> f64 {
|
|
self.glyphs
|
|
.get(line.glyph_start..line.glyph_end)
|
|
.unwrap_or(&[])
|
|
.iter()
|
|
.filter(|g| g.char_end > g.char_start)
|
|
.map(|g| g.x + g.advance)
|
|
.fold(line.x, f64::max)
|
|
}
|
|
|
|
/// The char index nearest to `(x, y)` (layout coordinates).
|
|
pub fn hit(&self, x: f64, y: f64) -> usize {
|
|
let Some((li, line)) =
|
|
self.lines.iter().enumerate().find(|(_, l)| y < l.top + l.height).or_else(|| self.lines.iter().enumerate().next_back())
|
|
else {
|
|
return 0;
|
|
};
|
|
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
|
for g in gl {
|
|
if g.char_end <= g.char_start {
|
|
continue;
|
|
}
|
|
if x < g.x + g.advance / 2.0 {
|
|
return g.char_start;
|
|
}
|
|
}
|
|
let last_line = li + 1 == self.lines.len();
|
|
if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }
|
|
}
|
|
|
|
/// Selection rectangles for chars `start..end`.
|
|
pub fn selection_rects(&self, start: usize, end: usize) -> Vec<Rect> {
|
|
let (a, b) = (start.min(end), start.max(end));
|
|
let mut out = Vec::new();
|
|
for (li, line) in self.lines.iter().enumerate() {
|
|
if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) {
|
|
continue;
|
|
}
|
|
let (x0, _) = self.caret(a.max(line.char_start));
|
|
let x1 = if b >= line.char_end {
|
|
self.line_end_x(line) + if line.para_end && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
|
} else {
|
|
self.caret(b).0
|
|
};
|
|
out.push(Rect::new(x0, line.top, x1.max(x0 + 1.0), line.top + line.height));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// All glyph outlines as one path (for outline/flatten and boolean ops).
|
|
pub fn to_path(&self) -> BezPath {
|
|
let mut p = BezPath::new();
|
|
for g in &self.glyphs {
|
|
p.extend(g.path());
|
|
}
|
|
for d in &self.decorations {
|
|
p.extend(figcraft_geom::rounded_rect(d.rect.width(), d.rect.height(), [0.0; 4], 0.0).iter().map(|el| {
|
|
let mut e = el;
|
|
e.apply_affine(Affine::translate((d.rect.x0, d.rect.y0)));
|
|
e
|
|
}));
|
|
}
|
|
p
|
|
}
|
|
}
|
|
|
|
trait ApplyAffine {
|
|
fn apply_affine(&mut self, a: Affine);
|
|
}
|
|
|
|
impl ApplyAffine for figcraft_geom::kurbo::PathEl {
|
|
fn apply_affine(&mut self, a: Affine) {
|
|
use figcraft_geom::kurbo::PathEl::*;
|
|
*self = match *self {
|
|
MoveTo(p) => MoveTo(a * p),
|
|
LineTo(p) => LineTo(a * p),
|
|
QuadTo(p1, p2) => QuadTo(a * p1, a * p2),
|
|
CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3),
|
|
ClosePath => ClosePath,
|
|
};
|
|
}
|
|
}
|