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:
parent
f7a68c864b
commit
b367392c4b
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue