mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
- Inline IME composition: egui Preedit events become a composition held in the text edit
session (`TextEdit.preedit`), shown inline at the caret with an underline and the IME cursor,
reflowing the text; the document only gets the committed text (`text.edit.insert`). Composing
over a selection replaces it; moving the caret or ending the edit drops an uncommitted
composition. The IME candidate window follows the cursor inside the composition.
New command `text.edit.preedit {text, cursor?}`; `text.edit.selection` reports it.
- Variable fonts: faces read their fvar axes (tag, name, range, default, hidden) and named
instances. Named instances are offered as styles and resolve in `find`; a style name with no
instance implies weight/italic/slant on the axes and optical size follows the font size.
Per-run custom axis values (`TextStyle.variations`, serde default, omitted when empty) are applied
in HarfRust shaping (ShaperInstance) and skrifa outlines/advances (cached per coordinates).
PDF export outlines non-default instances instead of embedding the default one.
- Commands/props: `fontVariations` / `variationSettings` props (whole layer or start..end),
`text.setVariations {variations, replace?}` (merges; edited range while editing),
`text.fonts {family, style?, axes: true}`; `node.get` and copy/paste properties carry the axes.
Picking a named style (Bold/Italic toggles, weight steps, fontStyle/fontWeight) drops custom
wght/ital/slnt values and keeps the others.
- Type settings ▸ Variable tab for variable fonts: a slider (default tick, double-click to reset)
and a field per visible axis, "Mixed" across differing styles, live drag as one undo step,
"Reset to style". Long style/family names stay on one line with an ellipsis and a tooltip.
- Tests: variable font shaping/outlines/clamping/instances (skipped without a variable font),
serde backward compatibility, preedit lifecycle and undo, variation commands/props/undo/junk;
junk sweeps extended with variation and preedit params.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
7.5 KiB
Rust
192 lines
7.5 KiB
Rust
//! Text: real, selectable text in embedded (subset) fonts, positioned glyph by glyph from the
|
|
//! `figcraft_text` layout the renderer uses, with outline fallbacks.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::ops::Range;
|
|
use std::sync::Arc;
|
|
|
|
use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeProps};
|
|
use figcraft_geom::{Affine, BezPath, Shape};
|
|
use figcraft_text::fontdb::FontBytes;
|
|
use figcraft_text::{Face, Glyph, TextLayout};
|
|
use krilla::geom::Point;
|
|
use krilla::paint::{Fill, FillRule};
|
|
use krilla::surface::Surface;
|
|
use krilla::text::{Font, GlyphId, KrillaGlyph};
|
|
|
|
use figcraft_color::{GradientKind, Paint, PaintKind};
|
|
|
|
use crate::draw::{Exporter, above, affine_ok, blend, to_xf, vector_paint};
|
|
|
|
/// Glyphs of one face and size on one line, written as one text run.
|
|
struct Run {
|
|
face: u32,
|
|
font: Font,
|
|
size: f64,
|
|
x0: f64,
|
|
y0: f64,
|
|
/// (glyph id, x, y, advance, source text range in `text`)
|
|
glyphs: Vec<(u32, f64, f64, f64, Range<usize>)>,
|
|
text: String,
|
|
/// The last glyph's source chars (glyphs of one cluster share their text).
|
|
last_chars: Option<(usize, usize)>,
|
|
}
|
|
|
|
/// Draw a text node; `xf` maps its local space to the page.
|
|
pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine) {
|
|
let NodeKind::Text(t) = &n.kind else { return };
|
|
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
|
|
let gxf = xf * Affine::translate((0.0, dy));
|
|
if !affine_ok(gxf) {
|
|
return;
|
|
}
|
|
let (w, h) = (n.width, n.height);
|
|
let chars: Vec<char> = t.characters.chars().collect();
|
|
let styles: BTreeSet<usize> = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
|
|
for k in styles {
|
|
let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills);
|
|
let mut decorations = BezPath::new();
|
|
for d in layout.decorations.iter().filter(|d| d.style == k) {
|
|
decorations.extend(d.rect.to_path(0.1));
|
|
}
|
|
let mut outlines = BezPath::new();
|
|
for g in layout.glyphs.iter().filter(|g| g.style == k) {
|
|
outlines.extend(g.path());
|
|
}
|
|
outlines.extend(decorations.iter());
|
|
let mut real_text_written = false;
|
|
for p in fills {
|
|
if !p.visible || !above(p.opacity, 0.0) {
|
|
continue;
|
|
}
|
|
let embeddable = matches!(&p.kind, PaintKind::Solid { .. })
|
|
|| matches!(&p.kind, PaintKind::Gradient(g) if matches!(g.kind, GradientKind::Linear | GradientKind::Radial));
|
|
if embeddable && !real_text_written && !ex.opts.outline_text {
|
|
// The first fill writes the text itself; the rest paint its outlines on top.
|
|
real_text_written = true;
|
|
let mut rest = glyph_runs(ex, s, &layout, k, &chars, p, w, h, gxf);
|
|
rest.extend(decorations.iter());
|
|
ex.fill(s, &rest, FillRule::NonZero, p, w, h, gxf);
|
|
} else {
|
|
ex.fill(s, &outlines, FillRule::NonZero, p, w, h, gxf);
|
|
}
|
|
}
|
|
if n.has_visible_stroke() {
|
|
let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
|
|
let area = figcraft_pathops::stroke_center(&outlines, props.weight, &props);
|
|
for p in &n.strokes {
|
|
ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Exporter<'_> {
|
|
/// The PDF font of a face (cached; `None` when krilla can't read it).
|
|
fn font(&mut self, face: &Arc<Face>) -> Option<Font> {
|
|
if let Some(f) = self.fonts.get(&face.id) {
|
|
return f.clone();
|
|
}
|
|
let data = match &face.bytes {
|
|
FontBytes::Static(b) => krilla::Data::from(*b),
|
|
FontBytes::Shared(v) => krilla::Data::from(v.clone()),
|
|
};
|
|
let font = Font::new(data, face.index);
|
|
if font.is_none() {
|
|
log::warn!("PDF export: the font {} {} can't be embedded; its text is outlined", face.family, face.style);
|
|
}
|
|
self.fonts.insert(face.id, font.clone());
|
|
font
|
|
}
|
|
}
|
|
|
|
/// Write the glyphs of style `style` as real text filled with `p` (layout space, mapped to the
|
|
/// page by `gxf`). Returns the outlines of glyphs that can't be written as text.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn glyph_runs(
|
|
ex: &mut Exporter,
|
|
s: &mut Surface,
|
|
layout: &TextLayout,
|
|
style: usize,
|
|
chars: &[char],
|
|
p: &Paint,
|
|
w: f64,
|
|
h: f64,
|
|
gxf: Affine,
|
|
) -> BezPath {
|
|
let mut rest = BezPath::new();
|
|
for line in &layout.lines {
|
|
let mut run: Option<Run> = None;
|
|
for g in layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.style == style) {
|
|
// Glyphs at a variable font instance other than the default are outlined (an
|
|
// embedded font draws its default instance).
|
|
let has_source = g.char_end > g.char_start && g.char_end <= chars.len() && g.coords.is_empty();
|
|
let font = if has_source && g.size.is_finite() && g.size > 0.0 && g.x.is_finite() && g.y.is_finite() { ex.font(&g.face) } else { None };
|
|
let Some(font) = font else {
|
|
// List markers, ellipses and unembeddable fonts: outlines.
|
|
rest.extend(g.path());
|
|
continue;
|
|
};
|
|
let continues = run.as_ref().is_some_and(|r| r.face == g.face.id && (r.size - g.size).abs() < 1e-9);
|
|
if !continues {
|
|
if let Some(r) = run.take() {
|
|
write_run(s, r, p, w, h, gxf);
|
|
}
|
|
run = Some(Run { face: g.face.id, font, size: g.size, x0: g.x, y0: g.y, glyphs: Vec::new(), text: String::new(), last_chars: None });
|
|
}
|
|
if let Some(r) = run.as_mut() {
|
|
push_glyph(r, g, chars);
|
|
}
|
|
}
|
|
if let Some(r) = run.take() {
|
|
write_run(s, r, p, w, h, gxf);
|
|
}
|
|
}
|
|
rest
|
|
}
|
|
|
|
fn push_glyph(r: &mut Run, g: &Glyph, chars: &[char]) {
|
|
let src = (g.char_start, g.char_end);
|
|
let range = match (r.last_chars, r.glyphs.last()) {
|
|
// Another glyph of the same cluster: share its text.
|
|
(Some(last), Some(prev)) if last == src => prev.4.clone(),
|
|
_ => {
|
|
let start = r.text.len();
|
|
r.text.extend(chars.get(g.char_start..g.char_end).unwrap_or(&[]).iter());
|
|
start..r.text.len()
|
|
}
|
|
};
|
|
r.last_chars = Some(src);
|
|
r.glyphs.push((g.gid, g.x, g.y, g.advance, range));
|
|
}
|
|
|
|
fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine) {
|
|
if r.glyphs.is_empty() {
|
|
return;
|
|
}
|
|
let Some((paint, opacity)) = vector_paint(p, w, h) else { return };
|
|
let size = r.size;
|
|
let glyphs: Vec<KrillaGlyph> = r
|
|
.glyphs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (gid, x, y, advance, range))| {
|
|
// Absolute positions: each advance reaches the next glyph's pen position.
|
|
let next = r.glyphs.get(i + 1).map_or(x + advance, |n| n.1);
|
|
KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, ((r.y0 - y) / size) as f32, 0.0, range.clone(), None)
|
|
})
|
|
.collect();
|
|
let pb = blend(p.blend);
|
|
if let Some(b) = pb {
|
|
s.push_blend_mode(b);
|
|
}
|
|
s.push_transform(&to_xf(gxf));
|
|
s.set_stroke(None);
|
|
s.set_fill(Some(Fill { paint, opacity, rule: FillRule::NonZero }));
|
|
s.draw_glyphs(Point::from_xy(r.x0 as f32, r.y0 as f32), &glyphs, r.font, &r.text, size as f32, false);
|
|
s.pop();
|
|
if pb.is_some() {
|
|
s.pop();
|
|
}
|
|
}
|