From b367392c4bff7a5bf53f73ad43882af21caf3d6c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 12:12:34 -0400 Subject: [PATCH] M0.4: text (shaping, layout), pathops (booleans, aligned strokes), render (tiny-skia scene renderer) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 365 ++++++++++++++++++++++ crates/pathops/Cargo.toml | 14 + crates/pathops/src/lib.rs | 361 ++++++++++++++++++++++ crates/render/Cargo.toml | 19 ++ crates/render/src/draw.rs | 583 +++++++++++++++++++++++++++++++++++ crates/render/src/fx.rs | 247 +++++++++++++++ crates/render/src/images.rs | 106 +++++++ crates/render/src/lib.rs | 163 ++++++++++ crates/render/src/paint.rs | 151 +++++++++ crates/render/src/tests.rs | 189 ++++++++++++ crates/text/Cargo.toml | 16 + crates/text/build.rs | 60 ++++ crates/text/src/fontdb.rs | 468 ++++++++++++++++++++++++++++ crates/text/src/layout.rs | 594 ++++++++++++++++++++++++++++++++++++ crates/text/src/lib.rs | 48 +++ crates/text/src/tests.rs | 104 +++++++ 16 files changed, 3488 insertions(+) create mode 100644 crates/pathops/Cargo.toml create mode 100644 crates/pathops/src/lib.rs create mode 100644 crates/render/Cargo.toml create mode 100644 crates/render/src/draw.rs create mode 100644 crates/render/src/fx.rs create mode 100644 crates/render/src/images.rs create mode 100644 crates/render/src/lib.rs create mode 100644 crates/render/src/paint.rs create mode 100644 crates/render/src/tests.rs create mode 100644 crates/text/Cargo.toml create mode 100644 crates/text/build.rs create mode 100644 crates/text/src/fontdb.rs create mode 100644 crates/text/src/layout.rs create mode 100644 crates/text/src/lib.rs create mode 100644 crates/text/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 6dc0f41..c4b4c8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/crates/pathops/Cargo.toml b/crates/pathops/Cargo.toml new file mode 100644 index 0000000..27e2b72 --- /dev/null +++ b/crates/pathops/Cargo.toml @@ -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 } diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs new file mode 100644 index 0000000..5b8fdb2 --- /dev/null +++ b/crates/pathops/src/lib.rs @@ -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 { + 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 { + 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 { + 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 = 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 = props.dashes.iter().copied().filter(|d| d.is_finite() && *d >= 0.0).collect(); + if !dashes.is_empty() && dashes.iter().sum::() > 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 = 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 { + 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()); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 0000000..7e84b7b --- /dev/null +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs new file mode 100644 index 0000000..d2fdc4a --- /dev/null +++ b/crates/render/src/draw.rs @@ -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 { + 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>> { + static C: OnceLock>>> = 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 { + 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 { + 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 = 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>> { + static C: OnceLock>>> = 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 { + 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 { + 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 = 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 = 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 = 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 = 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 +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs new file mode 100644 index 0000000..d72f770 --- /dev/null +++ b/crates/render/src/fx.rs @@ -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 = 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 { + 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)); + } + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs new file mode 100644 index 0000000..a177a34 --- /dev/null +++ b/crates/render/src/images.rs @@ -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>> { + static C: OnceLock>>> = 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 { + 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> { + 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() +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 0000000..c3dea9b --- /dev/null +++ b/crates/render/src/lib.rs @@ -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, + pub outline_mode: bool, + /// Nodes to leave out (an editor drawing them itself). + pub skip: Vec, +} + +fn new_pixmap(w: u32, h: u32) -> Option { + 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 { + 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 { + 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 { + 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 { + 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, 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, 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, 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; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs new file mode 100644 index 0000000..7791642 --- /dev/null +++ b/crates/render/src/paint.rs @@ -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 { + 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, 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> { + 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 { + 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) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs new file mode 100644 index 0000000..73469cb --- /dev/null +++ b/crates/render/src/tests.rs @@ -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()); +} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml new file mode 100644 index 0000000..a184861 --- /dev/null +++ b/crates/text/Cargo.toml @@ -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 } diff --git a/crates/text/build.rs b/crates/text/build.rs new file mode 100644 index 0000000..5375599 --- /dev/null +++ b/crates/text/build.rs @@ -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 { + 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) +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs new file mode 100644 index 0000000..ca612db --- /dev/null +++ b/crates/text/src/fontdb.rs @@ -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>), +} + +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, + outlines: Mutex>>, + advances: Mutex>, +} + +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 { + 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 = 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::from_index(self.bytes.data(), self.index).ok() + } + + /// Glyph outline in font units, y down. + pub fn outline(&self, gid: u32) -> Arc { + { + 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 { + 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, Option) { + 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>, + /// Lower-case family → (display family, face indices). + families: BTreeMap)>, + /// System faces known by name but not loaded (lower-case family → entries). + pending: BTreeMap)>, + system_scanned: bool, +} + +static DB: OnceLock> = OnceLock::new(); + +/// The process-wide font database (embedded fonts loaded on first use). +pub fn db() -> &'static RwLock { + 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 { + 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) -> Vec> { + 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 { + let mut v: Vec = 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 { + 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>> = 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> { + self.load_family(family); + let (_, idx) = self.families.get(&family.to_lowercase())?; + let faces: Vec<&Arc> = 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> { + 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> { + 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 = self.families.keys().cloned().collect(); + for k in embedded { + self.pending.remove(&k); + } + } +} + +fn system_font_dirs() -> Vec { + let mut v: Vec = 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)>) { + 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> { + 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 { + 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 { + 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(); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs new file mode 100644 index 0000000..4f8b252 --- /dev/null +++ b/crates/text/src/layout.rs @@ -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, + 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, + pub lines: Vec, + pub decorations: Vec, + /// The distinct styles the glyphs refer to. + pub styles: Vec, + /// 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, + 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, box_height: Option) -> TextLayout { + let chars: Vec = text.characters.chars().collect(); + let spans = text.spans(); + let mut styles: Vec = Vec::new(); + let mut span_style: Vec = 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>> = 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 = 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 { + 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 = 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) { + 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, style: &TextStyle, size: f64, spacing: f64, st_idx: usize, items: &mut Vec) { + 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 { + 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, + }; + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs new file mode 100644 index 0000000..317452d --- /dev/null +++ b/crates/text/src/lib.rs @@ -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; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs new file mode 100644 index 0000000..97caca1 --- /dev/null +++ b/crates/text/src/tests.rs @@ -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); +}