From 83773baa003fa4cd0d4a6b57c1d08e2e9e513361 Mon Sep 17 00:00:00 2001 From: Salman Abuhaimed <85521119+BKSalman@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:23:17 +0300 Subject: [PATCH] Replace Rustybuzz with Parley for text layout, and add text tilt parameter (#2739) * replace rustybuzz with parley for text layout handling change text input direction based on text direction * Code review * change default character spacing to 0 * add shear to text node this also adds migration code for documents that don't have shear * shear migration for text node - add shear property - set character spacing to 0 * use old max_width and max_height in text migration if available * Final code review pass * Add units to the parameters --------- Co-authored-by: Keavon Chambers --- .gitignore | 1 + .nix/flake.nix | 2 +- Cargo.lock | 340 ++++++++++++------ Cargo.toml | 3 +- .../document/graph_operation/utility_types.rs | 1 + .../node_graph/document_node_definitions.rs | 15 + .../utility_types/network_interface.rs | 3 +- .../messages/portfolio/document_migration.rs | 43 ++- .../graph_modification_utils.rs | 2 + .../common_functionality/utility_functions.rs | 6 +- .../messages/tool/tool_messages/text_tool.rs | 15 +- .../src/components/panels/Document.svelte | 4 + .../widgets/inputs/FieldInput.svelte | 1 + node-graph/gcore/Cargo.toml | 3 +- node-graph/gcore/src/text/font_cache.rs | 5 +- node-graph/gcore/src/text/to_path.rs | 310 ++++++++-------- node-graph/gcore/src/transform_nodes.rs | 4 +- node-graph/gstd/src/text.rs | 30 +- 18 files changed, 488 insertions(+), 300 deletions(-) diff --git a/.gitignore b/.gitignore index 5f1f0aaff..5700f4e89 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ perf.data* profile.json flamegraph.svg .idea/ +.direnv diff --git a/.nix/flake.nix b/.nix/flake.nix index b74b64fb4..fc0cb6cbe 100644 --- a/.nix/flake.nix +++ b/.nix/flake.nix @@ -51,7 +51,7 @@ libraw - # Tauri dependencies: keep in sync with https://v2.tauri.app/start/prerequisites/ + # Tauri dependencies: keep in sync with https://v2.tauri.app/start/prerequisites/#system-dependencies (under the NixOS tab) at-spi2-atk atkmm cairo diff --git a/Cargo.lock b/Cargo.lock index c7ae0cdb1..c93d3d4de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.5.2" @@ -486,11 +492,11 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d59b4c170e16f0405a2e95aff44432a0d41aa97675f3d52623effe95792a037" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" dependencies = [ - "objc2 0.6.0", + "objc2 0.6.1", ] [[package]] @@ -528,9 +534,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" dependencies = [ "bytemuck_derive", ] @@ -822,6 +828,12 @@ dependencies = [ "serde", ] +[[package]] +name = "color" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ae467d04a8a8aea5d9a49018a6ade2e4221d92968e8ce55a48c0b1164e5f698" + [[package]] name = "color_quant" version = "1.1.0" @@ -1255,6 +1267,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1569,6 +1591,25 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -1592,6 +1633,29 @@ dependencies = [ "ttf-parser 0.24.1", ] +[[package]] +name = "fontique" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f97079e1293b8c1e9fb03a2875d328bd2ee8f3b95ce62959c0acc04049c708" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.4", + "icu_locid", + "memmap2", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.1", + "peniko 0.4.0", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2109,7 +2173,7 @@ checksum = "dcf29e94d6d243368b7a56caa16bc213e4f9f8ed38c4d9557069527b5d5281ca" dependencies = [ "bitflags 2.9.0", "gpu-descriptor-types", - "hashbrown 0.15.2", + "hashbrown 0.15.4", ] [[package]] @@ -2200,13 +2264,14 @@ dependencies = [ "node-macro", "num-derive", "num-traits", + "parley", "petgraph 0.7.1", "rand 0.9.0", "rand_chacha 0.9.0", "rustc-hash 2.1.1", - "rustybuzz 0.20.1", "serde", "serde_json", + "skrifa 0.32.0", "specta", "tinyvec", "tokio", @@ -2470,10 +2535,12 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2949,7 +3016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.15.4", "serde", ] @@ -3204,9 +3271,9 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89234b2cc610a7dd927ebde6b41dd1a5d4214cffaef4cf1fb2195d592f92518f" +checksum = "1077d333efea6170d9ccb96d3c3026f300ca0773da4938cc4c811daa6df68b0c" dependencies = [ "arrayvec", "serde", @@ -3282,7 +3349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3506,10 +3573,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "once_cell", "png", "serde", @@ -3790,9 +3857,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" dependencies = [ "objc2-encode 4.1.0", "objc2-exception-helper", @@ -3805,15 +3872,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" dependencies = [ "bitflags 2.9.0", - "block2 0.6.0", + "block2 0.6.1", "libc", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "objc2-quartz-core 0.3.0", ] @@ -3824,8 +3891,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", - "objc2-foundation 0.3.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -3835,18 +3902,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", - "objc2-foundation 0.3.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] name = "objc2-core-foundation" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "dispatch2", + "objc2 0.6.1", ] [[package]] @@ -3856,7 +3924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-core-foundation", "objc2-io-surface", ] @@ -3867,8 +3935,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56" dependencies = [ - "objc2 0.6.0", - "objc2-foundation 0.3.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ba833d4a1cb1aac330f8c973fd92b6ff1858e4aef5cdd00a255eefb28022fb5" +dependencies = [ + "bitflags 2.9.0", + "objc2-core-foundation", ] [[package]] @@ -3906,14 +3984,14 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.9.0", - "block2 0.6.0", + "block2 0.6.1", "libc", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -3924,7 +4002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -3960,8 +4038,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", - "objc2-foundation 0.3.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -3971,9 +4049,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-core-foundation", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", ] [[package]] @@ -3983,11 +4061,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b717127e4014b0f9f3e8bba3d3f2acec81f1bde01f656823036e823ed2c94dce" dependencies = [ "bitflags 2.9.0", - "block2 0.6.0", - "objc2 0.6.0", + "block2 0.6.1", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", ] [[package]] @@ -4149,6 +4227,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parley" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e57638545cf2ba4c3e72cc5715e53b1880b829cc3dbefda3d1700c58efe723" +dependencies = [ + "fontique", + "hashbrown 0.15.4", + "peniko 0.4.0", + "skrifa 0.31.3", + "swash", +] + [[package]] name = "paste" version = "1.0.15" @@ -4180,7 +4271,18 @@ name = "peniko" version = "0.2.0" source = "git+https://github.com/linebender/peniko.git?rev=d114c62#d114c6292dbcfb03e7360692198be423168a0edd" dependencies = [ - "color", + "color 0.1.0", + "kurbo", + "smallvec", +] + +[[package]] +name = "peniko" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9529efd019889b2a205193c14ffb6e2839b54ed9d2720674f10f4b04d87ac9" +dependencies = [ + "color 0.3.1", "kurbo", "smallvec", ] @@ -4968,7 +5070,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" dependencies = [ "bytemuck", - "font-types", + "font-types 0.8.3", +] + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91" +dependencies = [ + "bytemuck", + "font-types 0.9.0", ] [[package]] @@ -5261,26 +5383,8 @@ dependencies = [ "log", "smallvec", "ttf-parser 0.24.1", - "unicode-bidi-mirroring 0.3.0", - "unicode-ccc 0.3.0", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.9.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser 0.25.1", - "unicode-bidi-mirroring 0.4.0", - "unicode-ccc 0.4.0", + "unicode-bidi-mirroring", + "unicode-ccc", "unicode-properties", "unicode-script", ] @@ -5415,9 +5519,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.218" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] @@ -5446,9 +5550,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.218" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -5652,7 +5756,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.25.3", +] + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d632b5a73f566303dbeabd344dc3e716fd4ddc9a70d6fc8ea8e6f06617da97" +dependencies = [ + "bytemuck", + "read-fonts 0.30.1", ] [[package]] @@ -5675,9 +5799,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -5895,6 +6019,17 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "swash" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f745de914febc7c9ab4388dfaf94bbc87e69f57bb41133a9b0c84d4be49856f3" +dependencies = [ + "skrifa 0.31.3", + "yazi", + "zeno", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -6005,9 +6140,9 @@ dependencies = [ "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "once_cell", "parking_lot", "raw-window-handle", @@ -6060,9 +6195,9 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "percent-encoding", "plist", "raw-window-handle", @@ -6266,9 +6401,9 @@ dependencies = [ "http", "jni", "log", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "once_cell", "percent-encoding", "raw-window-handle", @@ -6688,11 +6823,11 @@ dependencies = [ "dirs", "libappindicator", "muda", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "once_cell", "png", "serde", @@ -6720,9 +6855,6 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] [[package]] name = "typeid" @@ -6795,24 +6927,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - [[package]] name = "unicode-ccc" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42" -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - [[package]] name = "unicode-ident" version = "1.0.18" @@ -6900,7 +7020,7 @@ dependencies = [ "log", "pico-args", "roxmltree", - "rustybuzz 0.18.0", + "rustybuzz", "simplecss", "siphasher 1.0.1", "strict-num", @@ -6971,9 +7091,9 @@ dependencies = [ "bytemuck", "futures-intrusive", "log", - "peniko", + "peniko 0.2.0", "png", - "skrifa", + "skrifa 0.26.6", "static_assertions", "thiserror 2.0.12", "vello_encoding", @@ -6988,8 +7108,8 @@ source = "git+https://github.com/linebender/vello.git?rev=3275ec8#3275ec85d83118 dependencies = [ "bytemuck", "guillotiere", - "peniko", - "skrifa", + "peniko 0.2.0", + "skrifa 0.26.6", "smallvec", ] @@ -7556,10 +7676,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -8077,7 +8197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b19b78efae8b853c6c817e8752fc1dbf9cab8a8ffe9c30f399bd750ccf0f0730" dependencies = [ "base64 0.22.1", - "block2 0.6.0", + "block2 0.6.1", "cookie", "crossbeam-channel", "dpi", @@ -8091,10 +8211,10 @@ dependencies = [ "kuchikiki", "libc", "ndk 0.9.0", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -8199,6 +8319,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + [[package]] name = "yoke" version = "0.7.5" @@ -8223,6 +8349,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index 56c27ce6f..1dab01000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,7 +116,8 @@ rand_chacha = "0.9" glam = { version = "0.29", default-features = false, features = ["serde", "scalar-math", "debug-glam-assert"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp"] } -rustybuzz = "0.20" +parley = "0.5.0" +skrifa = "0.32.0" pretty_assertions = "1.4.1" fern = { version = "0.7", features = ["colored"] } num_enum = "0.7" diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index d79a60b81..245b2f41e 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -190,6 +190,7 @@ impl<'a> ModifyInputsContext<'a> { Some(NodeInput::value(TaggedValue::F64(typesetting.character_spacing), false)), Some(NodeInput::value(TaggedValue::OptionalF64(typesetting.max_width), false)), Some(NodeInput::value(TaggedValue::OptionalF64(typesetting.max_height), false)), + Some(NodeInput::value(TaggedValue::F64(typesetting.tilt), false)), ]); let text_id = NodeId::new(); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index c426ac85a..63a001ba5 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -1556,6 +1556,7 @@ fn static_nodes() -> Vec { NodeInput::value(TaggedValue::F64(TypesettingConfig::default().character_spacing), false), NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false), NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false), + NodeInput::value(TaggedValue::F64(TypesettingConfig::default().tilt), false), ], ..Default::default() }, @@ -1577,6 +1578,7 @@ fn static_nodes() -> Vec { "Line Height", "TODO", WidgetOverride::Number(NumberInputSettings { + unit: Some("x".to_string()), min: Some(0.), step: Some(0.1), ..Default::default() @@ -1586,6 +1588,7 @@ fn static_nodes() -> Vec { "Character Spacing", "TODO", WidgetOverride::Number(NumberInputSettings { + unit: Some(" px".to_string()), min: Some(0.), step: Some(0.1), ..Default::default() @@ -1595,6 +1598,7 @@ fn static_nodes() -> Vec { "Max Width", "TODO", WidgetOverride::Number(NumberInputSettings { + unit: Some(" px".to_string()), min: Some(1.), blank_assist: false, ..Default::default() @@ -1604,11 +1608,22 @@ fn static_nodes() -> Vec { "Max Height", "TODO", WidgetOverride::Number(NumberInputSettings { + unit: Some(" px".to_string()), min: Some(1.), blank_assist: false, ..Default::default() }), ), + PropertiesRow::with_override( + "Tilt", + "Faux italic", + WidgetOverride::Number(NumberInputSettings { + min: Some(-85.), + max: Some(85.), + unit: Some("°".to_string()), + ..Default::default() + }), + ), ], output_names: vec!["Vector".to_string()], ..Default::default() diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 04e2f0803..2566650e7 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -1161,8 +1161,7 @@ impl NodeNetworkInterface { .and_then(|node_metadata| node_metadata.persistent_metadata.input_properties.get(index)) } - pub fn insert_input_properties_row(&mut self, node_id: &NodeId, index: usize, network_path: &[NodeId]) { - let row = ("", "TODO").into(); + pub fn insert_input_properties_row(&mut self, node_id: &NodeId, index: usize, network_path: &[NodeId], row: PropertiesRow) { let _ = self .node_metadata_mut(node_id, network_path) .map(|node_metadata| node_metadata.persistent_metadata.input_properties.insert(index - 1, row)); diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index e8724966c..5612a8a23 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1,6 +1,7 @@ // TODO: Eventually remove this document upgrade code // This file contains lots of hacky code for upgrading old documents to the new format +use super::document::utility_types::network_interface::{NumberInputSettings, PropertiesRow, WidgetOverride}; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector}; @@ -308,8 +309,8 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ let node_definition = resolve_document_node_type(reference).unwrap(); let document_node = node_definition.default_node_template().document_node; document.network_interface.replace_implementation(node_id, network_path, document_node.implementation.clone()); - document.network_interface.insert_input_properties_row(node_id, 8, network_path); - document.network_interface.insert_input_properties_row(node_id, 9, network_path); + document.network_interface.insert_input_properties_row(node_id, 8, network_path, ("", "TODO").into()); + document.network_interface.insert_input_properties_row(node_id, 9, network_path, ("", "TODO").into()); let old_inputs = document.network_interface.replace_inputs(node_id, document_node.inputs.clone(), network_path); let align_input = NodeInput::value(TaggedValue::StrokeAlign(StrokeAlign::Center), false); @@ -402,7 +403,7 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } // Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016 - if reference == "Text" && inputs_count != 8 { + if reference == "Text" && inputs_count != 9 { let node_definition = resolve_document_node_type(reference).unwrap(); let document_node = node_definition.default_node_template().document_node; document.network_interface.replace_implementation(node_id, network_path, document_node.implementation.clone()); @@ -433,12 +434,44 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ ); document.network_interface.set_input( &InputConnector::node(*node_id, 6), - NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false), + if inputs_count >= 7 { + old_inputs[6].clone() + } else { + NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false) + }, network_path, ); document.network_interface.set_input( &InputConnector::node(*node_id, 7), - NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false), + if inputs_count >= 8 { + old_inputs[7].clone() + } else { + NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false) + }, + network_path, + ); + document.network_interface.insert_input_properties_row( + node_id, + 9, + network_path, + PropertiesRow::with_override( + "Tilt", + "Faux italic", + WidgetOverride::Number(NumberInputSettings { + min: Some(-85.), + max: Some(85.), + unit: Some("°".to_string()), + ..Default::default() + }), + ), + ); + document.network_interface.set_input( + &InputConnector::node(*node_id, 8), + if inputs_count >= 9 { + old_inputs[8].clone() + } else { + NodeInput::value(TaggedValue::F64(TypesettingConfig::default().tilt), false) + }, network_path, ); } diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index e06d34b9e..765bfc1ea 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -361,6 +361,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter let Some(&TaggedValue::F64(character_spacing)) = inputs[5].as_value() else { return None }; let Some(&TaggedValue::OptionalF64(max_width)) = inputs[6].as_value() else { return None }; let Some(&TaggedValue::OptionalF64(max_height)) = inputs[7].as_value() else { return None }; + let Some(&TaggedValue::F64(tilt)) = inputs[8].as_value() else { return None }; let typesetting = TypesettingConfig { font_size, @@ -368,6 +369,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter max_width, character_spacing, max_height, + tilt, }; Some((text, font, typesetting)) } diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index b7b95123c..23829271b 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -11,7 +11,7 @@ use crate::messages::tool::utility_types::ToolType; use bezier_rs::{Bezier, BezierHandles}; use glam::{DAffine2, DVec2}; use graphene_std::renderer::Quad; -use graphene_std::text::{FontCache, load_face}; +use graphene_std::text::{FontCache, load_font}; use graphene_std::vector::{HandleExt, HandleId, ManipulatorPointId, PointId, SegmentId, VectorData, VectorModificationType}; use kurbo::{CubicBez, Line, ParamCurveExtrema, PathSeg, Point, QuadBez}; @@ -70,8 +70,8 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH return Quad::from_box([DVec2::ZERO, DVec2::ZERO]); }; - let buzz_face = font_cache.get(font).map(|data| load_face(data)); - let far = graphene_std::text::bounding_box(text, buzz_face.as_ref(), typesetting, false); + let font_data = font_cache.get(font).map(|data| load_font(data)); + let far = graphene_std::text::bounding_box(text, font_data, typesetting, false); Quad::from_box([DVec2::ZERO, far]) } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 44814fc39..e56e53d47 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -18,7 +18,7 @@ use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; use graphene_std::renderer::Quad; -use graphene_std::text::{Font, FontCache, TypesettingConfig, lines_clipping, load_face}; +use graphene_std::text::{Font, FontCache, TypesettingConfig, lines_clipping, load_font}; use graphene_std::vector::style::Fill; #[derive(Default)] @@ -35,6 +35,7 @@ pub struct TextOptions { font_name: String, font_style: String, fill: ToolColorOptions, + tilt: f64, } impl Default for TextOptions { @@ -42,10 +43,11 @@ impl Default for TextOptions { Self { font_size: 24., line_height_ratio: 1.2, - character_spacing: 1., + character_spacing: 0., font_name: graphene_std::consts::DEFAULT_FONT_FAMILY.into(), font_style: graphene_std::consts::DEFAULT_FONT_STYLE.into(), fill: ToolColorOptions::new_primary(), + tilt: 0., } } } @@ -468,8 +470,8 @@ impl Fsm for TextToolFsmState { transform: document.metadata().transform_to_viewport(tool_data.layer).to_cols_array(), }); if let Some(editing_text) = tool_data.editing_text.as_mut() { - let buzz_face = font_cache.get(&editing_text.font).map(|data| load_face(data)); - let far = graphene_std::text::bounding_box(&tool_data.new_text, buzz_face.as_ref(), editing_text.typesetting, false); + let font_data = font_cache.get(&editing_text.font).map(|data| load_font(data)); + let far = graphene_std::text::bounding_box(&tool_data.new_text, font_data, editing_text.typesetting, false); if far.x != 0. && far.y != 0. { let quad = Quad::from_box([DVec2::ZERO, far]); let transformed_quad = document.metadata().transform_to_viewport(tool_data.layer) * quad; @@ -517,8 +519,8 @@ impl Fsm for TextToolFsmState { // Draw red overlay if text is clipped let transformed_quad = layer_transform * bounds; if let Some((text, font, typesetting)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface) { - let buzz_face = font_cache.get(font).map(|data| load_face(data)); - if lines_clipping(text.as_str(), buzz_face, typesetting) { + let font_data = font_cache.get(font).map(|data| load_font(data)); + if lines_clipping(text.as_str(), font_data, typesetting) { overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.)); } } @@ -784,6 +786,7 @@ impl Fsm for TextToolFsmState { max_width: constraint_size.map(|size| size.x), character_spacing: tool_options.character_spacing, max_height: constraint_size.map(|size| size.y), + tilt: tool_options.tilt, }, font: Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()), color: tool_options.fill.active_color(), diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index db9174144..70d4ea99b 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -760,6 +760,8 @@ .text-input { word-break: break-all; + unicode-bidi: plaintext; + text-align: left; } .text-input div { @@ -773,6 +775,8 @@ overflow-wrap: anywhere; white-space: pre-wrap; word-break: normal; + unicode-bidi: plaintext; + text-align: left; display: inline-block; // Workaround to force Chrome to display the flashing text entry cursor when text is empty padding-left: 1px; diff --git a/frontend/src/components/widgets/inputs/FieldInput.svelte b/frontend/src/components/widgets/inputs/FieldInput.svelte index 2efc1353b..b35279401 100644 --- a/frontend/src/components/widgets/inputs/FieldInput.svelte +++ b/frontend/src/components/widgets/inputs/FieldInput.svelte @@ -158,6 +158,7 @@ background: none; color: var(--color-e-nearwhite); caret-color: var(--color-e-nearwhite); + unicode-bidi: plaintext; &::selection { background-color: var(--color-4-dimgray); diff --git a/node-graph/gcore/Cargo.toml b/node-graph/gcore/Cargo.toml index 9d7a9dde3..cc8b30c8b 100644 --- a/node-graph/gcore/Cargo.toml +++ b/node-graph/gcore/Cargo.toml @@ -29,10 +29,11 @@ ctor = { workspace = true } rand_chacha = { workspace = true } bezier-rs = { workspace = true } specta = { workspace = true } -rustybuzz = { workspace = true } image = { workspace = true } half = { workspace = true } tinyvec = { workspace = true } +parley = { workspace = true } +skrifa = { workspace = true } kurbo = { workspace = true } log = { workspace = true } base64 = { workspace = true } diff --git a/node-graph/gcore/src/text/font_cache.rs b/node-graph/gcore/src/text/font_cache.rs index a872166fb..37a8bfc50 100644 --- a/node-graph/gcore/src/text/font_cache.rs +++ b/node-graph/gcore/src/text/font_cache.rs @@ -22,11 +22,12 @@ impl Default for Font { /// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, DynAny)] pub struct FontCache { - /// Actual font file data used for rendering a font with ttf_parser and rustybuzz + /// Actual font file data used for rendering a font font_file_data: HashMap>, /// Web font preview URLs used for showing fonts when live editing preview_urls: HashMap, } + impl FontCache { /// Returns the font family name if the font is cached, otherwise returns the fallback font family name if that is cached pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> { @@ -40,7 +41,7 @@ impl FontCache { } /// Try to get the bytes for a font - pub fn get<'a>(&'a self, font: &Font) -> Option<&'a Vec> { + pub fn get(&self, font: &Font) -> Option<&Vec> { self.resolve_font(font).and_then(|font| self.font_file_data.get(font)) } diff --git a/node-graph/gcore/src/text/to_path.rs b/node-graph/gcore/src/text/to_path.rs index 9988fcae3..bfe60da86 100644 --- a/node-graph/gcore/src/text/to_path.rs +++ b/node-graph/gcore/src/text/to_path.rs @@ -1,29 +1,67 @@ use crate::vector::PointId; use bezier_rs::{ManipulatorGroup, Subpath}; -use glam::DVec2; -use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder}; -use rustybuzz::{GlyphBuffer, UnicodeBuffer}; +use core::cell::RefCell; +use glam::{DAffine2, DVec2}; +use parley::fontique::Blob; +use parley::{Alignment, AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; +use skrifa::GlyphId; +use skrifa::instance::{LocationRef, NormalizedCoord, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FontRef as ReadFontsRef; +use skrifa::{MetadataProvider, OutlineGlyph}; +use std::sync::Arc; -struct Builder { +// Thread-local storage avoids expensive re-initialization of font and layout contexts +// across multiple text rendering operations within the same thread +thread_local! { + static FONT_CONTEXT: RefCell = RefCell::new(FontContext::new()); + static LAYOUT_CONTEXT: RefCell> = RefCell::new(LayoutContext::new()); +} + +struct PathBuilder { current_subpath: Subpath, + glyph_subpaths: Vec>, other_subpaths: Vec>, - text_cursor: DVec2, - offset: DVec2, - ascender: f64, + origin: DVec2, scale: f64, id: PointId, } -impl Builder { +impl PathBuilder { fn point(&self, x: f32, y: f32) -> DVec2 { - self.text_cursor + self.offset + DVec2::new(x as f64, self.ascender - y as f64) * self.scale + // Y-axis inversion converts from font coordinate system (Y-up) to graphics coordinate system (Y-down) + DVec2::new(self.origin.x + x as f64, self.origin.y - y as f64) * self.scale + } + + fn set_origin(&mut self, x: f64, y: f64) { + self.origin = DVec2::new(x, y); + } + + fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, skew: DAffine2) { + let location_ref = LocationRef::new(normalized_coords); + let settings = DrawSettings::unhinted(Size::new(size), location_ref); + glyph.draw(settings, self).unwrap(); + + // Apply transforms in correct order: style-based skew first, then user-requested skew + // This ensures font synthesis (italic) is applied before user transformations + for glyph_subpath in &mut self.glyph_subpaths { + if let Some(style_skew) = style_skew { + glyph_subpath.apply_transform(style_skew); + } + + glyph_subpath.apply_transform(skew); + } + + if !self.glyph_subpaths.is_empty() { + self.other_subpaths.extend(core::mem::take(&mut self.glyph_subpaths)); + } } } -impl OutlineBuilder for Builder { +impl OutlinePen for PathBuilder { fn move_to(&mut self, x: f32, y: f32) { if !self.current_subpath.is_empty() { - self.other_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); } self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id())); } @@ -47,36 +85,10 @@ impl OutlineBuilder for Builder { fn close(&mut self) { self.current_subpath.set_closed(true); - self.other_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); } } -fn font_properties(buzz_face: &rustybuzz::Face, font_size: f64, line_height_ratio: f64) -> (f64, f64, UnicodeBuffer) { - let scale = (buzz_face.units_per_em() as f64).recip() * font_size; - let line_height = font_size * line_height_ratio; - let buffer = UnicodeBuffer::new(); - (scale, line_height, buffer) -} - -fn push_str(buffer: &mut UnicodeBuffer, word: &str) { - buffer.push_str(word); -} - -fn wrap_word(max_width: Option, glyph_buffer: &GlyphBuffer, font_size: f64, character_spacing: f64, x_pos: f64, space_glyph: Option) -> bool { - if let Some(max_width) = max_width { - // We don't word wrap spaces (to match the browser) - let all_glyphs = glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()); - let non_space_glyphs = all_glyphs.take_while(|(_, info)| space_glyph != Some(GlyphId(info.glyph_id as u16))); - let word_length: f64 = non_space_glyphs.map(|(pos, _)| pos.x_advance as f64 * character_spacing).sum(); - let scaled_word_length = word_length * font_size; - - if scaled_word_length + x_pos > max_width { - return true; - } - } - false -} - #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub struct TypesettingConfig { pub font_size: f64, @@ -84,6 +96,7 @@ pub struct TypesettingConfig { pub character_spacing: f64, pub max_width: Option, pub max_height: Option, + pub tilt: f64, } impl Default for TypesettingConfig { @@ -91,163 +104,130 @@ impl Default for TypesettingConfig { Self { font_size: 24., line_height_ratio: 1.2, - character_spacing: 1., + character_spacing: 0., max_width: None, max_height: None, + tilt: 0., } } } -pub fn to_path(str: &str, buzz_face: Option, typesetting: TypesettingConfig) -> Vec> { - let Some(buzz_face) = buzz_face else { return vec![] }; - let space_glyph = buzz_face.glyph_index(' '); +fn render_glyph_run(glyph_run: &GlyphRun<'_, ()>, path_builder: &mut PathBuilder, tilt: f64) { + let mut run_x = glyph_run.offset(); + let run_y = glyph_run.baseline(); - let (scale, line_height, mut buffer) = font_properties(&buzz_face, typesetting.font_size, typesetting.line_height_ratio); + let run = glyph_run.run(); - let mut builder = Builder { + // User-requested tilt applied around baseline to avoid vertical displacement + // Translation ensures rotation point is at the baseline, not origin + let skew = DAffine2::from_translation(DVec2::new(0., run_y as f64)) + * DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.]) + * DAffine2::from_translation(DVec2::new(0., -run_y as f64)); + + let synthesis = run.synthesis(); + + // Font synthesis (e.g., synthetic italic) applied separately from user transforms + // This preserves the distinction between font styling and user transformations + let style_skew = synthesis.skew().map(|angle| { + DAffine2::from_translation(DVec2::new(0., run_y as f64)) + * DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.]) + * DAffine2::from_translation(DVec2::new(0., -run_y as f64)) + }); + + let font = run.font(); + let font_size = run.font_size(); + + let normalized_coords = run.normalized_coords().iter().map(|coord| NormalizedCoord::from_bits(*coord)).collect::>(); + + // TODO: This can be cached for better performance + let font_collection_ref = font.data.as_ref(); + let font_ref = ReadFontsRef::from_index(font_collection_ref, font.index).unwrap(); + let outlines = font_ref.outline_glyphs(); + + for glyph in glyph_run.glyphs() { + let glyph_x = run_x + glyph.x; + let glyph_y = run_y - glyph.y; + run_x += glyph.advance; + + let glyph_id = GlyphId::from(glyph.id); + if let Some(glyph_outline) = outlines.get(glyph_id) { + path_builder.set_origin(glyph_x as f64, glyph_y as f64); + path_builder.draw_glyph(&glyph_outline, font_size, &normalized_coords, style_skew, skew); + } + } +} + +fn layout_text(str: &str, font_data: Option>, typesetting: TypesettingConfig) -> Option> { + let font_cx = FONT_CONTEXT.with(Clone::clone); + let mut font_cx = font_cx.borrow_mut(); + let layout_cx = LAYOUT_CONTEXT.with(Clone::clone); + let mut layout_cx = layout_cx.borrow_mut(); + + let font_family = font_data.and_then(|font_data| { + font_cx + .collection + .register_fonts(font_data, None) + .first() + .and_then(|(family_id, _)| font_cx.collection.family_name(*family_id).map(String::from)) + })?; + + const DISPLAY_SCALE: f32 = 1.; + let mut builder = layout_cx.ranged_builder(&mut font_cx, str, DISPLAY_SCALE, true); + + builder.push_default(StyleProperty::FontSize(typesetting.font_size as f32)); + builder.push_default(StyleProperty::LetterSpacing(typesetting.character_spacing as f32)); + builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(parley::FontFamily::Named(std::borrow::Cow::Owned(font_family))))); + builder.push_default(LineHeight::FontSizeRelative(typesetting.line_height_ratio as f32)); + + let mut layout: Layout<()> = builder.build(str); + + layout.break_all_lines(typesetting.max_width.map(|mw| mw as f32)); + layout.align(typesetting.max_width.map(|max_w| max_w as f32), Alignment::Left, AlignmentOptions::default()); + + Some(layout) +} + +pub fn to_path(str: &str, font_data: Option>, typesetting: TypesettingConfig) -> Vec> { + let Some(layout) = layout_text(str, font_data, typesetting) else { return Vec::new() }; + + let mut path_builder = PathBuilder { current_subpath: Subpath::new(Vec::new(), false), + glyph_subpaths: Vec::new(), other_subpaths: Vec::new(), - text_cursor: DVec2::ZERO, - offset: DVec2::ZERO, - ascender: (buzz_face.ascender() as f64 / buzz_face.height() as f64) * typesetting.font_size / scale, - scale, + origin: DVec2::ZERO, + scale: layout.scale() as f64, id: PointId::ZERO, }; - for line in str.split('\n') { - for (index, word) in SplitWordsIncludingSpaces::new(line).enumerate() { - push_str(&mut buffer, word); - let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer); - - // Don't wrap the first word - if index != 0 && wrap_word(typesetting.max_width, &glyph_buffer, scale, typesetting.character_spacing, builder.text_cursor.x, space_glyph) { - builder.text_cursor = DVec2::new(0., builder.text_cursor.y + line_height); + for line in layout.lines() { + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(glyph_run) = item { + render_glyph_run(&glyph_run, &mut path_builder, typesetting.tilt); } - - for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) { - let glyph_id = GlyphId(glyph_info.glyph_id as u16); - if let Some(max_width) = typesetting.max_width { - if space_glyph != Some(glyph_id) && builder.text_cursor.x + (glyph_position.x_advance as f64 * builder.scale * typesetting.character_spacing) >= max_width { - builder.text_cursor = DVec2::new(0., builder.text_cursor.y + line_height); - } - } - // Clip when the height is exceeded - if typesetting.max_height.is_some_and(|max_height| builder.text_cursor.y > max_height - line_height) { - return builder.other_subpaths; - } - - builder.offset = DVec2::new(glyph_position.x_offset as f64, glyph_position.y_offset as f64) * builder.scale; - buzz_face.outline_glyph(glyph_id, &mut builder); - if !builder.current_subpath.is_empty() { - builder.other_subpaths.push(std::mem::replace(&mut builder.current_subpath, Subpath::new(Vec::new(), false))); - } - - builder.text_cursor += DVec2::new(glyph_position.x_advance as f64 * typesetting.character_spacing, glyph_position.y_advance as f64) * builder.scale; - } - - buffer = glyph_buffer.clear(); } - - builder.text_cursor = DVec2::new(0., builder.text_cursor.y + line_height); } - builder.other_subpaths + path_builder.other_subpaths } -pub fn bounding_box(str: &str, buzz_face: Option<&rustybuzz::Face>, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 { - // Show blank layer if font has not loaded - let Some(buzz_face) = buzz_face else { return DVec2::ZERO }; - let space_glyph = buzz_face.glyph_index(' '); - - let (scale, line_height, mut buffer) = font_properties(buzz_face, typesetting.font_size, typesetting.line_height_ratio); - - let [mut text_cursor, mut bounds] = [DVec2::ZERO; 2]; +pub fn bounding_box(str: &str, font_data: Option>, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 { if !for_clipping_test { if let (Some(max_height), Some(max_width)) = (typesetting.max_height, typesetting.max_width) { return DVec2::new(max_width, max_height); } } - for line in str.split('\n') { - for (index, word) in SplitWordsIncludingSpaces::new(line).enumerate() { - push_str(&mut buffer, word); + let Some(layout) = layout_text(str, font_data, typesetting) else { return DVec2::ZERO }; - let glyph_buffer = rustybuzz::shape(buzz_face, &[], buffer); - - // Don't wrap the first word - if index != 0 && wrap_word(typesetting.max_width, &glyph_buffer, scale, typesetting.character_spacing, text_cursor.x, space_glyph) { - text_cursor = DVec2::new(0., text_cursor.y + line_height); - } - - for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) { - let glyph_id = GlyphId(glyph_info.glyph_id as u16); - if let Some(max_width) = typesetting.max_width { - if space_glyph != Some(glyph_id) && text_cursor.x + (glyph_position.x_advance as f64 * scale * typesetting.character_spacing) >= max_width { - text_cursor = DVec2::new(0., text_cursor.y + line_height); - } - } - text_cursor += DVec2::new(glyph_position.x_advance as f64 * typesetting.character_spacing, glyph_position.y_advance as f64) * scale; - bounds = bounds.max(text_cursor + DVec2::new(0., line_height)); - } - - buffer = glyph_buffer.clear(); - } - text_cursor = DVec2::new(0., text_cursor.y + line_height); - bounds = bounds.max(text_cursor); - } - - if !for_clipping_test { - if let Some(max_width) = typesetting.max_width { - bounds.x = max_width; - } - if let Some(max_height) = typesetting.max_height { - bounds.y = max_height; - } - } - - bounds + DVec2::new(layout.full_width() as f64, layout.height() as f64) } -pub fn load_face(data: &[u8]) -> rustybuzz::Face<'_> { - rustybuzz::Face::from_slice(data, 0).expect("Loading font failed") +pub fn load_font(data: &[u8]) -> Blob { + Blob::new(Arc::new(data.to_vec())) } -pub fn lines_clipping(str: &str, buzz_face: Option, typesetting: TypesettingConfig) -> bool { +pub fn lines_clipping(str: &str, font_data: Option>, typesetting: TypesettingConfig) -> bool { let Some(max_height) = typesetting.max_height else { return false }; - let bounds = bounding_box(str, buzz_face.as_ref(), typesetting, true); + let bounds = bounding_box(str, font_data, typesetting, true); max_height < bounds.y } - -struct SplitWordsIncludingSpaces<'a> { - text: &'a str, - start_byte: usize, -} - -impl<'a> SplitWordsIncludingSpaces<'a> { - pub fn new(text: &'a str) -> Self { - Self { text, start_byte: 0 } - } -} - -impl<'a> Iterator for SplitWordsIncludingSpaces<'a> { - type Item = &'a str; - fn next(&mut self) -> Option { - let mut eaten_chars = self.text[self.start_byte..].char_indices().skip_while(|(_, c)| *c != ' ').skip_while(|(_, c)| *c == ' '); - let start_byte = self.start_byte; - self.start_byte = eaten_chars.next().map_or(self.text.len(), |(offset, _)| self.start_byte + offset); - (self.start_byte > start_byte).then(|| self.text.get(start_byte..self.start_byte)).flatten() - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn split_words_including_spaces() { - let mut split_words = SplitWordsIncludingSpaces::new("hello world ."); - assert_eq!(split_words.next(), Some("hello ")); - assert_eq!(split_words.next(), Some("world ")); - assert_eq!(split_words.next(), Some(".")); - assert_eq!(split_words.next(), None); - } -} diff --git a/node-graph/gcore/src/transform_nodes.rs b/node-graph/gcore/src/transform_nodes.rs index 39024681e..5e584aa82 100644 --- a/node-graph/gcore/src/transform_nodes.rs +++ b/node-graph/gcore/src/transform_nodes.rs @@ -19,10 +19,10 @@ async fn transform( translate: DVec2, rotate: f64, scale: DVec2, - shear: DVec2, + skew: DVec2, _pivot: DVec2, ) -> Instances { - let matrix = DAffine2::from_scale_angle_translation(scale, rotate, translate) * DAffine2::from_cols_array(&[1., shear.y, shear.x, 1., 0., 0.]); + let matrix = DAffine2::from_scale_angle_translation(scale, rotate, translate) * DAffine2::from_cols_array(&[1., skew.y, skew.x, 1., 0., 0.]); let footprint = ctx.try_footprint().copied(); diff --git a/node-graph/gstd/src/text.rs b/node-graph/gstd/src/text.rs index 0f69ae1bf..71eea96fb 100644 --- a/node-graph/gstd/src/text.rs +++ b/node-graph/gstd/src/text.rs @@ -9,23 +9,37 @@ fn text<'i: 'n>( editor: &'i WasmEditorApi, text: String, font_name: Font, - #[default(24.)] font_size: f64, - #[default(1.2)] line_height_ratio: f64, - #[default(1.)] character_spacing: f64, - #[default(None)] max_width: Option, - #[default(None)] max_height: Option, + #[unit(" px")] + #[default(24.)] + font_size: f64, + #[unit("x")] + #[default(1.2)] + line_height_ratio: f64, + #[unit(" px")] + #[default(0.)] + character_spacing: f64, + #[unit(" px")] + #[default(None)] + max_width: Option, + #[unit(" px")] + #[default(None)] + max_height: Option, + #[unit("°")] + #[default(0.)] + tilt: f64, ) -> VectorDataTable { - let buzz_face = editor.font_cache.get(&font_name).map(|data| load_face(data)); - let typesetting = TypesettingConfig { font_size, line_height_ratio, character_spacing, max_width, max_height, + tilt, }; - let result = VectorData::from_subpaths(to_path(&text, buzz_face, typesetting), false); + let font_data = editor.font_cache.get(&font_name).map(|f| load_font(f)); + + let result = VectorData::from_subpaths(to_path(&text, font_data, typesetting), false); VectorDataTable::new(result) }