M0.4: text (shaping, layout), pathops (booleans, aligned strokes), render (tiny-skia scene renderer)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 12:12:34 -04:00
parent f7a68c864b
commit b367392c4b
16 changed files with 3488 additions and 0 deletions

365
Cargo.lock generated
View File

@ -2,11 +2,26 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
dependencies = [
"serde",
]
[[package]]
name = "autocfg"
@ -14,6 +29,59 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "euclid"
version = "0.22.14"
@ -23,6 +91,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "figcraft-color"
version = "0.1.0"
@ -51,6 +128,111 @@ dependencies = [
"serde",
]
[[package]]
name = "figcraft-pathops"
version = "0.1.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
"linesweeper",
]
[[package]]
name = "figcraft-render"
version = "0.1.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"figcraft-pathops",
"figcraft-text",
"image",
"log",
"tiny-skia",
]
[[package]]
name = "figcraft-text"
version = "0.1.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust",
"log",
"skrifa",
]
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "font-types"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
dependencies = [
"bytemuck",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "harfrust"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6"
dependencies = [
"bitflags",
"bytemuck",
"once_cell",
"read-fonts 0.43.3",
"smallvec",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -70,12 +252,61 @@ dependencies = [
"smallvec",
]
[[package]]
name = "linesweeper"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
dependencies = [
"arrayvec",
"kurbo",
"polycool",
"rustc-hash",
"smallvec",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -85,6 +316,25 @@ dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "polycool"
version = "0.4.0"
@ -103,6 +353,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@ -112,6 +374,34 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "read-fonts"
version = "0.43.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "read-fonts"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "serde"
version = "1.0.229"
@ -155,6 +445,22 @@ dependencies = [
"zmij",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "skrifa"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
dependencies = [
"bytemuck",
"read-fonts 0.44.0",
]
[[package]]
name = "smallvec"
version = "1.16.2"
@ -164,6 +470,12 @@ dependencies = [
"serde",
]
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "syn"
version = "3.0.6"
@ -195,12 +507,44 @@ dependencies = [
"syn",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "xtask"
version = "0.1.0"
@ -208,8 +552,29 @@ dependencies = [
"serde_json",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

14
crates/pathops/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "figcraft-pathops"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft path operations: booleans (union, subtract, intersect, exclude), outline stroke with inside/center/outside alignment, arrowheads"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-doc = { workspace = true }
linesweeper = { workspace = true }

361
crates/pathops/src/lib.rs Normal file
View File

@ -0,0 +1,361 @@
//! FigCraft path operations.
//!
//! - [`boolean`]: Union, Subtract, Intersect and Exclude over any number of closed paths (robust
//! sweep-line topology from `linesweeper`); results use the even-odd rule.
//! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment,
//! dashes, caps, joins and arrowheads.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps};
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
#[derive(Debug, PartialEq)]
pub enum PathOpsError {
/// The inputs contain NaN or infinite coordinates.
BadInput,
}
impl std::fmt::Display for PathOpsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "path contains invalid coordinates")
}
}
/// Close every open subpath (a filled open path paints as if closed).
pub fn closed(path: &BezPath) -> BezPath {
let mut out = BezPath::new();
let mut open = false;
for el in path.elements() {
match el {
PathEl::MoveTo(_) => {
if open {
out.close_path();
}
open = true;
}
PathEl::ClosePath => open = false,
_ => {}
}
out.push(*el);
}
if open {
out.close_path();
}
out
}
/// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input).
pub fn finite(path: &BezPath) -> bool {
let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
path.elements().iter().all(|el| match el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p),
PathEl::QuadTo(a, b) => ok(a) && ok(b),
PathEl::CurveTo(a, b, c) => ok(a) && ok(b) && ok(c),
PathEl::ClosePath => true,
})
}
fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
if a.elements().is_empty() && b.elements().is_empty() {
return Ok(BezPath::new());
}
if !finite(a) || !finite(b) {
return Err(PathOpsError::BadInput);
}
let contours = linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput)?;
let mut out = BezPath::new();
for c in contours.contours() {
out.extend(c.path.elements().iter().copied());
}
Ok(out)
}
/// Normalise a single path's winding (non-zero) into simple even-odd contours.
pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
binary(path, &BezPath::new(), linesweeper::BinaryOp::Union)
}
/// Apply a boolean operation to `paths` in order (bottom to top, like a boolean group's children):
/// Subtract removes every later shape from the first one.
pub fn boolean(paths: &[BezPath], op: BoolOp) -> Result<BezPath, PathOpsError> {
let mut it = paths.iter();
let Some(first) = it.next() else { return Ok(BezPath::new()) };
let mut acc = first.clone();
match op {
BoolOp::Union => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Union)?;
}
}
BoolOp::Intersect => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Intersection)?;
}
}
BoolOp::Exclude => {
for p in it {
acc = binary(&acc, p, linesweeper::BinaryOp::Xor)?;
}
}
BoolOp::Subtract => {
let rest: Vec<BezPath> = it.cloned().collect();
if rest.is_empty() {
return Ok(acc);
}
let mut cut = BezPath::new();
for p in &rest {
cut = binary(&cut, p, linesweeper::BinaryOp::Union)?;
}
acc = binary(&acc, &cut, linesweeper::BinaryOp::Difference)?;
}
}
if paths.len() == 1 {
return simplify(&acc);
}
Ok(acc)
}
fn kcap(c: StrokeCap) -> Cap {
match c {
StrokeCap::Round | StrokeCap::CircleFilled => Cap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => Cap::Square,
_ => Cap::Butt,
}
}
fn kjoin(j: StrokeJoin) -> Join {
match j {
StrokeJoin::Miter => Join::Miter,
StrokeJoin::Bevel => Join::Bevel,
StrokeJoin::Round => Join::Round,
}
}
/// Plain centred stroke of `path` at `width`.
pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath {
if !(width.is_finite() && width > 0.0) || !finite(path) {
return BezPath::new();
}
let mut style = kurbo::Stroke::new(width)
.with_join(kjoin(props.join))
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
.with_start_cap(kcap(props.cap_start))
.with_end_cap(kcap(props.cap_end));
let dashes: Vec<f64> = props.dashes.iter().copied().filter(|d| d.is_finite() && *d >= 0.0).collect();
if !dashes.is_empty() && dashes.iter().sum::<f64>() > 0.01 {
let pat = if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes };
style = style.with_dashes(0.0, pat);
}
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
}
/// The painted stroke area of a shape as a fill path (local coordinates). `closed` says whether the
/// shape is closed (Inside/Outside only apply to closed shapes).
pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) -> BezPath {
let w = props.weight;
if !(w.is_finite() && w > 0.0) {
return BezPath::new();
}
let align = if closed_shape { props.align } else { StrokeAlign::Center };
let mut out = match align {
StrokeAlign::Center => stroke_center(path, w, props),
StrokeAlign::Inside => {
let s = stroke_center(path, w * 2.0, props);
binary(&s, path, linesweeper::BinaryOp::Intersection).unwrap_or(s)
}
StrokeAlign::Outside => {
let s = stroke_center(path, w * 2.0, props);
binary(&s, path, linesweeper::BinaryOp::Difference).unwrap_or(s)
}
};
if !closed_shape {
for (at_end, cap) in [(false, props.cap_start), (true, props.cap_end)] {
if cap.is_arrow()
&& let Some(head) = arrowhead(path, cap, w, at_end)
{
out.extend(head.elements().iter().copied());
}
}
}
out
}
/// End point and outward direction of an open path at its start or end.
fn path_end(path: &BezPath, at_end: bool) -> Option<(Point, Vec2)> {
let segs: Vec<PathSeg> = path.segments().collect();
let seg = if at_end { segs.last()? } else { segs.first()? };
use kurbo::ParamCurve;
use kurbo::ParamCurveDeriv;
let (p, d) = if at_end {
let d = match seg {
PathSeg::Line(l) => l.p1 - l.p0,
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
PathSeg::Cubic(c) => {
let d = c.deriv().eval(1.0).to_vec2();
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
}
};
(seg.end(), d)
} else {
let d = match seg {
PathSeg::Line(l) => l.p0 - l.p1,
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
PathSeg::Cubic(c) => {
let d = -c.deriv().eval(0.0).to_vec2();
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
}
};
(seg.start(), d)
};
let len = d.hypot();
if len < 1e-12 {
return None;
}
Some((p, d / len))
}
/// The filled arrowhead shape for `cap` at one end of an open path.
pub fn arrowhead(path: &BezPath, cap: StrokeCap, weight: f64, at_end: bool) -> Option<BezPath> {
let (tip, dir) = path_end(path, at_end)?;
let n = Vec2::new(-dir.y, dir.x);
let s = (weight * 3.0).max(6.0);
let mut p = BezPath::new();
match cap {
StrokeCap::TriangleArrow => {
p.move_to(tip + dir * (weight * 0.5));
p.line_to(tip - dir * s + n * (s * 0.6));
p.line_to(tip - dir * s - n * (s * 0.6));
p.close_path();
}
StrokeCap::ReversedTriangle => {
p.move_to(tip - dir * s);
p.line_to(tip + n * (s * 0.6));
p.line_to(tip - n * (s * 0.6));
p.close_path();
}
StrokeCap::LineArrow => {
let mut l = BezPath::new();
l.move_to(tip - dir * s + n * (s * 0.7));
l.line_to(tip);
l.line_to(tip - dir * s - n * (s * 0.7));
let props = StrokeProps { weight, cap_start: StrokeCap::Round, cap_end: StrokeCap::Round, join: StrokeJoin::Round, ..StrokeProps::default() };
return Some(stroke_center(&l, weight, &props));
}
StrokeCap::CircleFilled => {
p = kurbo::Circle::new(tip, s * 0.4).to_path(0.05);
}
StrokeCap::DiamondFilled => {
let r = s * 0.5;
p.move_to(tip + dir * r);
p.line_to(tip + n * r);
p.line_to(tip - dir * r);
p.line_to(tip - n * r);
p.close_path();
}
_ => return None,
}
Some(p)
}
/// Per-side stroke of a rectangle `[top, right, bottom, left]`, painted inside/center/outside.
pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath {
let [t, r, b, l] = sides.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
let f = match align {
StrokeAlign::Inside => 0.0,
StrokeAlign::Center => 0.5,
StrokeAlign::Outside => 1.0,
};
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| figcraft_geom::Rect::new(x0, y0, x1, y1).to_path(0.1);
let mut p = BezPath::new();
// Outer box edges.
let (ox0, oy0, ox1, oy1) = (-l * f, -t * f, w + r * f, h + b * f);
if t > 0.0 {
p.extend(rect(ox0, oy0, ox1, oy0 + t).elements().iter().copied());
}
if b > 0.0 {
p.extend(rect(ox0, oy1 - b, ox1, oy1).elements().iter().copied());
}
if l > 0.0 {
p.extend(rect(ox0, oy0 + t, ox0 + l, oy1 - b).elements().iter().copied());
}
if r > 0.0 {
p.extend(rect(ox1 - r, oy0 + t, ox1, oy1 - b).elements().iter().copied());
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use figcraft_geom::Rect;
fn sq(x: f64, y: f64, s: f64) -> BezPath {
Rect::new(x, y, x + s, y + s).to_path(0.1)
}
fn area_eo(p: &BezPath) -> f64 {
// Sum of absolute contour areas with even-odd nesting approximated by signed area sum.
p.area().abs()
}
#[test]
fn union_intersect_subtract_exclude() {
let a = sq(0.0, 0.0, 10.0);
let b = sq(5.0, 5.0, 10.0);
let u = boolean(&[a.clone(), b.clone()], BoolOp::Union).unwrap();
assert!((area_eo(&u) - 175.0).abs() < 0.5, "{}", area_eo(&u));
let i = boolean(&[a.clone(), b.clone()], BoolOp::Intersect).unwrap();
assert!((area_eo(&i) - 25.0).abs() < 0.5);
let s = boolean(&[a.clone(), b.clone()], BoolOp::Subtract).unwrap();
assert!((area_eo(&s) - 75.0).abs() < 0.5);
let x = boolean(&[a, b], BoolOp::Exclude).unwrap();
let bb = x.bounding_box();
assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 15.0, 15.0));
}
#[test]
fn hostile_inputs() {
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((1.0, 1.0));
assert!(boolean(&[bad, sq(0.0, 0.0, 1.0)], BoolOp::Union).is_err());
assert!(boolean(&[], BoolOp::Union).unwrap().elements().is_empty());
let _ = stroke_outline(&sq(0.0, 0.0, 10.0), &StrokeProps { weight: f64::INFINITY, ..Default::default() }, true);
}
#[test]
fn stroke_alignment_bounds() {
let r = sq(0.0, 0.0, 100.0);
let props = StrokeProps { weight: 10.0, align: StrokeAlign::Inside, ..Default::default() };
let inside = stroke_outline(&r, &props, true);
let b = inside.bounding_box();
assert!(b.x0 >= -0.01 && b.x1 <= 100.01, "{b:?}");
let outside = stroke_outline(&r, &StrokeProps { align: StrokeAlign::Outside, ..props.clone() }, true);
let b = outside.bounding_box();
assert!((b.x0 + 10.0).abs() < 0.01 && (b.x1 - 110.0).abs() < 0.01, "{b:?}");
let center = stroke_outline(&r, &StrokeProps { align: StrokeAlign::Center, ..props }, true);
let b = center.bounding_box();
assert!((b.x0 + 5.0).abs() < 0.01);
}
#[test]
fn arrows_on_open_paths() {
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((100.0, 0.0));
let props = StrokeProps { weight: 2.0, cap_end: StrokeCap::TriangleArrow, ..Default::default() };
let s = stroke_outline(&line, &props, false);
assert!(s.bounding_box().height() > 4.0);
assert!(arrowhead(&BezPath::new(), StrokeCap::LineArrow, 1.0, true).is_none());
}
#[test]
fn dashes() {
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((100.0, 0.0));
let props = StrokeProps { weight: 2.0, dashes: vec![10.0, 10.0], align: StrokeAlign::Center, ..Default::default() };
let s = stroke_center(&line, 2.0, &props);
assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area());
}
}

19
crates/render/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "figcraft-render"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft renderer: the scene graph to pixels on tiny-skia (fills, gradients, images, aligned strokes, shadows, blurs, masks, blend modes, text)"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
tiny-skia = { workspace = true }
image = { workspace = true }
log = { workspace = true }

583
crates/render/src/draw.rs Normal file
View File

@ -0,0 +1,583 @@
//! Recursive scene drawing.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::{BlendMode, Paint};
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use crate::fx;
use crate::paint::{prepare, ts_blend};
/// Shared state for one render.
pub struct Ctx<'a> {
pub doc: &'a Document,
/// Device pixels per document unit (for effect radii).
pub scale: f64,
pub outline_mode: bool,
/// Nodes not to draw (e.g. text being edited draws through the editor).
pub skip: &'a [NodeId],
}
pub fn ts_transform(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
/// kurbo path → tiny-skia path (None when empty or degenerate).
pub fn ts_path(p: &BezPath) -> Option<tiny_skia::Path> {
use figcraft_geom::kurbo::PathEl;
let mut b = tiny_skia::PathBuilder::new();
for el in p.elements() {
match *el {
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(c, p) => b.quad_to(c.x as f32, c.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(c1, c2, p) => b.cubic_to(c1.x as f32, c1.y as f32, c2.x as f32, c2.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => b.close(),
}
}
b.finish()
}
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, doc: &Document, mask: Option<&Mask>) {
for p in paints {
let Some(prep) = prepare(p, w, h, doc) else { continue };
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
pix.fill_path(path, &paint, rule, ts_transform(xf), mask);
}
}
/// Stroke outline cache: (geometry hash) → outline path.
fn stroke_cache() -> &'static Mutex<HashMap<u64, Arc<BezPath>>> {
static C: OnceLock<Mutex<HashMap<u64, Arc<BezPath>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn hash_path(p: &BezPath, h: &mut impl Hasher) {
for el in p.elements() {
use figcraft_geom::kurbo::PathEl::*;
match *el {
MoveTo(a) | LineTo(a) => (0u8, a.x.to_bits(), a.y.to_bits()).hash(h),
QuadTo(a, b) => (1u8, a.x.to_bits(), a.y.to_bits(), b.x.to_bits(), b.y.to_bits()).hash(h),
CurveTo(a, b, c) => (2u8, a.x.to_bits(), a.y.to_bits(), b.x.to_bits(), b.y.to_bits(), c.x.to_bits(), c.y.to_bits()).hash(h),
ClosePath => 3u8.hash(h),
}
}
}
/// The stroke area of a node in local coordinates.
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
let props = &n.stroke;
// Per-side strokes on rectangles/frames.
if let Some(sides) = props.sides
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
&& n.corners.uniform() == Some(0.0)
{
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
}
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
if props.dashes.is_empty() && closed && props.align != StrokeAlign::Center {
let w = props.weight;
let ring = |inset: f64| -> Option<BezPath> {
let (x0, y0, ww, hh) = (inset, inset, n.width - 2.0 * inset, n.height - 2.0 * inset);
if ww <= 0.0 || hh <= 0.0 {
return None;
}
match n.kind {
NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. }
if n.corners.smoothing == 0.0 =>
{
let radii = n.corners.radii.map(|r| if r > 0.0 { (r - inset).max(0.0) } else { 0.0 });
let mut p = figcraft_geom::rounded_rect(ww, hh, radii, 0.0);
p.apply_affine(Affine::translate((x0, y0)));
Some(p)
}
NodeKind::Ellipse { sweep, inner, .. } if sweep.abs() >= std::f64::consts::TAU - 1e-9 && inner == 0.0 => {
Some(figcraft_geom::kurbo::Ellipse::from_rect(Rect::new(x0, y0, x0 + ww, y0 + hh)).to_path(0.05))
}
_ => None,
}
};
let (outer, inner) = match props.align {
StrokeAlign::Inside => (ring(0.0), ring(w)),
_ => (ring(-w), ring(0.0)),
};
if let Some(mut o) = outer {
if let Some(i) = inner {
o.extend(i.elements().iter().copied());
}
return Arc::new(o);
}
}
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_path(path, &mut h);
(props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed).hash(&mut h);
for d in &props.dashes {
d.to_bits().hash(&mut h);
}
let key = h.finish();
if let Some(p) = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let out = Arc::new(figcraft_pathops::stroke_outline(path, props, closed));
let mut c = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 4096 {
c.clear();
}
c.insert(key, out.clone());
out
}
/// Even-odd ring paths need the even-odd rule; everything else non-zero.
fn stroke_rule(n: &Node, closed: bool) -> FillRule {
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) { FillRule::EvenOdd } else { FillRule::Winding }
}
/// The geometry a node fills, in local space: shapes, booleans (computed from children) and text
/// (glyph outlines). Groups and pages have none.
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
match &n.kind {
NodeKind::BooleanOp { op } => {
let paths: Vec<BezPath> = n
.children
.iter()
.filter_map(|c| doc.node(*c))
.filter(|c| c.visible)
.filter_map(|c| {
let (mut p, _) = subtree_outline(doc, c, 0)?;
p.apply_affine(c.transform);
Some(p)
})
.collect();
let p = boolean_cached(&paths, *op);
Some((p, FillRule::EvenOdd))
}
NodeKind::Text(_) => {
let (l, dy) = figcraft_text::layout_node(n)?;
let mut p = l.to_path();
p.apply_affine(Affine::translate((0.0, dy)));
Some((p, FillRule::Winding))
}
NodeKind::Vector { network } => {
let rule = if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
Some((network.fill_path(), rule))
}
_ => figcraft_doc::geometry::shape_path(n).map(|p| (p, FillRule::Winding)),
}
}
/// The union outline of a node and its descendants (for booleans of groups/frames).
pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
if depth > 64 {
return None;
}
match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } if !n.children.is_empty() => {
let mut out = BezPath::new();
if n.kind.is_frame_like()
&& let Some(p) = figcraft_doc::geometry::shape_path(n)
&& n.fills.iter().any(|f| f.visible)
{
out.extend(p.elements().iter().copied());
}
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
if !c.visible {
continue;
}
if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) {
p.apply_affine(c.transform);
out.extend(p.elements().iter().copied());
}
}
Some((out, FillRule::Winding))
}
_ => node_outline(doc, n),
}
}
fn boolean_cache() -> &'static Mutex<HashMap<u64, Arc<BezPath>>> {
static C: OnceLock<Mutex<HashMap<u64, Arc<BezPath>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn boolean_cached(paths: &[BezPath], op: figcraft_doc::BoolOp) -> BezPath {
let mut h = std::collections::hash_map::DefaultHasher::new();
(op as u8).hash(&mut h);
for p in paths {
hash_path(p, &mut h);
9u8.hash(&mut h);
}
let key = h.finish();
if let Some(p) = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return (**p).clone();
}
let out = figcraft_pathops::boolean(paths, op).unwrap_or_default();
let mut c = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 2048 {
c.clear();
}
c.insert(key, Arc::new(out.clone()));
out
}
fn device_rect(xf: Affine, r: Rect) -> Rect {
figcraft_geom::transform_rect_bbox(xf, r)
}
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect {
let reach = n.paint_reach();
let mut r = n.local_rect().inflate(reach, reach);
let clips = n.clip && n.kind.is_frame_like();
if (!clips || n.kind.bounds_follow_children()) && depth < 64 {
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
if !c.visible {
continue;
}
let cb = local_paint_bounds(doc, c, depth + 1);
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
}
}
// Text can overflow its box.
if let NodeKind::Text(_) = n.kind {
r = r.inflate(n.height.max(10.0), n.height.max(10.0) * 0.5);
}
r
}
fn needs_layer(n: &Node) -> bool {
n.opacity < 0.999 || !matches!(n.blend, BlendMode::Normal | BlendMode::PassThrough) || n.effects.iter().any(|e| e.visible)
}
fn int_rect(r: Rect, limit: (u32, u32)) -> Option<IntRect> {
let x0 = r.x0.floor().max(0.0);
let y0 = r.y0.floor().max(0.0);
let x1 = r.x1.ceil().min(f64::from(limit.0));
let y1 = r.y1.ceil().min(f64::from(limit.1));
if !(x1 > x0 && y1 > y0) {
return None;
}
IntRect::from_xywh(x0 as i32, y0 as i32, (x1 - x0) as u32, (y1 - y0) as u32)
}
/// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels.
pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) {
if depth > 256 || ctx.skip.contains(&id) {
return;
}
let Some(n) = ctx.doc.node(id) else { return };
if !n.visible || n.opacity <= 0.0 || n.is_mask {
return;
}
let xf = parent_xf * n.transform;
let (pw, ph) = (pix.width(), pix.height());
let local = local_paint_bounds(ctx.doc, n, 0);
let dev = device_rect(xf, local);
// Effects that read outside (blur) need a margin when culling.
let blur_margin = n
.effects
.iter()
.filter(|e| e.visible)
.map(|e| match &e.kind {
EffectKind::BackgroundBlur { radius } | EffectKind::LayerBlur { radius } => *radius * 2.0 * ctx.scale,
EffectKind::DropShadow(s) => (s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs()) * ctx.scale,
_ => 0.0,
})
.fold(0.0, f64::max);
let view = Rect::new(0.0, 0.0, f64::from(pw), f64::from(ph)).inflate(blur_margin, blur_margin);
if dev.intersect(view).is_zero_area() && !(dev.width() == 0.0 || dev.height() == 0.0) {
return;
}
if ctx.outline_mode {
draw_outline(ctx, pix, n, xf, depth);
return;
}
if !needs_layer(n) {
draw_content(ctx, pix, n, xf, depth);
return;
}
// Isolated layer covering the node's paint bounds (clipped to the visible area + margin).
let Some(ir) = int_rect(dev.intersect(view), (pw.saturating_add(blur_margin as u32 * 2), ph.saturating_add(blur_margin as u32 * 2))) else {
return;
};
// Allow negative origins: recompute without the 0-clamp for margins.
let lx = (dev.intersect(view).x0.floor()) as i32;
let ly = (dev.intersect(view).y0.floor()) as i32;
let lw = ir.width().min(16_384);
let lh = ir.height().min(16_384);
let Some(mut layer) = Pixmap::new(lw.max(1), lh.max(1)) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
let shape = node_outline(ctx.doc, n);
// Background blur: blurred backdrop clipped to the shape, under the content.
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::BackgroundBlur { radius } = e.kind
&& let Some((path, rule)) = &shape
&& let Some(tp) = ts_path(path)
{
let mut back = Pixmap::new(lw.max(1), lh.max(1));
if let Some(back) = back.as_mut() {
back.draw_pixmap(-lx, -ly, pix.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
fx::blur(back, radius / 2.0 * ctx.scale);
if let Some(mut m) = Mask::new(lw.max(1), lh.max(1)) {
m.fill_path(&tp, *rule, true, ts_transform(lxf));
layer.draw_pixmap(0, 0, back.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&m));
}
}
}
}
draw_content(ctx, &mut layer, n, lxf, depth);
// Shape alpha (for inner shadows / noise).
let shape_alpha = || -> Option<Pixmap> {
let (path, rule) = shape.as_ref()?;
let tp = ts_path(path)?;
let mut s = Pixmap::new(lw.max(1), lh.max(1))?;
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), anti_alias: true, ..Default::default() };
s.fill_path(&tp, &paint, *rule, ts_transform(lxf), None);
Some(s)
};
let mut inner_shadows: Vec<Pixmap> = Vec::new();
for e in n.effects.iter().filter(|e| e.visible) {
match &e.kind {
EffectKind::InnerShadow(s) => {
let Some(sa) = shape_alpha() else { continue };
let mut sh = sa.clone();
fx::invert_alpha(&mut sh);
let mut shifted = Pixmap::new(lw.max(1), lh.max(1));
let Some(shifted) = shifted.as_mut() else { continue };
// Outside of the layer counts as "outside the shape" too.
shifted.fill(tiny_skia::Color::WHITE);
let (dx, dy) = (s.offset.0 * ctx.scale, s.offset.1 * ctx.scale);
let rect = tiny_skia::Rect::from_xywh(dx as f32, dy as f32, lw as f32, lh as f32);
if let Some(rect) = rect {
let clear = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::TRANSPARENT), blend_mode: tiny_skia::BlendMode::Source, ..Default::default() };
shifted.fill_rect(rect, &clear, Transform::identity(), None);
}
shifted.draw_pixmap(dx.round() as i32, dy.round() as i32, sh.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
fx::morph(shifted, (s.spread * ctx.scale).round() as i32);
fx::blur(shifted, s.radius / 2.0 * ctx.scale);
fx::mask_by(shifted, &sa, false);
fx::tint(shifted, [s.color.r, s.color.g, s.color.b, s.color.a]);
inner_shadows.push(shifted.clone());
}
EffectKind::Noise { size, density, color } => {
if let Some(sa) = shape_alpha() {
fx::noise(&mut layer, &sa, *density, [color.r, color.g, color.b, color.a], id.0 as u32, size * ctx.scale * 2.0);
}
}
_ => {}
}
}
for s in &inner_shadows {
layer.draw_pixmap(0, 0, s.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::LayerBlur { radius } = e.kind {
fx::blur(&mut layer, radius / 2.0 * ctx.scale);
}
}
// Drop shadows go under the layer, in effect-list order.
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::DropShadow(s) = &e.kind {
let mut sh = layer.clone();
fx::morph(&mut sh, (s.spread * ctx.scale).round() as i32);
fx::blur(&mut sh, s.radius / 2.0 * ctx.scale);
fx::tint(&mut sh, [s.color.r, s.color.g, s.color.b, s.color.a]);
let (dx, dy) = ((s.offset.0 * ctx.scale).round() as i32, (s.offset.1 * ctx.scale).round() as i32);
let mut knock: Option<Mask> = None;
if !s.show_behind {
// Hide the shadow behind the layer's own pixels.
if let Some(m) = Mask::from_pixmap(layer.as_ref(), MaskType::Alpha).into() {
let mut inv = m;
inv.invert();
knock = Some(inv);
}
}
let op = n.opacity.clamp(0.0, 1.0);
let paint = PixmapPaint { opacity: op, blend_mode: ts_blend(s.blend), ..Default::default() };
if let Some(k) = &knock {
// Mask is in layer space: shift the shadow into layer space first.
let mut shifted = Pixmap::new(lw.max(1), lh.max(1));
if let Some(shifted) = shifted.as_mut() {
shifted.draw_pixmap(dx, dy, sh.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
shifted.apply_mask(k);
pix.draw_pixmap(lx, ly, shifted.as_ref(), &paint, Transform::identity(), None);
}
} else {
pix.draw_pixmap(lx + dx, ly + dy, sh.as_ref(), &paint, Transform::identity(), None);
}
}
}
let paint = PixmapPaint { opacity: n.opacity.clamp(0.0, 1.0), blend_mode: ts_blend(n.blend), ..Default::default() };
pix.draw_pixmap(lx, ly, layer.as_ref(), &paint, Transform::identity(), None);
}
fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
if let Some((path, _)) = node_outline(ctx.doc, n)
&& !matches!(n.kind, NodeKind::Group)
&& let Some(tp) = ts_path(&path)
{
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::BLACK), anti_alias: true, ..Default::default() };
let stroke = tiny_skia::Stroke { width: 1.0 / ctx.scale.max(1e-6) as f32, ..Default::default() };
pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None);
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
for c in &n.children {
draw_node(ctx, pix, *c, xf, depth + 1);
}
}
}
/// Fills, children (clipped), strokes and text of a node, without opacity/effects.
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
let doc = ctx.doc;
let (w, h) = (n.width, n.height);
match &n.kind {
NodeKind::Page { .. } => {
draw_children(ctx, pix, n, xf, depth);
}
NodeKind::Group => draw_children(ctx, pix, n, xf, depth),
NodeKind::Text(t) => {
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
let gxf = xf * Affine::translate((0.0, dy));
// Group glyphs by style so runs with their own fills paint with them.
let mut by_style: HashMap<usize, BezPath> = HashMap::new();
for g in &layout.glyphs {
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
}
for d in &layout.decorations {
let mut r = d.rect.to_path(0.1);
r.apply_affine(Affine::IDENTITY);
by_style.entry(d.style).or_default().extend(r.elements().iter().copied());
}
let mut keys: Vec<usize> = by_style.keys().copied().collect();
keys.sort_unstable();
for k in keys {
let Some(path) = by_style.get(&k) else { continue };
let Some(tp) = ts_path(path) else { continue };
let fills = layout.styles.get(k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, doc, None);
if n.has_visible_stroke() {
let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
let s = figcraft_pathops::stroke_center(path, props.weight, &props);
if let Some(ts) = ts_path(&s) {
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, doc, None);
}
}
}
let _ = t;
}
_ => {
let outline = node_outline(doc, n);
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
if let Some((path, rule)) = &outline
&& closed
&& let Some(tp) = ts_path(path)
{
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None);
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
let clips = n.clip && n.kind.is_frame_like();
if clips {
draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p));
} else {
draw_children(ctx, pix, n, xf, depth);
}
}
if n.has_visible_stroke() {
let geom = match &n.kind {
NodeKind::BooleanOp { .. } => outline.as_ref().map(|(p, _)| p.clone()),
_ => figcraft_doc::geometry::stroke_geometry(n),
};
if let Some(g) = geom {
let area = stroke_area(n, &g, closed);
if let Some(tp) = ts_path(&area) {
fill_with(pix, &tp, &n.strokes, w, h, xf, stroke_rule(n, closed), doc, None);
}
}
}
}
}
}
fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
let kids = &n.children;
let mut i = 0;
while i < kids.len() {
let Some(&c) = kids.get(i) else { break };
let is_mask = ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible);
if is_mask {
// Everything above the mask (until the next mask) is clipped by its alpha.
let mut j = i + 1;
while kids.get(j).is_some_and(|k| !ctx.doc.node(*k).is_some_and(|kn| kn.is_mask)) {
j += 1;
}
draw_masked(ctx, pix, c, kids.get(i + 1..j).unwrap_or(&[]), xf, depth);
i = j;
continue;
}
draw_node(ctx, pix, c, xf, depth + 1);
i += 1;
}
}
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
let (w, h) = (pix.width(), pix.height());
let Some(mut mask_px) = Pixmap::new(w, h) else { return };
// Draw the mask node as if it weren't a mask.
if let Some(m) = ctx.doc.node(mask_id) {
let mut m2 = m.clone();
m2.is_mask = false;
let mxf = xf * m2.transform;
if needs_layer(&m2) {
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
let mut tmp = Pixmap::new(w, h);
if let Some(tmp) = tmp.as_mut() {
draw_content(ctx, tmp, &m2, mxf, depth + 1);
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
}
} else {
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1);
}
}
let Some(mut layer) = Pixmap::new(w, h) else { return };
for c in content {
draw_node(ctx, &mut layer, *c, xf, depth + 1);
}
let mask = Mask::from_pixmap(mask_px.as_ref(), MaskType::Alpha);
pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
}
fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, clip: Option<&BezPath>) {
let (pw, ph) = (pix.width(), pix.height());
let dev = device_rect(xf, n.local_rect());
let Some(ir) = int_rect(dev, (pw, ph)) else { return };
let (lx, ly) = (ir.x(), ir.y());
let Some(mut layer) = Pixmap::new(ir.width(), ir.height()) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
draw_children(ctx, &mut layer, n, lxf, depth);
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
if rect_clip {
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
return;
}
if let Some(cp) = clip.and_then(ts_path)
&& let Some(mut m) = Mask::new(ir.width(), ir.height())
{
m.fill_path(&cp, FillRule::Winding, true, ts_transform(lxf));
layer.apply_mask(&m);
}
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
fn is_axis_aligned(xf: Affine) -> bool {
let c = xf.as_coeffs();
c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5])
}
fn fract_ok(_v: f64) -> bool {
true
}

247
crates/render/src/fx.rs Normal file
View File

@ -0,0 +1,247 @@
//! Raster effects on premultiplied RGBA8 pixmaps: blur (3× box ≈ Gaussian), dilate/erode (spread),
//! alpha extraction and tinting.
use tiny_skia::Pixmap;
/// Box radii for three passes approximating a Gaussian of `sigma` (Kovesi).
fn box_sizes(sigma: f64) -> [usize; 3] {
let n = 3.0;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
let r = |i: i64| -> usize {
let w = if i < m { wl } else { wu };
((w.max(1) - 1) / 2) as usize
};
[r(0), r(1), r(2)]
}
fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
if r == 0 {
dst.copy_from_slice(src);
return;
}
let div = (2 * r + 1) as u32;
for y in 0..h {
let row = y * w * 4;
let mut acc = [0u32; 4];
// Edge: transparent outside.
for x in 0..=r.min(w.saturating_sub(1)) {
for c in 0..4 {
acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0));
}
}
for x in 0..w {
for c in 0..4 {
if let Some(d) = dst.get_mut(row + x * 4 + c) {
*d = (acc[c] / div) as u8;
}
}
let add = x + r + 1;
if add < w {
for c in 0..4 {
acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0));
}
}
if x >= r {
let sub = x - r;
for c in 0..4 {
acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0)));
}
}
}
}
}
fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
for y in 0..h {
for x in 0..w {
let s = (y * w + x) * 4;
let d = (x * h + y) * 4;
if let (Some(sp), Some(dp)) = (src.get(s..s + 4), dst.get_mut(d..d + 4)) {
dp.copy_from_slice(sp);
}
}
}
}
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
pub fn blur(p: &mut Pixmap, sigma: f64) {
if !(sigma.is_finite() && sigma > 0.3) {
return;
}
let sigma = sigma.min(500.0);
let (w, h) = (p.width() as usize, p.height() as usize);
let radii = box_sizes(sigma);
let mut a = p.data().to_vec();
let mut b = vec![0u8; a.len()];
for r in radii {
box_blur_h(&a, &mut b, w, h, r);
std::mem::swap(&mut a, &mut b);
}
transpose(&a, &mut b, w, h);
std::mem::swap(&mut a, &mut b);
for r in radii {
box_blur_h(&a, &mut b, h, w, r);
std::mem::swap(&mut a, &mut b);
}
transpose(&a, &mut b, h, w);
p.data_mut().copy_from_slice(&b);
}
/// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring
/// element, separable max/min). Colour channels follow alpha (tinted afterwards anyway).
pub fn morph(p: &mut Pixmap, r: i32) {
if r == 0 {
return;
}
let grow = r > 0;
let r = r.unsigned_abs().min(400) as usize;
let (w, h) = (p.width() as usize, p.height() as usize);
let alpha: Vec<u8> = p.data().chunks_exact(4).map(|c| c.get(3).copied().unwrap_or(0)).collect();
let pass = |src: &[u8], w: usize, h: usize, horizontal: bool| -> Vec<u8> {
let mut out = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (x.saturating_sub(r), (x + r).min(w.saturating_sub(1)));
for k in lo..=hi {
let idx = if horizontal { y * w + k } else { k * h + y };
let s = src.get(idx).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
// Shrinking treats outside as empty.
if !grow && (x < r || x + r >= w) {
v = 0;
}
let o = if horizontal { y * w + x } else { x * h + y };
if let Some(d) = out.get_mut(o) {
*d = v;
}
}
}
out
};
let a1 = pass(&alpha, w, h, true);
// Vertical pass: treat columns as rows by swapping roles.
let mut a2 = vec![0u8; a1.len()];
for x in 0..w {
for y in 0..h {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (y.saturating_sub(r), (y + r).min(h.saturating_sub(1)));
for k in lo..=hi {
let s = a1.get(k * w + x).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
if !grow && (y < r || y + r >= h) {
v = 0;
}
if let Some(d) = a2.get_mut(y * w + x) {
*d = v;
}
}
}
for (px, a) in p.data_mut().chunks_exact_mut(4).zip(a2) {
px.copy_from_slice(&[a, a, a, a]);
}
}
/// Replace every pixel with `color` (non-premultiplied RGBA 0..1) at the pixel's alpha × color alpha.
pub fn tint(p: &mut Pixmap, color: [f32; 4]) {
let [r, g, b, a] = color;
for px in p.data_mut().chunks_exact_mut(4) {
let alpha = f32::from(px.get(3).copied().unwrap_or(0)) / 255.0 * a;
let q = |c: f32| (c * alpha * 255.0).round().clamp(0.0, 255.0) as u8;
px.copy_from_slice(&[q(r), q(g), q(b), (alpha * 255.0).round().clamp(0.0, 255.0) as u8]);
}
}
/// Invert the alpha (premultiplied white with alpha 1-a): used for inner shadows.
pub fn invert_alpha(p: &mut Pixmap) {
for px in p.data_mut().chunks_exact_mut(4) {
let a = 255 - px.get(3).copied().unwrap_or(0);
px.copy_from_slice(&[a, a, a, a]);
}
}
/// Multiply every pixel of `p` by the alpha of `m` (same size); `invert` uses 1-alpha.
pub fn mask_by(p: &mut Pixmap, m: &Pixmap, invert: bool) {
for (px, mp) in p.data_mut().chunks_exact_mut(4).zip(m.data().chunks_exact(4)) {
let ma = u32::from(mp.get(3).copied().unwrap_or(0));
let ma = if invert { 255 - ma } else { ma };
for c in px.iter_mut() {
*c = ((u32::from(*c) * ma + 127) / 255) as u8;
}
}
}
/// Deterministic per-pixel noise (hash), for the Noise effect.
pub fn noise(p: &mut Pixmap, shape: &Pixmap, density: f64, color: [f32; 4], seed: u32, scale: f64) {
let w = p.width() as usize;
let cell = scale.max(0.5);
let density = density.clamp(0.0, 1.0);
for (i, (px, sp)) in p.data_mut().chunks_exact_mut(4).zip(shape.data().chunks_exact(4)).enumerate() {
let sa = f32::from(sp.get(3).copied().unwrap_or(0)) / 255.0;
if sa <= 0.0 {
continue;
}
let (x, y) = ((i % w) as f64 / cell, (i / w.max(1)) as f64 / cell);
let mut hsh = (x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663) ^ seed.wrapping_mul(83_492_791);
hsh ^= hsh >> 13;
hsh = hsh.wrapping_mul(0x5bd1_e995);
hsh ^= hsh >> 15;
let v = f64::from(hsh & 0xffff) / 65535.0;
if v < density {
let a = color[3] * sa;
let src = [color[0] * a, color[1] * a, color[2] * a, a];
for (c, s) in px.iter_mut().zip(src) {
let d = f32::from(*c) / 255.0;
*c = ((s + d * (1.0 - a)) * 255.0).round().clamp(0.0, 255.0) as u8;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blur_spreads_and_conserves() {
let mut p = Pixmap::new(64, 64).unwrap();
let i = (32 * 64 + 32) * 4;
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
let before: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
blur(&mut p, 3.0);
let after: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
assert!(p.data()[i + 3] < 255);
// Integer rounding loses a little.
assert!(after <= before && after > 0, "{before} {after}");
}
#[test]
fn morph_grows() {
let mut p = Pixmap::new(20, 20).unwrap();
let i = (10 * 20 + 10) * 4;
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
morph(&mut p, 2);
let count = p.data().chunks_exact(4).filter(|c| c[3] == 255).count();
assert_eq!(count, 25);
morph(&mut p, -2);
let count = p.data().chunks_exact(4).filter(|c| c[3] == 255).count();
assert_eq!(count, 1);
}
#[test]
fn box_sizes_sane() {
for s in [0.5, 1.0, 4.0, 50.0] {
let b = box_sizes(s);
assert!(b.iter().all(|r| *r < 1000));
}
}
}

106
crates/render/src/images.rs Normal file
View File

@ -0,0 +1,106 @@
//! Decoded image cache (content hash + filters → premultiplied pixmap).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::ImageFilters;
use figcraft_doc::ImageData;
use tiny_skia::Pixmap;
type Key = (String, [i32; 7]);
fn cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
fn fkey(f: &ImageFilters) -> [i32; 7] {
let q = |v: f32| (v * 1000.0).round() as i32;
[q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)]
}
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side).
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 || w > 16_384 || h > 16_384 {
return None;
}
let mut p = Pixmap::new(w, h)?;
for (dst, src) in p.data_mut().chunks_exact_mut(4).zip(rgba.as_raw().chunks_exact(4)) {
let a = u32::from(src.get(3).copied().unwrap_or(255));
let pm = |c: u8| ((u32::from(c) * a + 127) / 255) as u8;
dst.copy_from_slice(&[pm(src.first().copied().unwrap_or(0)), pm(src.get(1).copied().unwrap_or(0)), pm(src.get(2).copied().unwrap_or(0)), a as u8]);
}
Some(p)
}
/// The decoded (and filtered) image for `hash`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters));
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
}
let data = data?;
let mut p = decode(&data.bytes)?;
if !filters.is_identity() {
apply_filters(&mut p, filters);
}
let p = Arc::new(p);
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 256 {
c.clear();
}
c.insert(key, p.clone());
Some(p)
}
/// Image adjustments (approximations of exposure, contrast, saturation, temperature, tint,
/// highlights and shadows) on a premultiplied pixmap.
pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) {
let exposure = 2f32.powf(f.exposure.clamp(-1.0, 1.0) * 2.0);
let contrast = 1.0 + f.contrast.clamp(-1.0, 1.0);
let sat = 1.0 + f.saturation.clamp(-1.0, 1.0);
let temp = f.temperature.clamp(-1.0, 1.0) * 0.15;
let tint = f.tint.clamp(-1.0, 1.0) * 0.15;
let hi = f.highlights.clamp(-1.0, 1.0) * 0.3;
let sh = f.shadows.clamp(-1.0, 1.0) * 0.3;
for px in p.data_mut().chunks_exact_mut(4) {
let a = f32::from(px.get(3).copied().unwrap_or(0)) / 255.0;
if a <= 0.0 {
continue;
}
let un = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0 / a;
let (mut r, mut g, mut b) = (un(0), un(1), un(2));
r *= exposure;
g *= exposure;
b *= exposure;
r = (r - 0.5) * contrast + 0.5;
g = (g - 0.5) * contrast + 0.5;
b = (b - 0.5) * contrast + 0.5;
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
r = l + (r - l) * sat;
g = l + (g - l) * sat;
b = l + (b - l) * sat;
r += temp;
b -= temp;
g += tint;
let l = (0.2126 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 1.0);
let adj = hi * l * l + sh * (1.0 - l) * (1.0 - l);
r += adj;
g += adj;
b += adj;
let q = |c: f32| (c.clamp(0.0, 1.0) * a * 255.0).round() as u8;
let (qr, qg, qb) = (q(r), q(g), q(b));
if let Some(s) = px.get_mut(0..3) {
s.copy_from_slice(&[qr, qg, qb]);
}
}
}
/// Read an image's pixel size without keeping the decode.
pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
reader.into_dimensions().ok()
}

163
crates/render/src/lib.rs Normal file
View File

@ -0,0 +1,163 @@
//! The FigCraft renderer.
//!
//! [`render_view`] draws a page region at any zoom into a premultiplied RGBA pixmap (the canvas);
//! [`render_node`] rasterises one node for export at a scale. Everything is CPU (tiny-skia) so it
//! runs identically on desktop, in tests and in the browser.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod draw;
pub mod fx;
pub mod images;
pub mod paint;
pub use draw::{node_outline, subtree_outline, ts_path};
pub use tiny_skia::Pixmap;
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::{Affine, Rect};
/// Which part of a page to draw, and how big.
#[derive(Clone, Debug)]
pub struct View {
/// Page coordinate shown at the pixmap's top-left.
pub origin: (f64, f64),
/// Device pixels per page unit.
pub scale: f64,
pub width: u32,
pub height: u32,
}
impl View {
pub fn transform(&self) -> Affine {
Affine::scale(self.scale) * Affine::translate((-self.origin.0, -self.origin.1))
}
pub fn page_rect(&self) -> Rect {
let s = self.scale.max(1e-9);
Rect::new(self.origin.0, self.origin.1, self.origin.0 + f64::from(self.width) / s, self.origin.1 + f64::from(self.height) / s)
}
}
#[derive(Clone, Debug, Default)]
pub struct Options {
/// Fill the background (None = transparent, e.g. export without page colour).
pub background: Option<Color>,
pub outline_mode: bool,
/// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>,
}
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
let w = w.clamp(1, 16_384);
let h = h.clamp(1, 16_384);
Pixmap::new(w, h)
}
/// Render a page region.
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
let mut pix = new_pixmap(view.width, view.height)?;
let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
Some(NodeKind::Page { background, .. }) => Some(*background),
_ => None,
});
if let Some(bg) = bg {
pix.fill(paint::ts_color(bg));
}
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip };
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
for c in &p.children {
draw::draw_node(&ctx, &mut pix, *c, xf, 0);
}
Some(pix)
}
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[] };
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
}
}
/// The page-space rectangle a node exports: a clipping frame's box (plus its own effects), else
/// everything it paints.
pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
if n.kind.is_frame_like() && n.clip {
let reach = n.paint_reach();
return Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), n.local_rect().inflate(reach, reach)));
}
doc.world_render_bounds(id)
}
/// Rasterise one node at `scale` (export). Transparent background.
pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
let b = export_bounds(doc, id)?;
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale).ceil().max(1.0);
let h = (b.height() * scale).ceil().max(1.0);
if w > 16_384.0 || h > 16_384.0 {
return None;
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
render_nodes_into(doc, &[id], &view, &mut pix);
Some(pix)
}
/// Un-premultiply into straight RGBA8 bytes.
pub fn to_rgba8(p: &Pixmap) -> Vec<u8> {
let mut out = Vec::with_capacity(p.data().len());
for px in p.data().chunks_exact(4) {
let a = px.get(3).copied().unwrap_or(0);
if a == 0 {
out.extend_from_slice(&[0, 0, 0, 0]);
} else {
let un = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
out.extend_from_slice(&[un(px.first().copied().unwrap_or(0)), un(px.get(1).copied().unwrap_or(0)), un(px.get(2).copied().unwrap_or(0)), a]);
}
}
out
}
/// Encode as PNG.
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let bg = background.to_rgba8();
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.chunks_exact(4) {
let a = u32::from(px.get(3).copied().unwrap_or(0));
for c in 0..3 {
let s = u32::from(px.get(c).copied().unwrap_or(0));
let b = u32::from(bg.get(c).copied().unwrap_or(255));
rgb.push(((s * a + b * (255 - a) + 127) / 255) as u8);
}
}
let mut out = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, p.width(), p.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as lossless WebP.
pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
#[cfg(test)]
mod tests;

151
crates/render/src/paint.rs Normal file
View File

@ -0,0 +1,151 @@
//! Paints → tiny-skia shaders, in a node's local space (width × height box).
use std::sync::Arc;
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::Document;
use tiny_skia::{FilterQuality, GradientStop, Pixmap, Point, Shader, SpreadMode, Transform};
pub fn ts_color(c: Color) -> tiny_skia::Color {
tiny_skia::Color::from_rgba(c.r, c.g, c.b, c.a).unwrap_or(tiny_skia::Color::BLACK)
}
pub fn ts_blend(b: BlendMode) -> tiny_skia::BlendMode {
use tiny_skia::BlendMode as T;
match b {
BlendMode::PassThrough | BlendMode::Normal => T::SourceOver,
BlendMode::Darken => T::Darken,
BlendMode::Multiply => T::Multiply,
// Plus darker: approximate with multiply-ish darken.
BlendMode::PlusDarker => T::Multiply,
BlendMode::ColorBurn => T::ColorBurn,
BlendMode::Lighten => T::Lighten,
BlendMode::Screen => T::Screen,
BlendMode::PlusLighter => T::Plus,
BlendMode::ColorDodge => T::ColorDodge,
BlendMode::Overlay => T::Overlay,
BlendMode::SoftLight => T::SoftLight,
BlendMode::HardLight => T::HardLight,
BlendMode::Difference => T::Difference,
BlendMode::Exclusion => T::Exclusion,
BlendMode::Hue => T::Hue,
BlendMode::Saturation => T::Saturation,
BlendMode::Color => T::Color,
BlendMode::Luminosity => T::Luminosity,
}
}
fn stops(g: &Gradient, opacity: f32) -> Vec<GradientStop> {
g.sorted_stops().into_iter().map(|s| GradientStop::new(s.position, ts_color(s.color.with_alpha(s.color.a * opacity)))).collect()
}
/// The unit-box → local mapping for a gradient's handles: maps (0,0)→from, (1,0)→to,
/// (0,1)→width (relative to from), in a w×h box.
fn handle_frame(g: &Gradient, w: f64, h: f64) -> Transform {
let f = (g.from.0 * w, g.from.1 * h);
let t = (g.to.0 * w, g.to.1 * h);
let k = (g.width.0 * w, g.width.1 * h);
Transform::from_row((t.0 - f.0) as f32, (t.1 - f.1) as f32, (k.0 - f.0) as f32, (k.1 - f.1) as f32, f.0 as f32, f.1 as f32)
}
/// What a paint needs beyond a shader: an owned pixmap backing an image/diamond pattern.
pub enum Prepared {
Shader(Shader<'static>),
Pattern { pixmap: Arc<Pixmap>, mode: SpreadMode, quality: FilterQuality, opacity: f32, transform: Transform },
}
impl Prepared {
pub fn shader(&self) -> Shader<'_> {
match self {
Prepared::Shader(s) => s.clone(),
Prepared::Pattern { pixmap, mode, quality, opacity, transform } => tiny_skia::Pattern::new(pixmap.as_ref().as_ref(), *mode, *quality, *opacity, *transform),
}
}
}
fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
const N: u32 = 256;
let mut p = Pixmap::new(N, N)?;
let half = f64::from(N) / 2.0;
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
let x = ((i as u32 % N) as f64 + 0.5 - half) / half;
let y = ((i as u32 / N) as f64 + 0.5 - half) / half;
let t = (x.abs() + y.abs()) as f32;
let c = g.sample(t);
let a = c.a * opacity;
let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8;
px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
}
Some(Arc::new(p))
}
/// Build the shader for `paint` on a `w`×`h` local box. Returns `None` when the paint draws
/// nothing (invisible, fully transparent, missing image).
pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared> {
if !paint.visible || paint.opacity <= 0.0 {
return None;
}
let op = paint.opacity.clamp(0.0, 1.0);
match &paint.kind {
PaintKind::Solid { color } => {
let c = color.with_alpha(color.a * op);
(c.a > 0.0).then(|| Prepared::Shader(Shader::SolidColor(ts_color(c))))
}
PaintKind::Gradient(g) => {
let frame = handle_frame(g, w.max(1e-6), h.max(1e-6));
let st = stops(g, op);
let shader = match g.kind {
GradientKind::Linear => tiny_skia::LinearGradient::new(Point::from_xy(0.0, 0.0), Point::from_xy(1.0, 0.0), st, SpreadMode::Pad, frame),
GradientKind::Radial => tiny_skia::RadialGradient::new(Point::from_xy(0.0, 0.0), 0.0, Point::from_xy(0.0, 0.0), 1.0, st, SpreadMode::Pad, frame),
GradientKind::Angular => tiny_skia::SweepGradient::new(Point::from_xy(0.0, 0.0), 0.0, 360.0, st, SpreadMode::Pad, frame),
GradientKind::Diamond => {
let pm = diamond_pixmap(g, op)?;
// Pattern pixel space (0..256) → unit square (-1..1) → handle frame.
let to_unit = Transform::from_row(2.0 / 256.0, 0.0, 0.0, 2.0 / 256.0, -1.0, -1.0);
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Pad, quality: FilterQuality::Bilinear, opacity: 1.0, transform: to_unit.post_concat(frame) });
}
};
shader.map(Prepared::Shader)
}
PaintKind::Image(img) => {
let data = doc.images.get(&img.image);
let pm = crate::images::get(&img.image, data, &img.filters)?;
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
let mode = if img.scale_mode == ImageScaleMode::Tile { SpreadMode::Repeat } else { SpreadMode::Pad };
Some(Prepared::Pattern { pixmap: pm, mode, quality: FilterQuality::Bicubic, opacity: op, transform })
}
}
}
/// Mapping from image pixels to the node's local box for each scale mode.
pub fn image_transform(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> Transform {
let iw = iw.max(1.0);
let ih = ih.max(1.0);
let rot = img.rotation.rem_euclid(4);
let (rw, rh) = if rot % 2 == 1 { (ih, iw) } else { (iw, ih) };
// Rotation about the image centre, then translate so the rotated image's box starts at 0.
let rot_t = Transform::from_translate(-(iw / 2.0) as f32, -(ih / 2.0) as f32)
.post_rotate(90.0 * rot as f32)
.post_translate((rw / 2.0) as f32, (rh / 2.0) as f32);
let fitted = match img.scale_mode {
ImageScaleMode::Fill => {
let s = (w / rw).max(h / rh);
Transform::from_scale(s as f32, s as f32).post_translate(((w - rw * s) / 2.0) as f32, ((h - rh * s) / 2.0) as f32)
}
ImageScaleMode::Fit => {
let s = (w / rw).min(h / rh);
Transform::from_scale(s as f32, s as f32).post_translate(((w - rw * s) / 2.0) as f32, ((h - rh * s) / 2.0) as f32)
}
ImageScaleMode::Tile => {
let s = if img.scale.is_finite() && img.scale > 0.0 { img.scale } else { 1.0 };
Transform::from_scale(s as f32, s as f32)
}
ImageScaleMode::Crop => {
// crop = unit box → image uv (a, b, c, d, e, f). Inverse maps uv → unit box.
let m = img.crop.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
let uv_to_unit = Transform::from_row(m[0] as f32, m[1] as f32, m[2] as f32, m[3] as f32, m[4] as f32, m[5] as f32).invert().unwrap_or_default();
Transform::from_scale((1.0 / rw) as f32, (1.0 / rh) as f32).post_concat(uv_to_unit).post_scale(w as f32, h as f32)
}
};
rot_t.post_concat(fitted)
}

189
crates/render/src/tests.rs Normal file
View File

@ -0,0 +1,189 @@
use super::*;
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData};
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
let d = &p.data()[i..i + 4];
[d[0], d[1], d[2], d[3]]
}
fn doc_with_rect(fill: Color) -> (figcraft_doc::Document, figcraft_doc::NodeId, figcraft_doc::NodeId) {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 10.0, 10.0, 50.0, 30.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(fill)];
(d, p, r)
}
fn view(w: u32, h: u32) -> View {
View { origin: (0.0, 0.0), scale: 1.0, width: w, height: h }
}
#[test]
fn solid_rect_and_page_background() {
let (d, p, _) = doc_with_rect(Color::from_hex("FF0000").unwrap());
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 30, 20), [255, 0, 0, 255]);
assert_eq!(px(&pix, 5, 5), [0xF5, 0xF5, 0xF5, 255]);
}
#[test]
fn zoom_scales() {
let (d, p, _) = doc_with_rect(Color::BLACK);
let v = View { origin: (0.0, 0.0), scale: 2.0, width: 200, height: 200 };
let pix = render_view(&d, p, &v, &Options::default()).unwrap();
assert_eq!(px(&pix, 100, 60)[3], 255);
assert_eq!(px(&pix, 118, 78), [0, 0, 0, 255]);
assert_eq!(px(&pix, 125, 60)[0], 0xF5);
}
#[test]
fn opacity_and_layer() {
let (mut d, p, r) = doc_with_rect(Color::BLACK);
d.get_mut(r).unwrap().opacity = 0.5;
let pix = render_view(&d, p, &view(100, 100), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
let v = px(&pix, 30, 20)[0];
assert!((120..=135).contains(&v), "{v}");
}
#[test]
fn drop_shadow_paints_outside() {
let (mut d, p, r) = doc_with_rect(Color::WHITE);
d.get_mut(r).unwrap().effects.push(Effect {
kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (0.0, 10.0), radius: 0.0, ..Default::default() }),
visible: true,
});
let pix = render_view(&d, p, &view(100, 100), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
// Below the rect (y 40..50) is shadow.
assert!(px(&pix, 30, 45)[0] < 30, "{:?}", px(&pix, 30, 45));
// The rect itself stays white.
assert_eq!(px(&pix, 30, 20)[0], 255);
}
#[test]
fn inner_shadow_and_blur_dont_crash() {
let (mut d, p, r) = doc_with_rect(Color::WHITE);
let n = d.get_mut(r).unwrap();
n.effects.push(Effect::inner_shadow());
n.effects.push(Effect::layer_blur());
n.effects.push(Effect::background_blur());
n.effects.push(Effect { kind: EffectKind::Noise { size: 1.0, density: 0.5, color: Color::BLACK }, visible: true });
let _ = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
}
#[test]
fn gradients_render() {
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond] {
let (mut d, p, r) = doc_with_rect(Color::BLACK);
let g = Gradient {
kind,
stops: vec![ColorStop { position: 0.0, color: Color::from_hex("FF0000").unwrap() }, ColorStop { position: 1.0, color: Color::from_hex("0000FF").unwrap() }],
from: (0.0, 0.5),
to: (1.0, 0.5),
width: (0.0, 1.0),
};
let g = if kind == GradientKind::Linear { g } else { Gradient { from: (0.5, 0.5), to: (1.0, 0.5), width: (0.5, 1.0), ..g } };
d.get_mut(r).unwrap().fills = vec![Paint::gradient(g)];
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
let left = px(&pix, if kind == GradientKind::Linear { 11 } else { 35 }, 25);
let right = px(&pix, 58, 25);
assert_ne!(left, right, "{kind:?}");
}
}
#[test]
fn frame_clips_children() {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 50.0, 50.0).unwrap();
let r = d.create(f, NodeKind::Rectangle, "R", 25.0, 25.0, 50.0, 50.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 40, 40), [0, 0, 0, 255]);
assert_eq!(px(&pix, 60, 60)[0], 0xF5);
d.get_mut(f).unwrap().clip = false;
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 60, 60), [0, 0, 0, 255]);
}
#[test]
fn strokes_align() {
let (mut d, p, r) = doc_with_rect(Color::WHITE);
let n = d.get_mut(r).unwrap();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 4.0;
n.stroke.align = StrokeAlign::Outside;
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 30, 8), [0, 0, 0, 255]);
assert_eq!(px(&pix, 30, 12), [255, 255, 255, 255]);
d.get_mut(r).unwrap().stroke.align = StrokeAlign::Inside;
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 30, 12), [0, 0, 0, 255]);
assert_eq!(px(&pix, 30, 8)[0], 0xF5);
}
#[test]
fn mask_clips_siblings() {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
let m = d.create(g, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "M", 0.0, 0.0, 50.0, 50.0).unwrap();
d.get_mut(m).unwrap().is_mask = true;
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 25, 25), [0, 0, 0, 255]);
assert_eq!(px(&pix, 2, 2)[0], 0xF5);
assert_eq!(px(&pix, 80, 80)[0], 0xF5);
}
#[test]
fn boolean_subtract_renders_hole() {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let b = d.create(p, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
d.create(b, NodeKind::Rectangle, "A", 0.0, 0.0, 100.0, 100.0).unwrap();
d.create(b, NodeKind::Rectangle, "Hole", 25.0, 25.0, 50.0, 50.0).unwrap();
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 10, 10), [0, 0, 0, 255]);
assert_eq!(px(&pix, 50, 50)[0], 0xF5);
}
#[test]
fn text_renders_something() {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let mut td = TextData::new("Hello");
td.style.font_size = 32.0;
d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 50.0).unwrap();
let pix = render_view(&d, p, &view(200, 60), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
let dark = pix.data().chunks_exact(4).filter(|c| c[0] < 100).count();
if figcraft_text::find_face("Inter", "Regular").is_some() {
assert!(dark > 50, "{dark}");
}
}
#[test]
fn export_png_roundtrip() {
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
let pix = render_node(&d, r, 2.0).unwrap();
assert_eq!((pix.width(), pix.height()), (100, 60));
let png = encode_png(&pix).unwrap();
let back = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(back.get_pixel(50, 30).0, [0, 255, 0, 255]);
let _ = encode_jpeg(&pix, Color::WHITE, 90).unwrap();
}
#[test]
fn hostile_sizes() {
let (mut d, p, r) = doc_with_rect(Color::BLACK);
let n = d.get_mut(r).unwrap();
n.width = f64::NAN;
n.height = 1e12;
n.effects.push(Effect { kind: EffectKind::LayerBlur { radius: 1e9 }, visible: true });
let _ = render_view(&d, p, &view(64, 64), &Options::default());
let _ = render_node(&d, r, f64::NAN);
let _ = render_view(&d, p, &View { origin: (f64::NAN, 0.0), scale: 0.0, width: 0, height: 0 }, &Options::default());
}

16
crates/text/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "figcraft-text"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft text: font database (embedded craft-fonts + system fonts), shaping, line layout, glyph outlines"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-doc = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
log = { workspace = true }

60
crates/text/build.rs Normal file
View File

@ -0,0 +1,60 @@
//! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the
//! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing,
//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`.
use std::fmt::Write as _;
use std::path::PathBuf;
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
let mut ok = false;
for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] {
match craft_fonts(&dir, manifest) {
Ok(entries) => {
ok = true;
src.push_str(&entries);
}
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
}
if !ok {
println!("cargo::warning=building without craft-fonts");
}
}
src.push_str("];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
}
}
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, String> {
let manifest = dir.join(manifest_rel);
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
// Web builds have a size budget: embed only Inter (the default UI and document font) there.
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
if wasm && *family != "Inter" {
continue;
}
// CJK fonts are large; the desktop app finds system CJK fonts instead unless asked.
if scripts.contains("Hans") && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let _ = writeln!(out, " CraftFont {{ family: {family:?}, style: {style:?}, bytes: include_bytes!({:?}) }},", path.display().to_string());
}
Ok(out)
}

468
crates/text/src/fontdb.rs Normal file
View File

@ -0,0 +1,468 @@
//! The font database: embedded craft-fonts faces plus (on desktop) the system's fonts.
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use figcraft_geom::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::{FileRef, TableProvider};
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
pub family: &'static str,
pub style: &'static str,
pub bytes: &'static [u8],
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
#[derive(Clone)]
pub enum FontBytes {
Static(&'static [u8]),
Shared(Arc<Vec<u8>>),
}
impl FontBytes {
pub fn data(&self) -> &[u8] {
match self {
FontBytes::Static(b) => b,
FontBytes::Shared(v) => v.as_slice(),
}
}
}
static NEXT_FACE: AtomicU32 = AtomicU32::new(1);
/// One loaded font face.
pub struct Face {
pub id: u32,
pub family: String,
pub style: String,
pub weight: u16,
pub italic: bool,
pub bytes: FontBytes,
pub index: u32,
pub upem: f64,
/// Ascent and descent in font units, both positive.
pub ascent: f64,
pub descent: f64,
pub line_gap: f64,
pub cap_height: f64,
pub x_height: f64,
pub underline_pos: f64,
pub underline_thickness: f64,
pub strike_pos: f64,
pub shaper: harfrust::ShaperData,
charmap: HashMap<u32, u32>,
outlines: Mutex<HashMap<u32, Arc<BezPath>>>,
advances: Mutex<HashMap<u32, f64>>,
}
impl std::fmt::Debug for Face {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Face({} {} #{})", self.family, self.style, self.id)
}
}
impl Face {
pub fn from_bytes(bytes: FontBytes, index: u32, family_override: Option<&str>, style_override: Option<&str>) -> Option<Face> {
let data = bytes.data();
let f = skrifa::FontRef::from_index(data, index).ok()?;
let (family, style) = names(&f);
let family = family_override.map(str::to_string).or(family)?;
let style = style_override.map(str::to_string).or(style).unwrap_or_else(|| "Regular".into());
let m = f.metrics(Size::unscaled(), LocationRef::default());
let attrs = f.attributes();
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
let upem = f64::from(m.units_per_em.max(1));
let ascent = f64::from(m.ascent);
let descent = -f64::from(m.descent);
let charmap: HashMap<u32, u32> = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
let weight = {
let w = attrs.weight.value();
if w.is_finite() && w > 0.0 { w.round() as u16 } else { figcraft_doc::style_weight(&style) }
};
Some(Face {
id: NEXT_FACE.fetch_add(1, Ordering::Relaxed),
italic: !matches!(attrs.style, skrifa::attribute::Style::Normal) || style.to_ascii_lowercase().contains("italic"),
weight,
family,
style,
index,
upem,
ascent,
descent,
line_gap: f64::from(m.leading),
cap_height: m.cap_height.map(f64::from).filter(|v| *v > 0.0).unwrap_or(ascent * 0.72),
x_height: m.x_height.map(f64::from).filter(|v| *v > 0.0).unwrap_or(ascent * 0.5),
underline_pos: under.0,
underline_thickness: under.1.max(upem * 0.02),
strike_pos: strike,
shaper,
charmap,
bytes,
outlines: Mutex::new(HashMap::new()),
advances: Mutex::new(HashMap::new()),
})
}
pub fn has_char(&self, c: char) -> bool {
self.charmap.get(&(c as u32)).is_some_and(|g| *g != 0)
}
pub fn glyph_for(&self, c: char) -> u32 {
self.charmap.get(&(c as u32)).copied().unwrap_or(0)
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.bytes.data(), self.index).ok()
}
/// Glyph outline in font units, y down.
pub fn outline(&self, gid: u32) -> Arc<BezPath> {
{
let cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(p) = cache.get(&gid) {
return p.clone();
}
}
let mut pen = FlipPen(BezPath::new());
if let Ok(f) = skrifa::FontRef::from_index(self.bytes.data(), self.index)
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() > 20_000 {
cache.clear();
}
cache.insert(gid, p.clone());
p
}
/// Horizontal advance in font units.
pub fn advance(&self, gid: u32) -> f64 {
{
let cache = self.advances.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(a) = cache.get(&gid) {
return *a;
}
}
let adv = skrifa::FontRef::from_index(self.bytes.data(), self.index)
.ok()
.and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
.map(f64::from)
.unwrap_or(self.upem * 0.5);
self.advances.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(gid, adv);
adv
}
}
struct FlipPen(BezPath);
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((f64::from(x), -f64::from(y)));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((f64::from(x), -f64::from(y)));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((f64::from(cx0), -f64::from(cy0)), (f64::from(x), -f64::from(y)));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((f64::from(cx0), -f64::from(cy0)), (f64::from(cx1), -f64::from(cy1)), (f64::from(x), -f64::from(y)));
}
fn close(&mut self) {
self.0.close_path();
}
}
fn name_string(f: &skrifa::FontRef, id: StringId) -> Option<String> {
let s: String = f.localized_strings(id).english_or_first()?.chars().collect();
let s = s.trim().to_string();
(!s.is_empty()).then_some(s)
}
/// (family, style) preferring the typographic names.
fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
let family = name_string(f, StringId::TYPOGRAPHIC_FAMILY_NAME).or_else(|| name_string(f, StringId::FAMILY_NAME));
let style = name_string(f, StringId::TYPOGRAPHIC_SUBFAMILY_NAME).or_else(|| name_string(f, StringId::SUBFAMILY_NAME));
(family, style)
}
/// A system font file entry not yet loaded.
#[derive(Clone, Debug)]
struct Pending {
path: std::path::PathBuf,
index: u32,
style: String,
}
#[derive(Default)]
pub struct FontDb {
faces: Vec<Arc<Face>>,
/// Lower-case family → (display family, face indices).
families: BTreeMap<String, (String, Vec<usize>)>,
/// System faces known by name but not loaded (lower-case family → entries).
pending: BTreeMap<String, (String, Vec<Pending>)>,
system_scanned: bool,
}
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
/// The process-wide font database (embedded fonts loaded on first use).
pub fn db() -> &'static RwLock<FontDb> {
DB.get_or_init(|| {
let mut db = FontDb::default();
for f in CRAFT_FONTS {
if let Some(face) = Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(f.family), Some(f.style)) {
db.add(face);
}
}
RwLock::new(db)
})
}
impl FontDb {
pub fn add(&mut self, face: Face) -> Arc<Face> {
let key = face.family.to_lowercase();
let display = face.family.clone();
let face = Arc::new(face);
self.faces.push(face.clone());
let idx = self.faces.len() - 1;
self.families.entry(key).or_insert_with(|| (display, Vec::new())).1.push(idx);
face
}
/// Add font file bytes (all faces of a collection). Returns the faces added.
pub fn add_bytes(&mut self, bytes: Vec<u8>) -> Vec<Arc<Face>> {
let shared = Arc::new(bytes);
let count = match FileRef::new(shared.as_slice()) {
Ok(FileRef::Collection(c)) => c.len(),
Ok(FileRef::Font(_)) => 1,
Err(_) => 0,
};
(0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect()
}
/// Family display names, sorted (loaded and known system families).
pub fn families(&self) -> Vec<String> {
let mut v: Vec<String> = self.families.values().map(|(d, _)| d.clone()).collect();
for (k, (d, _)) in &self.pending {
if !self.families.contains_key(k) {
v.push(d.clone());
}
}
v.sort_by_key(|a| a.to_lowercase());
v.dedup();
v
}
/// Style names of a family, by weight then italic.
pub fn styles(&mut self, family: &str) -> Vec<String> {
self.load_family(family);
let Some((_, idx)) = self.families.get(&family.to_lowercase()) else { return vec![] };
let mut v: Vec<(u16, bool, String)> = idx.iter().filter_map(|i| self.faces.get(*i)).map(|f| (f.weight, f.italic, f.style.clone())).collect();
v.sort();
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|(_, _, s)| s).collect()
}
pub fn has_family(&self, family: &str) -> bool {
let k = family.to_lowercase();
self.families.contains_key(&k) || self.pending.contains_key(&k)
}
fn load_family(&mut self, family: &str) {
let key = family.to_lowercase();
let Some((_, entries)) = self.pending.remove(&key) else { return };
let mut by_path: BTreeMap<std::path::PathBuf, Arc<Vec<u8>>> = BTreeMap::new();
for e in entries {
let bytes = match by_path.get(&e.path) {
Some(b) => b.clone(),
None => match std::fs::read(&e.path) {
Ok(b) => {
let b = Arc::new(b);
by_path.insert(e.path.clone(), b.clone());
b
}
Err(_) => continue,
},
};
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
self.add(face);
}
}
}
/// The best face for `family` + `style` (exact style name, else nearest weight/italic).
pub fn find(&mut self, family: &str, style: &str) -> Option<Arc<Face>> {
self.load_family(family);
let (_, idx) = self.families.get(&family.to_lowercase())?;
let faces: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
let norm = |s: &str| s.to_lowercase().replace([' ', '-'], "");
if let Some(f) = faces.iter().find(|f| norm(&f.style) == norm(style)) {
return Some((*f).clone());
}
let want_w = i32::from(figcraft_doc::style_weight(style));
let want_i = style.to_lowercase().contains("italic");
faces.into_iter().min_by_key(|f| (i32::from(f.weight) - want_w).abs() + if f.italic == want_i { 0 } else { 1000 }).cloned()
}
/// The default face (Inter Regular if embedded, else any system sans).
pub fn default_face(&mut self) -> Option<Arc<Face>> {
for fam in ["Inter", "Helvetica Neue", "Helvetica", "Arial", "Segoe UI", "Roboto", "DejaVu Sans", "Liberation Sans", "Noto Sans"] {
if let Some(f) = self.find(fam, "Regular") {
return Some(f);
}
}
self.faces.first().cloned()
}
/// Faces to try for characters the primary face lacks.
pub fn fallbacks(&mut self) -> Vec<Arc<Face>> {
let mut out = Vec::new();
for fam in [
"Inter",
"Helvetica Neue",
"Arial",
"Segoe UI",
"DejaVu Sans",
"Noto Sans",
"BIZ UDPGothic",
"Hiragino Sans",
"PingFang SC",
"Noto Sans CJK SC",
"Microsoft YaHei",
"Apple Color Emoji",
"Noto Sans Arabic",
"Apple Symbols",
"Segoe UI Symbol",
] {
if let Some(f) = self.find(fam, "Regular") {
out.push(f);
}
}
out
}
pub fn face_count(&self) -> usize {
self.faces.len()
}
/// Index the system font directories (desktop only): family names are read now, faces loaded
/// when first used. Safe to call more than once.
pub fn scan_system(&mut self) {
if self.system_scanned {
return;
}
self.system_scanned = true;
for dir in system_font_dirs() {
scan_dir(&dir, 0, &mut self.pending);
}
// Families already embedded win.
let embedded: Vec<String> = self.families.keys().cloned().collect();
for k in embedded {
self.pending.remove(&k);
}
}
}
fn system_font_dirs() -> Vec<std::path::PathBuf> {
let mut v: Vec<std::path::PathBuf> = Vec::new();
#[cfg(target_os = "macos")]
{
v.push("/System/Library/Fonts".into());
v.push("/Library/Fonts".into());
if let Some(h) = std::env::var_os("HOME") {
v.push(std::path::PathBuf::from(h).join("Library/Fonts"));
}
}
#[cfg(target_os = "windows")]
{
let windir = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
v.push(windir.join("Fonts"));
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
v.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
v.push("/usr/share/fonts".into());
v.push("/usr/local/share/fonts".into());
if let Some(h) = std::env::var_os("HOME") {
v.push(std::path::PathBuf::from(&h).join(".fonts"));
v.push(std::path::PathBuf::from(h).join(".local/share/fonts"));
}
}
v
}
fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (String, Vec<Pending>)>) {
if depth > 6 {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, depth + 1, out);
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
continue;
}
// Skip very large files (emoji, CJK collections) unless asked for by name later.
let Ok(bytes) = std::fs::read(&path) else { continue };
let count = match FileRef::new(bytes.as_slice()) {
Ok(FileRef::Collection(c)) => c.len(),
Ok(FileRef::Font(_)) => 1,
Err(_) => 0,
};
for i in 0..count.min(64) {
let Ok(f) = skrifa::FontRef::from_index(bytes.as_slice(), i) else { continue };
// Only outline fonts we can draw.
if f.glyf().is_err() && f.cff().is_err() && f.cff2().is_err() {
continue;
}
let (Some(family), style) = names(&f) else { continue };
if family.starts_with('.') {
continue;
}
let style = style.unwrap_or_else(|| "Regular".into());
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index: i, style });
}
}
}
/// Convenience: find a face with the global db.
pub fn find_face(family: &str, style: &str) -> Option<Arc<Face>> {
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
db.find(family, style).or_else(|| db.default_face())
}
/// All family names known to the global db.
pub fn family_names() -> Vec<String> {
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families()
}
/// Style names of a family in the global db.
pub fn family_styles(family: &str) -> Vec<String> {
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family)
}
/// Scan system fonts into the global db (desktop; a no-op on the web).
pub fn scan_system_fonts() {
#[cfg(not(target_arch = "wasm32"))]
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).scan_system();
}

594
crates/text/src/layout.rs Normal file
View File

@ -0,0 +1,594 @@
//! 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 + if line.glyph_end > line.glyph_start { 0.0 } else { 0.0 }, 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().last()) 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,
};
}
}

48
crates/text/src/lib.rs Normal file
View File

@ -0,0 +1,48 @@
//! FigCraft text: fonts, shaping and layout.
//!
//! Fonts come from the optional craft-fonts build input (Inter and friends, embedded at build time)
//! and, on desktop, from the system's font folders. [`layout`] turns a text node's
//! [`TextData`](figcraft_doc::TextData) into positioned glyphs, lines and decorations; the renderer
//! fills [`Glyph::path`]s and the editor uses [`TextLayout::caret`] / [`TextLayout::hit`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod fontdb;
mod layout;
pub use fontdb::{CRAFT_FONTS, Face, FontDb, db, family_names, family_styles, find_face, scan_system_fonts};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
use figcraft_doc::{Node, NodeKind, TextAlignV, TextAutoResize};
/// Lay out a text node at its current box, returning the layout and the vertical offset that
/// applies its vertical alignment.
pub fn layout_node(n: &Node) -> Option<(TextLayout, f64)> {
let NodeKind::Text(t) = &n.kind else { return None };
let l = layout(t, Some(n.width), Some(n.height));
let dy = match (t.auto_resize, t.align_v) {
(TextAutoResize::WidthAndHeight | TextAutoResize::Height, _) => 0.0,
(_, TextAlignV::Top) => 0.0,
(_, TextAlignV::Middle) => (n.height - l.height) / 2.0,
(_, TextAlignV::Bottom) => n.height - l.height,
};
Some((l, dy))
}
/// The box size a text node should have after its content or style changed, per its resize mode.
pub fn auto_size(n: &Node) -> Option<(f64, f64)> {
let NodeKind::Text(t) = &n.kind else { return None };
match t.auto_resize {
TextAutoResize::WidthAndHeight => {
let l = layout(t, None, None);
Some((l.width.ceil().max(1.0), l.height.max(1.0)))
}
TextAutoResize::Height => {
let l = layout(t, Some(n.width), None);
Some((n.width, l.height.max(1.0)))
}
TextAutoResize::None | TextAutoResize::Truncate => None,
}
}
#[cfg(test)]
mod tests;

104
crates/text/src/tests.rs Normal file
View File

@ -0,0 +1,104 @@
use super::*;
use figcraft_doc::{TextAlignH, TextData, TextDecoration};
fn have_fonts() -> bool {
db().write().unwrap().default_face().is_some() || {
scan_system_fonts();
db().write().unwrap().default_face().is_some()
}
}
#[test]
fn lays_out_single_line() {
if !have_fonts() {
eprintln!("no fonts; skipping");
return;
}
let mut t = TextData::new("Hello");
t.style.font_size = 20.0;
let l = layout(&t, None, None);
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.len() >= 5);
assert!(l.width > 30.0 && l.width < 100.0, "{}", l.width);
assert!(l.height > 20.0 && l.height < 30.0, "{}", l.height);
// Caret moves right.
assert!(l.caret(3).0 > l.caret(1).0);
assert_eq!(l.hit(-5.0, 5.0), 0);
assert_eq!(l.hit(1000.0, 5.0), 5);
}
#[test]
fn newline_and_wrap() {
if !have_fonts() {
return;
}
let mut t = TextData::new("one two three four five six\nseven");
t.auto_resize = figcraft_doc::TextAutoResize::Height;
let l = layout(&t, Some(60.0), None);
assert!(l.lines.len() >= 3, "{}", l.lines.len());
assert!(l.lines.iter().all(|ln| ln.width <= 60.0 + 1e-6 || ln.glyph_end - ln.glyph_start <= 1));
// Auto width ignores the box.
t.auto_resize = figcraft_doc::TextAutoResize::WidthAndHeight;
assert_eq!(layout(&t, Some(60.0), None).lines.len(), 2);
}
#[test]
fn alignment_and_decoration() {
if !have_fonts() {
return;
}
let mut t = TextData::new("Hi");
t.auto_resize = figcraft_doc::TextAutoResize::None;
t.align_h = TextAlignH::Right;
t.style.decoration = TextDecoration::Underline;
let l = layout(&t, Some(200.0), None);
let last = l.glyphs.last().unwrap();
assert!((last.x + last.advance - 200.0).abs() < 0.5);
assert_eq!(l.decorations.len(), 1);
}
#[test]
fn empty_and_hostile_inputs() {
if !have_fonts() {
return;
}
let mut t = TextData::new("");
let l = layout(&t, None, None);
assert_eq!(l.lines.len(), 1);
assert!(l.height > 0.0);
t.characters = "\n\n".into();
assert_eq!(layout(&t, None, None).lines.len(), 3);
t.style.font_size = f64::NAN;
t.characters = "x".into();
let _ = layout(&t, Some(f64::NAN), Some(-1.0));
t.runs.push(figcraft_doc::StyleRun { start: 5, end: 2, style: Default::default() });
let _ = layout(&t, None, None);
let mut emoji = TextData::new("a😀b 漢字 ß");
emoji.style.case = figcraft_doc::TextCase::Upper;
let l = layout(&emoji, None, None);
assert!(!l.glyphs.is_empty());
}
#[test]
fn truncate() {
if !have_fonts() {
return;
}
let mut t = TextData::new("a very long line of text that will not fit at all");
t.auto_resize = figcraft_doc::TextAutoResize::Truncate;
let l = layout(&t, Some(80.0), Some(16.0));
assert!(l.truncated);
assert_eq!(l.lines.len(), 1);
}
#[test]
fn glyph_paths_have_area() {
if !have_fonts() {
return;
}
use figcraft_geom::Shape;
let t = TextData::new("O");
let l = layout(&t, None, None);
let p = l.to_path();
assert!(p.area().abs() > 1.0);
}