diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..6c42baa6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1051 @@ +# This file is automatically @generated by Cargo. +# 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 = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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 3.0.6", +] + +[[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" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[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 = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "designcraft-color" +version = "0.1.0" +dependencies = [ + "kurbo", + "moxcms", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "designcraft-compose" +version = "0.1.0" +dependencies = [ + "designcraft-color", + "designcraft-doc", + "designcraft-fonts", + "designcraft-geom", + "log", + "proptest", +] + +[[package]] +name = "designcraft-doc" +version = "0.1.0" +dependencies = [ + "designcraft-color", + "designcraft-geom", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "designcraft-fonts" +version = "0.1.0" +dependencies = [ + "harfrust", + "kurbo", + "log", + "skrifa 0.47.0", +] + +[[package]] +name = "designcraft-geom" +version = "0.1.0" +dependencies = [ + "kurbo", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "designcraft-render" +version = "0.1.0" +dependencies = [ + "designcraft-color", + "designcraft-compose", + "designcraft-doc", + "designcraft-fonts", + "designcraft-geom", + "image", + "log", + "vello_common", + "vello_cpu", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[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 = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[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", + "tiff", + "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 2.0.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "serde", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[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 = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "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.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "crossbeam-channel", + "glifo", + "hashbrown", + "ordered-channel", + "png", + "rayon", + "thread_local", + "vello_common", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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/Cargo.toml b/Cargo.toml index cd893757..b3a041bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/*", "apps/*", "xtask"] -default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] +members = ["crates/*", "xtask"] +default-members = ["crates/*", "xtask"] [workspace.package] version = "0.1.0" diff --git a/assets/fonts/SourceSerif4-Bold.ttf b/assets/fonts/SourceSerif4-Bold.ttf new file mode 100644 index 00000000..c4159ff2 Binary files /dev/null and b/assets/fonts/SourceSerif4-Bold.ttf differ diff --git a/assets/fonts/SourceSerif4-BoldIt.ttf b/assets/fonts/SourceSerif4-BoldIt.ttf new file mode 100644 index 00000000..40f3863f Binary files /dev/null and b/assets/fonts/SourceSerif4-BoldIt.ttf differ diff --git a/assets/fonts/SourceSerif4-It.ttf b/assets/fonts/SourceSerif4-It.ttf new file mode 100644 index 00000000..e818087d Binary files /dev/null and b/assets/fonts/SourceSerif4-It.ttf differ diff --git a/assets/fonts/SourceSerif4-Semibold.ttf b/assets/fonts/SourceSerif4-Semibold.ttf new file mode 100644 index 00000000..151e9a9d Binary files /dev/null and b/assets/fonts/SourceSerif4-Semibold.ttf differ diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml new file mode 100644 index 00000000..e507559a --- /dev/null +++ b/crates/compose/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "designcraft-compose" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft text composition: paragraph composer (Knuth–Plass), hyphenation, justification, columns, threading, wrap" + +[lints] +workspace = true + +[dependencies] +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-doc = { workspace = true } +designcraft-fonts = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs new file mode 100644 index 00000000..87074862 --- /dev/null +++ b/crates/compose/src/breaker.rs @@ -0,0 +1,390 @@ +//! Line breaking: the paragraph composer (Knuth–Plass total fit) and the single-line composer +//! (greedy first fit). Both work on a paragraph's glyphs and a per-line measure function. + +use crate::shape::{Glyph, SOFT_HYPHEN}; + +/// One line chosen by a breaker: glyphs `start..end` are visible; `next` is where the next line starts. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Break { + pub start: usize, + pub end: usize, + pub next: usize, + /// Broken at a hyphenation point (a hyphen glyph must be added). + pub hyphen: bool, + /// Ended by a forced break (paragraph end, forced line break, column/frame/page break). + pub forced: bool, +} + +/// Spacing parameters (from the paragraph's Justification settings), as fractions. +#[derive(Clone, Copy, Debug)] +pub struct Spacing { + pub justify: bool, + pub word_min: f64, + pub word_desired: f64, + pub word_max: f64, + /// Penalty for a hyphenated break (0 = free, larger = fewer hyphens). + pub hyphen_penalty: f64, + /// Maximum consecutive hyphenated lines (0 = unlimited). + pub hyphen_limit: u32, + /// Ragged-right stretchability (points) for unjustified paragraphs. + pub ragged_stretch: f64, +} + +#[derive(Clone, Copy, Debug)] +enum Item { + /// Unbreakable width (a glyph). + Box { w: f64 }, + /// Space glyph `g`. + Glue { w: f64, st: f64, sh: f64 }, + Penalty { w: f64, p: f64, flagged: bool }, +} + +const INF: f64 = 10000.0; + +/// Does the glyph force a line end after it? +pub fn is_forced(c: char) -> bool { + use designcraft_doc::story::*; + matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK) +} + +/// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens). +fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec) { + let mut it = Vec::with_capacity(glyphs.len() * 2 + 2); + let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2); + let n = glyphs.len(); + for (i, g) in glyphs.iter().enumerate() { + if is_forced(g.ch) { + if !sp.justify || true { + it.push(Item::Glue { w: 0.0, st: INF, sh: 0.0 }); + ig.push(i); + } + it.push(Item::Penalty { w: 0.0, p: -INF, flagged: false }); + ig.push(i + 1); + continue; + } + if g.is_space() && !g.no_break { + let w = g.adv; + if sp.justify { + let base = w / sp.word_desired.max(1e-6); + it.push(Item::Glue { w, st: base * (sp.word_max - sp.word_desired).max(0.0), sh: base * (sp.word_desired - sp.word_min).max(0.0) }); + ig.push(i); + } else { + // Ragged right (Knuth): glue(0, s, 0) penalty(0) glue(w, -s, 0). + it.push(Item::Glue { w: 0.0, st: sp.ragged_stretch, sh: 0.0 }); + ig.push(i); + it.push(Item::Penalty { w: 0.0, p: 0.0, flagged: false }); + ig.push(i); + it.push(Item::Glue { w, st: -sp.ragged_stretch, sh: 0.0 }); + ig.push(i); + } + continue; + } + if g.ch == SOFT_HYPHEN { + let hw = g.size * 0.33; + it.push(Item::Penalty { w: hw, p: sp.hyphen_penalty, flagged: true }); + ig.push(i + 1); + continue; + } + it.push(Item::Box { w: g.adv }); + ig.push(i); + let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space()); + if i + 1 < n && !next_is_space && !g.no_break { + if matches!(g.ch, '-' | '\u{2010}') { + it.push(Item::Penalty { w: 0.0, p: 50.0, flagged: true }); + ig.push(i + 1); + } else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') { + it.push(Item::Penalty { w: 0.0, p: 0.0, flagged: false }); + ig.push(i + 1); + } else if hyph_after[i] { + let hw = g.size * 0.33; + it.push(Item::Penalty { w: hw, p: sp.hyphen_penalty, flagged: true }); + ig.push(i + 1); + } + } + } + // Paragraph end: fill glue + forced break. + it.push(Item::Glue { w: 0.0, st: INF, sh: 0.0 }); + ig.push(n); + it.push(Item::Penalty { w: 0.0, p: -INF, flagged: false }); + ig.push(n); + (it, ig) +} + +#[derive(Clone, Copy, Debug)] +struct Node { + pos: usize, + line: usize, + fitness: u8, + tw: f64, + ty: f64, + tz: f64, + demerits: f64, + prev: Option, + hyphens: u32, + flagged: bool, +} + +/// Knuth–Plass total-fit line breaking. `width(line)` gives each line's measure. +pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec { + if glyphs.is_empty() { + return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }]; + } + let (items, ig) = items(glyphs, hyph_after, sp); + for tolerance in [3.0, 30.0, f64::INFINITY] { + if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) { + return b; + } + } + greedy(glyphs, hyph_after, sp, width) +} + +#[allow(clippy::too_many_arguments)] +fn kp_pass(items: &[Item], ig: &[usize], glyphs: &[Glyph], sp: &Spacing, width: &dyn Fn(usize) -> f64, tol: f64, emergency: bool) -> Option> { + let m = items.len(); + // Prefix sums (before item i). + let mut sw = vec![0.0; m + 1]; + let mut sy = vec![0.0; m + 1]; + let mut sz = vec![0.0; m + 1]; + for (i, it) in items.iter().enumerate() { + let (w, y, z) = match *it { + Item::Box { w } => (w, 0.0, 0.0), + Item::Glue { w, st, sh } => (w, st, sh), + Item::Penalty { .. } => (0.0, 0.0, 0.0), + }; + sw[i + 1] = sw[i] + w; + sy[i + 1] = sy[i] + y; + sz[i + 1] = sz[i] + z; + } + let mut arena: Vec = vec![Node { pos: 0, line: 0, fitness: 1, tw: 0.0, ty: 0.0, tz: 0.0, demerits: 0.0, prev: None, hyphens: 0, flagged: false }]; + let mut active: Vec = vec![0]; + for b in 0..m { + let (is_break, pw, pp, flagged) = match items[b] { + Item::Penalty { w, p, flagged } if p < INF => (true, w, p, flagged), + Item::Glue { .. } if b > 0 && matches!(items[b - 1], Item::Box { .. }) => (true, 0.0, 0.0, false), + _ => (false, 0.0, 0.0, false), + }; + if !is_break { + continue; + } + let forced = pp <= -INF; + let mut best: [Option<(f64, usize, f64)>; 4] = [None; 4]; // (demerits, active node, ratio) + let mut keep = Vec::with_capacity(active.len()); + let mut last_removed: Option<(usize, f64)> = None; + for &a in &active { + let n = arena[a]; + let l = sw[b] - n.tw + pw; + let target = width(n.line).max(1.0); + let (y, z) = (sy[b] - n.ty, sz[b] - n.tz); + let r = if l < target { + if y > 1e-9 { (target - l) / y } else { INF } + } else if l > target { + if z > 1e-9 { (target - l) / z } else { -INF } + } else { + 0.0 + }; + let deactivate = r < -1.0 || forced; + if !deactivate { + keep.push(a); + } else { + last_removed = Some((a, r)); + } + if r >= -1.0 && r <= tol || (forced && r >= -1.0) { + let bad = (100.0 * r.abs().powi(3)).min(INF); + let lp = 10.0 + bad; + let mut d = if pp >= 0.0 { lp * lp + pp * pp } else if pp > -INF { lp * lp - pp * pp } else { lp * lp }; + if flagged && n.flagged { + d += 3000.0; + } + if flagged && sp.hyphen_limit > 0 && n.hyphens >= sp.hyphen_limit { + d += 1e7; + } + let fit = if r < -0.5 { + 0 + } else if r <= 0.5 { + 1 + } else if r <= 1.0 { + 2 + } else { + 3 + }; + if (fit as i32 - n.fitness as i32).abs() > 1 { + d += 3000.0; + } + let total = n.demerits + d; + if best[fit as usize].is_none_or(|(bd, _, _)| total < bd) { + best[fit as usize] = Some((total, a, r)); + } + } + } + // Emergency: nothing can reach this break and nothing remains active → accept an overfull line. + if emergency + && keep.is_empty() + && best.iter().all(Option::is_none) + && let Some((a, _)) = last_removed + { + best[0] = Some((arena[a].demerits + 1e8, a, -1.0)); + } + active = keep; + // Totals after the break: skip glue and penalties up to the next box. + let (mut tw, mut ty, mut tz) = (sw[b], sy[b], sz[b]); + let mut i = b; + while i < m { + match items[i] { + Item::Box { .. } => break, + Item::Glue { w, st, sh } => { + tw += w; + ty += st; + tz += sh; + } + Item::Penalty { p, .. } if p <= -INF && i > b => break, + Item::Penalty { .. } => {} + } + i += 1; + } + for (fit, c) in best.iter().enumerate() { + if let Some((d, a, _)) = *c { + let prev = arena[a]; + arena.push(Node { + pos: b, + line: prev.line + 1, + fitness: fit as u8, + tw, + ty, + tz, + demerits: d, + prev: Some(a), + hyphens: if flagged { prev.hyphens + 1 } else { 0 }, + flagged, + }); + active.push(arena.len() - 1); + } + } + if active.is_empty() { + return None; + } + } + // The final forced break: best node at position m-1. + let end = active.iter().copied().filter(|&a| arena[a].pos == m - 1).min_by(|&a, &b| arena[a].demerits.total_cmp(&arena[b].demerits))?; + let mut chain = Vec::new(); + let mut cur = Some(end); + while let Some(c) = cur { + if arena[c].prev.is_some() { + chain.push(arena[c].pos); + } + cur = arena[c].prev; + } + chain.reverse(); + Some(breaks_from_positions(items, ig, glyphs, &chain)) +} + +fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: &[usize]) -> Vec { + let n = glyphs.len(); + let mut out = Vec::with_capacity(chain.len()); + let mut start = 0; + for &b in chain { + let (end, hyphen, forced) = match items[b] { + Item::Penalty { p, flagged, w } => { + let e = ig[b].min(n); + // A flagged penalty with width = an inserted hyphen (not an explicit '-'). + let explicit = e > 0 && matches!(glyphs[e - 1].ch, '-' | '\u{2010}'); + (e, flagged && w > 0.0 && !explicit || (flagged && e > 0 && glyphs[e - 1].ch == SOFT_HYPHEN), p <= -INF) + } + Item::Glue { .. } => (ig[b], false, false), + Item::Box { .. } => (ig[b], false, false), + }; + // Trim trailing spaces from the visible range; skip leading spaces of the next line. + let mut vis_end = end; + while vis_end > start && glyphs[vis_end - 1].is_space() { + vis_end -= 1; + } + let mut next = end; + while next < n && glyphs[next].is_space() { + next += 1; + } + let forced_glyph = end > 0 && end <= n && is_forced(glyphs[end - 1].ch); + out.push(Break { start, end: vis_end, next, hyphen, forced: forced || forced_glyph }); + start = next; + } + if out.is_empty() { + out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true }); + } + // Lines produced past the paragraph end (e.g. a trailing forced break) are dropped. + out.retain(|b| b.start <= n); + out +} + +/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs). +pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec { + let n = glyphs.len(); + let mut out = Vec::new(); + let mut start = 0; + let mut line = 0; + if n == 0 { + return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }]; + } + while start < n { + let w = width(line).max(1.0); + let mut x = 0.0; + let mut spaces_shrink = 0.0; + let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen) + let mut i = start; + let mut brk: Option<(usize, bool, bool)> = None; + while i < n { + let g = &glyphs[i]; + if is_forced(g.ch) { + brk = Some((i + 1, false, true)); + break; + } + if g.is_space() && !g.no_break { + if sp.justify { + spaces_shrink += g.adv * (sp.word_desired - sp.word_min).max(0.0) / sp.word_desired.max(1e-6); + } + last_ok = Some((i, false)); + x += g.adv; + i += 1; + continue; + } + let hy = if hyph_after[i] { g.size * 0.33 } else { 0.0 }; + if x + g.adv > w + spaces_shrink && i > start { + // Overflow: break at the last opportunity, else before this glyph. + brk = Some(match last_ok { + Some((p, h)) => (p, h, false), + None => (i, false, false), + }); + break; + } + x += g.adv; + if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break { + if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') { + last_ok = Some((i + 1, false)); + } else if hyph_after[i] && x + hy <= w + spaces_shrink { + last_ok = Some((i + 1, true)); + } + } + i += 1; + } + let (end, hyphen, forced) = brk.unwrap_or((n, false, true)); + let mut vis_end = end; + while vis_end > start && glyphs[vis_end - 1].is_space() { + vis_end -= 1; + } + let mut next = end; + while next < n && glyphs[next].is_space() { + next += 1; + } + let next = next.max(start + 1).min(n.max(start + 1)); + out.push(Break { start, end: vis_end, next: next.min(n), hyphen, forced: forced || end == n }); + if next >= n { + break; + } + start = next; + line += 1; + } + // A forced break as the very last glyph leaves an empty final line (InDesign shows it). + if let Some(last) = glyphs.last() + && is_forced(last.ch) + { + out.push(Break { start: n, end: n, next: n, hyphen: false, forced: true }); + } + out +} diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs new file mode 100644 index 00000000..60629d5d --- /dev/null +++ b/crates/compose/src/cache.rs @@ -0,0 +1,93 @@ +//! Composition cache shared by the engine, the renderer and exporters. +//! +//! An entry is valid while the inputs it was composed from are the *same allocations*: the story +//! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every +//! wrapping item on the frames' spreads. Entries hold clones of those `Arc`s, so the pointers can't +//! be reused by other allocations while the entry lives. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode}; + +use crate::{ComposeOptions, ComposedStory}; + +type Key = (StoryId, Option); + +struct Entry { + sig: Vec, + _keep: (Arc, Arc, Vec>), + out: Arc, + stamp: u64, +} + +#[derive(Default)] +pub struct Cache { + map: Mutex<(HashMap, u64)>, +} + +impl Cache { + pub fn new() -> Self { + Self::default() + } + + /// The composed story, from cache or freshly composed. + pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc { + let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; + let (sig, keep_items) = signature(doc, story); + let key = (sid, page_name.map(str::to_string)); + { + let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + g.1 += 1; + let stamp = g.1; + if let Some(e) = g.0.get_mut(&key) + && e.sig == sig + { + e.stamp = stamp; + return e.out.clone(); + } + } + let out = Arc::new(crate::compose_story(doc, sid, &ComposeOptions { page_name: page_name.map(str::to_string) })); + let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let stamp = g.1; + if g.0.len() > 4096 { + g.0.retain(|_, e| stamp - e.stamp < 64); + } + g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + out + } + + pub fn clear(&self) { + self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear(); + } +} + +fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) { + let mut sig = vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len()]; + let mut keep = Vec::new(); + let mut spreads = Vec::new(); + for f in &story.frames { + let Some(loc) = doc.find(*f) else { + sig.push(0); + continue; + }; + if let Some(sp) = doc.spread(loc.spread) { + let top = &sp.items[loc.top()]; + sig.push(Arc::as_ptr(top) as usize); + keep.push(top.clone()); + // Page position matters for page-number markers. + sig.push(loc.top()); + if !spreads.contains(&loc.spread) { + spreads.push(loc.spread); + for it in &sp.items { + if it.wrap.mode != WrapMode::None { + sig.push(Arc::as_ptr(it) as usize); + keep.push(it.clone()); + } + } + sig.push(usize::MAX); + } + } + } + (sig, keep) +} diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs new file mode 100644 index 00000000..ddb6e0ca --- /dev/null +++ b/crates/compose/src/hyphen.rs @@ -0,0 +1,120 @@ +//! Dictionary-free English hyphenation (ours): breaks between syllables at vowel–consonant +//! boundaries (V-CV "ty-po", VC-CV "hap-pen"), respecting onset clusters ("gra-phy"). +//! +//! A Liang pattern engine with loadable pattern sets is planned (see plan/adr); patterns are only +//! bundled once their licence is confirmed to fit the asset policy. + +/// Hyphenation limits from the paragraph's Hyphenation settings. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub min_word: usize, + pub after_first: usize, + pub before_last: usize, + pub capitalized: bool, +} + +impl Default for Limits { + fn default() -> Self { + Limits { min_word: 5, after_first: 2, before_last: 2, capitalized: true } + } +} + +fn is_vowel(c: char) -> bool { + matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii()) +} + +fn onset_pair(a: char, b: char) -> bool { + let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase()); + matches!( + (a, b), + ('c' | 's' | 't' | 'p' | 'w' | 'g', 'h') + | ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r') + | ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l') + | ('q', 'u') + | ('s', 't' | 'p' | 'c' | 'k') + ) +} + +/// Common English suffixes that form their own syllable (break before them). +const SUFFIXES: &[&str] = &["tion", "sion", "ment", "ness", "less", "ful", "able", "ible", "ing", "ly"]; + +/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char). +pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { + let c: Vec = word.chars().collect(); + let n = c.len(); + let min_before = lim.after_first.max(1); + let min_after = lim.before_last.max(1); + if n < lim.min_word.max(2) || n < min_before + min_after || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) { + return vec![]; + } + if c.iter().all(|c| c.is_uppercase()) || (!lim.capitalized && c[0].is_uppercase()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + let lower: String = word.to_lowercase(); + let mut out = vec![]; + for i in min_before..=n - min_after { + if !c[i].is_alphabetic() || !c[i - 1].is_alphabetic() { + continue; + } + let suffix_at = SUFFIXES.iter().any(|s| { + let sl = s.chars().count(); + n - i == sl && lower.ends_with(s) && v[..i].iter().any(|x| *x) + }); + let ok = suffix_at + || if v[i - 1] && !v[i] { + (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) + } else if !v[i - 1] && !v[i] { + i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2]) + } else { + false + }; + if ok && !out.contains(&i) { + out.push(i); + } + } + out.sort(); + out +} + +/// `word` with `-` at every hyphenation point (tests and diagnostics). +pub fn hyphenate_word(word: &str, lim: &Limits) -> String { + let pts = hyphen_points(word, lim); + let mut s = String::with_capacity(word.len() + pts.len()); + for (i, ch) in word.chars().enumerate() { + if pts.contains(&i) { + s.push('-'); + } + s.push(ch); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(w: &str) -> String { + hyphenate_word(w, &Limits::default()) + } + + #[test] + fn common_words() { + assert_eq!(h("happen"), "hap-pen"); + assert_eq!(h("typography"), "ty-po-gra-phy"); + assert!(h("composition").contains("com-")); + assert_eq!(h("cat"), "cat"); + assert_eq!(h("NASA"), "NASA"); + } + + #[test] + fn limits_are_respected() { + let lim = Limits { min_word: 5, after_first: 3, before_last: 3, capitalized: false }; + for w in ["typography", "hyphenation", "justification", "Paragraph", "beautiful"] { + for p in hyphen_points(w, &lim) { + assert!(p >= 3 && p <= w.chars().count() - 3, "{w} {p}"); + } + } + assert!(hyphen_points("Paragraph", &lim).is_empty()); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs new file mode 100644 index 00000000..ac6224e8 --- /dev/null +++ b/crates/compose/src/lib.rs @@ -0,0 +1,913 @@ +//! DesignCraft text composition. +//! +//! [`compose_story`] lays a story out through its chain of threaded frames: +//! style resolution → shaping ([`shape`]) → line breaking ([`breaker`]: the paragraph composer is +//! Knuth–Plass total fit, the single-line composer is greedy) → placement in columns and frames +//! (first-baseline offset, leading, space before/after, baseline grid, text wrap, column/frame +//! breaks, vertical justification) → overset detection. +//! +//! Output coordinates are each frame's *inner* space (apply the frame item's `xf` for spread space). +#![forbid(unsafe_code)] + +pub mod breaker; +mod cache; +pub mod hyphen; +pub mod shape; + +use std::ops::Range; +use std::sync::Arc; + +use designcraft_doc::{ + Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions, + VerticalJustification, WrapMode, story, +}; +use designcraft_fonts::{FontDb, FontFace}; +use designcraft_geom::{Point, Rect}; + +use crate::breaker::{Break, Spacing}; +pub use crate::cache::Cache; +use crate::shape::{Glyph, StyleTable, SubstCtx}; + +/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]). +#[derive(Clone, Debug, PartialEq)] +pub struct RunStyle { + pub fill: String, + pub fill_tint: f32, + pub stroke: String, + pub stroke_tint: f32, + pub stroke_weight: f64, + pub underline: bool, + pub strikethrough: bool, + pub skew: f64, + pub size: f64, +} + +/// A positioned glyph. `x` is absolute in frame inner space; `y` is relative to the line baseline. +#[derive(Clone, Debug)] +pub struct PlacedGlyph { + pub face: Arc, + pub gid: u32, + pub x: f64, + pub y: f64, + pub adv: f64, + pub sx: f64, + pub sy: f64, + pub style: u32, + pub byte: usize, + pub len: usize, + /// Control characters (tabs, breaks, markers' carriers) are not drawn. + pub visible: bool, +} + +#[derive(Clone, Debug)] +pub struct Line { + pub column: u32, + /// Baseline y in frame inner space. + pub baseline: f64, + /// Line slot (column minus wrap) horizontal extent. + pub x0: f64, + pub x1: f64, + pub ascent: f64, + pub descent: f64, + pub leading: f64, + /// Story byte range (excluding the paragraph separator). + pub range: Range, + pub para: usize, + pub glyphs: Vec, + pub hyphenated: bool, + pub first_in_para: bool, + pub last_in_para: bool, + /// x after the last glyph (caret at line end). + pub end_x: f64, + /// Word-space ratio actually used vs desired (H&J violation highlighting), 1.0 = desired. + pub spacing: f64, +} + +/// A paragraph rule or shading rectangle in frame inner space. +#[derive(Clone, Debug, PartialEq)] +pub struct Deco { + pub rect: Rect, + pub color: String, + pub tint: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct FrameText { + pub frame: ItemId, + pub lines: Vec, + pub decos: Vec, + /// Byte range of the story shown in this frame. + pub range: Range, + /// Column rectangles (inner space) for drawing column guides / hit testing. + pub columns: Vec, + /// Height the text needs (for auto-sizing), from the text area top. + pub content_height: f64, +} + +#[derive(Clone, Debug, Default)] +pub struct ComposedStory { + pub story: StoryId, + pub rev: u64, + pub frames: Vec, + /// First byte that didn't fit (overset text), if any. + pub overset_at: Option, + pub styles: Vec, + pub text_len: usize, +} + +impl ComposedStory { + pub fn is_overset(&self) -> bool { + self.overset_at.is_some() + } + pub fn frame(&self, id: ItemId) -> Option<&FrameText> { + self.frames.iter().find(|f| f.frame == id) + } + /// Number of lines in all frames. + pub fn line_count(&self) -> usize { + self.frames.iter().map(|f| f.lines.len()).sum() + } +} + +/// A text-wrap exclusion in a frame's inner space. +#[derive(Clone, Debug, PartialEq)] +pub struct Exclusion { + pub rect: Rect, + pub mode: WrapMode, +} + +/// One frame of the thread, ready for composition. +#[derive(Clone, Debug)] +pub struct FrameSpec { + pub id: ItemId, + /// Text area (inner space, after inset). + pub area: Rect, + pub opts: TextFrameOptions, + pub exclusions: Vec, + pub page_name: Option, + /// Baseline grid in inner space: (first grid line y, increment). + pub grid: Option<(f64, f64)>, + /// The frame is on a left page (for towards/away-from-spine alignment). + pub left_page: bool, +} + +impl FrameSpec { + pub fn columns(&self) -> Vec { + let a = self.area; + let n = self.opts.columns.max(1); + match self.opts.columns_kind { + designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n) + .map(|i| { + let x = a.x0 + i as f64 * (self.opts.column_width + self.opts.gutter); + Rect::new(x, a.y0, x + self.opts.column_width, a.y1) + }) + .collect(), + _ => designcraft_doc::page::column_rects(a, n, self.opts.gutter), + } + } +} + +/// Composition inputs that are not in the story itself. +#[derive(Clone, Debug, Default)] +pub struct ComposeOptions { + /// Page name for page-number markers when the story is composed for a specific page (parent items). + pub page_name: Option, +} + +/// Build the frame specs of a story from the document (geometry, wrap, page names, grid). +pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { + let Some(st) = doc.story(sid) else { return vec![] }; + let mut out = Vec::with_capacity(st.frames.len()); + for &fid in &st.frames { + let Some(loc) = doc.find(fid) else { continue }; + let Some(item) = doc.item_at(&loc) else { continue }; + let Some(tf) = item.text_frame() else { continue }; + let xf = doc.parent_xf(&loc) * item.xf; + let inv = xf.inverse(); + let mut exclusions = Vec::new(); + let spread = doc.spread(loc.spread); + if !tf.options.ignore_wrap + && let Some(sp) = spread + { + for other in &sp.items { + if other.id == item.id || other.wrap.mode == WrapMode::None || other.hidden { + continue; + } + if doc.layer(other.layer).is_some_and(|l| !l.visible) { + continue; + } + let o = other.wrap.offsets; + let b = other.bounds(); + let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]); + let inner = inv.transform_rect_bbox(r); + exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode }); + } + } + let (page_name, left_page) = match (loc.spread, spread) { + (designcraft_doc::SpreadRef::Doc(si), Some(sp)) => { + let c = item.bounds().center(); + let pi = sp.page_at_x(c.x).unwrap_or(0); + let abs = doc.first_page_of_spread(si) + pi; + (Some(doc.page_name(abs)), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) + } + (designcraft_doc::SpreadRef::Parent(pi), Some(sp)) => { + let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); + let _ = pi; + (Some(prefix), false) + } + _ => (None, false), + }; + let g = &doc.settings.baseline_grid; + let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start)); + // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). + let ty = xf.translation().y; + let grid = (inc > 0.0).then_some((start - ty, inc)); + out.push(FrameSpec { id: fid, area: item.text_area(), opts: tf.options.clone(), exclusions, page_name, grid, left_page }); + } + out +} + +/// Compose a story of `doc` through its frames. +pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { + let specs = frame_specs(doc, sid); + match doc.story(sid) { + Some(st) => compose(doc, st, &specs, opts), + None => ComposedStory { story: sid, ..Default::default() }, + } +} + +const DEFAULT_TAB: f64 = 36.0; + +/// Compose `story` into `frames`. +pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions) -> ComposedStory { + let db = FontDb::global(); + let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() }; + let mut styles_tab: Vec = Vec::new(); + out.frames = frames.iter().map(|f| FrameText { frame: f.id, columns: f.columns(), ..Default::default() }).collect(); + let cols: Vec> = frames.iter().map(FrameSpec::columns).collect(); + let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, pending: 0.0 }; + let para_ranges = story.para_ranges(); + let mut list_counter: u32 = 0; + 'paras: for (pi, prange) in para_ranges.iter().enumerate() { + let pf = &story.paras[pi]; + let (pp, base_chars) = doc.styles.resolve_para(pf); + // Bullets & numbering: generated prefix (shaped as its own glyphs, mapped to the paragraph start). + let sub = SubstCtx { + page_name: opts.page_name.clone().or_else(|| frames.get(cur.fi.min(frames.len().saturating_sub(1))).and_then(|f| f.page_name.clone())), + section_marker: None, + }; + let mut table = StyleTable { styles: &mut styles_tab }; + let mut sp = shape::shape_para(db, &doc.styles, story, pi, prange.clone(), &base_chars, pp.auto_leading, &sub, &mut table); + match pp.list_type { + designcraft_doc::ListType::Numbers => { + list_counter += 1; + let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator); + prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table); + } + designcraft_doc::ListType::Bullets => { + let label = format!("{}{}", pp.bullet_char, pp.list_separator); + prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table); + } + designcraft_doc::ListType::None => list_counter = 0, + } + let glyphs = sp.glyphs; + let hyph_after = hyphenation_points(&story.text, &glyphs, &pp); + let base_size = base_chars.size; + let base_leading = match base_chars.leading { + designcraft_doc::Leading::Auto => base_size * pp.auto_leading, + designcraft_doc::Leading::Points(v) => v, + }; + let spacing = Spacing { + justify: pp.align.is_justified(), + word_min: pp.word_space_min, + word_desired: pp.word_space_desired, + word_max: pp.word_space_max, + hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight, + hyphen_limit: pp.hyph_limit, + ragged_stretch: base_size * 2.0, + }; + // Paragraph start options. + if cur.last_baseline.is_some() { + match pp.start_paragraph { + StartParagraph::NextColumn => cur.next_column(&cols), + StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(), + StartParagraph::Anywhere => {} + } + } + if cur.last_baseline.is_some() { + cur.pending += pp.space_before; + } + // Rule above / shading track the paragraph's first line. + let mut g0 = 0usize; + let mut line_no = 0usize; + let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); + let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span + loop { + if cur.fi >= frames.len() { + out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); + break 'paras; + } + let f = &frames[cur.fi]; + let col = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col = match pp.span_columns { + SpanColumns::Span(n) if cur.col == 0 => { + let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) }; + Rect::new(col.x0, col.y0, cols[cur.fi][n - 1].x1, col.y1) + } + _ => col, + }; + // Estimate slots for the breaker with the paragraph's base leading. + let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); + let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); + let width = |j: usize| -> f64 { + let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); + let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 }; + (x1 - x0 - ind).max(1.0) + }; + let rest = &glyphs[g0..]; + let rest_h = &hyph_after[g0..]; + let breaks: Vec = if pp.composer == Composer::SingleLine || has_tabs || rest.len() > 4000 { + breaker::greedy(rest, rest_h, &spacing, &width) + } else { + breaker::knuth_plass(rest, rest_h, &spacing, &width) + }; + let mut moved = false; + for (k, b) in breaks.iter().enumerate() { + let (s, e) = (g0 + b.start, g0 + b.end); + let line_glyphs = &glyphs[s..e.max(s)]; + let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); + let mut baseline = cur.next_baseline(f, col, lead, asc, &pp); + // Baseline grid. + if let Some((g_start, inc)) = f.grid + && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) + && inc > 0.0 + { + let n = ((baseline - g_start) / inc - 1e-6).ceil(); + baseline = g_start + n * inc; + } + // Wrap: push the line down until a slot exists. + let (mut x0, mut x1) = (col.x0, col.x1); + if !f.exclusions.is_empty() { + let mut tries = 0; + loop { + match free_slot(f, col, baseline - asc, baseline + desc, base_size) { + Some((a, b)) => { + x0 = a; + x1 = b; + break; + } + None => { + baseline += 1.0; + tries += 1; + if baseline > col.y1 || tries > 4000 { + break; + } + } + } + } + } + let fits = baseline + desc <= col.y1 + 0.01 || (cur.last_baseline.is_none() && baseline <= col.y1 && false); + if !fits { + // Next column / frame; re-break the rest of the paragraph there. + g0 = s; + cur.next_column(&cols); + moved = true; + let _ = k; + break; + } + let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }; + let lx0 = x0 + ind_l; + let lx1 = x1 - pp.right_indent; + let last = k + 1 == breaks.len(); + let (placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, last, b.forced && !last, f.left_page); + let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; + let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); + let range_start = if line_no == 0 { prange.start } else { range_start }; + let ft = &mut out.frames[cur.fi]; + if line_no == 0 { + first_line_rect = Some((cur.fi, baseline, asc, x0)); + } + ft.lines.push(Line { + column: cur.col as u32, + baseline, + x0, + x1, + ascent: asc, + descent: desc, + leading: lead, + range: range_start..range_end, + para: pi, + glyphs: placed, + hyphenated: b.hyphen, + first_in_para: line_no == 0, + last_in_para: last, + end_x, + spacing: ratio, + }); + cur.last_baseline = Some(baseline); + cur.last_descent = desc; + cur.pending = 0.0; + line_no += 1; + // Column / frame / page break characters. + if b.forced && e < glyphs.len() + 1 { + let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch); + match brk { + Some(story::COLUMN_BREAK) => cur.next_column(&cols), + Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => cur.next_frame(), + _ => {} + } + } + } + if !moved { + break; + } + } + // Rules and shading for the paragraph. + if let Some((fi, bl, asc, _)) = first_line_rect { + let ft = &mut out.frames[fi]; + if pp.rule_above.on { + let r = &pp.rule_above; + let y = bl - asc - r.offset; + let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0)); + ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color: r.color.clone(), tint: r.tint }); + } + if pp.shading_on { + let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect(); + if let (Some(a), Some(z)) = (lines.first(), lines.last()) { + ft.decos.insert(0, Deco { rect: Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent), color: pp.shading_color.clone(), tint: pp.shading_tint }); + } + } + } + if pp.rule_below.on + && let Some(ft) = out.frames.get_mut(cur.fi.min(frames.len().saturating_sub(1))) + && let Some(l) = ft.lines.iter().rev().find(|l| l.para == pi) + { + let r = &pp.rule_below; + let y = l.baseline + r.offset; + let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; + ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint }); + } + cur.pending += pp.space_after; + } + // Ranges, content heights and vertical justification. + for (fi, ft) in out.frames.iter_mut().enumerate() { + let f = &frames[fi]; + if let (Some(a), Some(z)) = (ft.lines.first(), ft.lines.last()) { + ft.range = a.range.start..z.range.end; + ft.content_height = z.baseline + z.descent - f.area.y0; + } else { + let p = ft_prev_end(&out.overset_at, story.text.len()); + ft.range = p..p; + } + vertical_justify(ft, f); + } + // Frame ranges for empty frames after the text: start at the end of the text shown. + let mut last_end = 0; + for ft in &mut out.frames { + if ft.lines.is_empty() { + let p = out.overset_at.unwrap_or(last_end).max(last_end); + ft.range = p..p; + } else { + last_end = ft.range.end; + } + } + out.styles = styles_tab; + out +} + +fn ft_prev_end(overset: &Option, len: usize) -> usize { + overset.unwrap_or(len) +} + +struct Cursor { + fi: usize, + col: usize, + last_baseline: Option, + last_descent: f64, + /// Space before/after waiting to be added to the next line. + pending: f64, +} + +impl Cursor { + fn next_column(&mut self, cols: &[Vec]) { + self.col += 1; + if self.fi < cols.len() && self.col >= cols[self.fi].len() { + self.col = 0; + self.fi += 1; + } + self.last_baseline = None; + self.pending = 0.0; + } + fn next_frame(&mut self) { + self.fi += 1; + self.col = 0; + self.last_baseline = None; + self.pending = 0.0; + } + /// Baseline for the next line with leading `lead` and ascent `asc`. + fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { + match self.last_baseline { + Some(b) => b + lead + self.pending, + None => { + let off = match f.opts.first_baseline { + FirstBaseline::Ascent => asc, + FirstBaseline::CapHeight => asc * 0.72, + FirstBaseline::XHeight => asc * 0.5, + FirstBaseline::Leading => lead, + FirstBaseline::Fixed => 0.0, + }; + col.y0 + off.max(f.opts.first_baseline_min) + } + } + } +} + +fn line_metrics(line: &[Glyph], all: &[Glyph], s: usize, base_leading: f64, base_size: f64, db: &FontDb, base: &designcraft_doc::CharProps) -> (f64, f64, f64) { + let _ = db; + let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; + if src.is_empty() { + let face = FontDb::global().face(&base.font_family, &base.font_style); + let k = base_size / face.upem; + return (face.ascent * k, face.descent * k, base_leading); + } + let mut asc: f64 = 0.0; + let mut desc: f64 = 0.0; + let mut lead: f64 = 0.0; + for g in src { + asc = asc.max(g.ascent + g.shift.max(0.0)); + desc = desc.max(g.descent - g.shift.min(0.0)); + lead = lead.max(g.leading); + } + (asc, desc, lead) +} + +/// Widest free horizontal interval of `col` in the band, or None if blocked. +fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { + let mut free = vec![(col.x0, col.x1)]; + for ex in &f.exclusions { + let r = ex.rect; + let overlaps_band = r.y0 < y1 && r.y1 > y0; + match ex.mode { + WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, + WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, + WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { + let mut next = Vec::new(); + for (a, b) in free { + if r.x1 <= a || r.x0 >= b { + next.push((a, b)); + continue; + } + if r.x0 > a { + next.push((a, r.x0)); + } + if r.x1 < b { + next.push((r.x1, b)); + } + } + free = next; + } + _ => {} + } + } + free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) +} + +#[allow(clippy::too_many_arguments)] +fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> { + let _ = (pp, line_no); + if f.exclusions.is_empty() { + return vec![]; + } + // Rough upper bound on lines: total advance / column width × 2. + let total: f64 = glyphs.iter().map(|g| g.adv).sum(); + let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000); + let mut v = Vec::with_capacity(n); + let mut b = first; + for _ in 0..n { + let mut tries = 0; + let slot = loop { + if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) { + break s; + } + b += 1.0; + tries += 1; + if b > col.y1 || tries > 4000 { + break (col.x0, col.x1); + } + }; + v.push(slot); + b += lead; + } + v +} + +/// Position glyphs `s..e` within `[x0, x1]`; returns (glyphs, end x, word-space ratio). +#[allow(clippy::too_many_arguments)] +fn layout_line( + glyphs: &[Glyph], + s: usize, + e: usize, + hyphen: bool, + x0: f64, + x1: f64, + tab_origin: f64, + pp: &ParaProps, + last: bool, + forced_mid: bool, + left_page: bool, +) -> (Vec, f64, f64) { + let mut line: Vec = glyphs[s..e.max(s)].to_vec(); + if hyphen && let Some(g) = line.last() { + let h = shape::hyphen_after(g); + line.push(h); + } + // Tabs: compute widths left to right. + let measure = x1 - x0; + let mut x = 0.0; + let mut i = 0; + while i < line.len() { + if line[i].ch == '\t' { + let abs = x0 + x - tab_origin; + let stop = pp.tabs.iter().find(|t| t.position > abs + 0.01).cloned(); + let (pos, align) = match &stop { + Some(t) => (t.position, t.align), + None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left), + }; + // Width of the text after this tab up to the next tab / line end. + let seg_end = line[i + 1..].iter().position(|g| g.ch == '\t').map_or(line.len(), |p| i + 1 + p); + let seg_w: f64 = line[i + 1..seg_end].iter().map(|g| g.adv).sum(); + let target = pos + tab_origin - x0; + let w = match align { + TabAlign::Left => target - x, + TabAlign::Right => target - x - seg_w, + TabAlign::Center => target - x - seg_w / 2.0, + TabAlign::Char => { + let ch = stop.as_ref().and_then(|t| t.align_on.chars().next()).unwrap_or('.'); + let before: f64 = line[i + 1..seg_end].iter().take_while(|g| g.ch != ch).map(|g| g.adv).sum(); + target - x - before + } + }; + line[i].adv = w.max(0.0); + } else if line[i].ch == story::RIGHT_INDENT_TAB { + let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); + line[i].adv = (measure - x - rest).max(0.0); + } + x += line[i].adv; + i += 1; + } + let natural: f64 = line.iter().map(|g| g.adv).sum(); + let extra = measure - natural; + let spaces: Vec = line.iter().enumerate().filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect(); + let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB); + let align = match pp.align { + Align::TowardsSpine => { + if left_page { + Align::Right + } else { + Align::Left + } + } + Align::AwayFromSpine => { + if left_page { + Align::Left + } else { + Align::Right + } + } + a => a, + }; + let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab; + let mut per_space = 0.0; + let mut offset = 0.0; + let mut ratio = 1.0; + if justify_this && !spaces.is_empty() { + per_space = extra / spaces.len() as f64; + let space_w: f64 = spaces.iter().map(|&i| line[i].adv).sum::() / spaces.len() as f64; + ratio = if space_w > 0.0 { (space_w + per_space) / space_w * pp.word_space_desired } else { 1.0 }; + } else if justify_this && spaces.is_empty() && line.len() > 1 && !last { + // Single word: Single Word Justification. + match pp.single_word_justify { + Align::FullyJustified => { + let gaps = (line.len() - 1) as f64; + let per = extra / gaps; + let mut x = x0; + let mut out = Vec::with_capacity(line.len()); + for (i, g) in line.iter().enumerate() { + out.push(place(g, x)); + x += g.adv + if i + 1 < line.len() { per } else { 0.0 }; + } + return (out, x, 1.0); + } + Align::Center => offset = extra / 2.0, + Align::Right => offset = extra, + _ => {} + } + } else { + let last_align = match align { + Align::LeftJustified => Align::Left, + Align::CenterJustified => Align::Center, + Align::RightJustified => Align::Right, + Align::FullyJustified => Align::Left, + a => a, + }; + offset = match last_align { + Align::Center => extra / 2.0, + Align::Right => extra, + _ => 0.0, + }; + // Overfull ragged lines are not shifted left of the slot. + if offset < 0.0 { + offset = 0.0; + } + } + let mut out = Vec::with_capacity(line.len()); + let mut x = x0 + offset; + for (i, g) in line.iter().enumerate() { + out.push(place(g, x)); + x += g.adv; + if per_space != 0.0 && spaces.binary_search(&i).is_ok() { + x += per_space; + if let Some(p) = out.last_mut() { + p.adv += per_space; + } + } + } + (out, x, ratio) +} + +fn place(g: &Glyph, x: f64) -> PlacedGlyph { + let visible = !(g.ch == '\t' || g.ch == '\n' || breaker::is_forced(g.ch) || g.ch == story::INDENT_HERE || g.ch == story::RIGHT_INDENT_TAB || g.ch == shape::SOFT_HYPHEN); + PlacedGlyph { face: g.face.clone(), gid: g.gid, x: x + g.dx, y: -g.shift + g.dy, adv: g.adv, sx: g.sx, sy: g.sy, style: g.style, byte: g.byte, len: g.len, visible } +} + +fn prepend_label(db: &FontDb, glyphs: &mut Vec, label: &str, at: usize, base: &designcraft_doc::CharProps, pp: &ParaProps, table: &mut StyleTable<'_>) { + // Shape the label as a tiny standalone story so it uses the paragraph's base character style. + let mut tmp = Story::new(StoryId(0)); + tmp.insert(0, label); + let styles = designcraft_doc::Styles::default(); + let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, pp.auto_leading, &SubstCtx::default(), table); + let mut pre: Vec = shaped + .glyphs + .into_iter() + .map(|mut g| { + g.byte = at; + g.len = 0; + g + }) + .collect(); + pre.append(glyphs); + *glyphs = pre; +} + +/// Mark glyphs after which a hyphen may be inserted. +fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps) -> Vec { + let mut out = vec![false; glyphs.len()]; + if !pp.hyphenate { + return out; + } + let lim = hyphen::Limits { + min_word: pp.hyph_min_word as usize, + after_first: pp.hyph_after_first as usize, + before_last: pp.hyph_before_last as usize, + capitalized: pp.hyph_capitalized, + }; + let mut i = 0; + while i < glyphs.len() { + if !glyphs[i].is_letter() || glyphs[i].len == 0 { + i += 1; + continue; + } + let mut j = i; + while j < glyphs.len() && (glyphs[j].is_letter() || glyphs[j].len == 0) && !glyphs[j].no_break { + j += 1; + } + let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len); + if b > a && b <= text.len() && text.is_char_boundary(a) && text.is_char_boundary(b) { + let word = &text[a..b]; + // Do not hyphenate the paragraph's last word unless allowed. + let is_last_word = !pp.hyph_last_word && glyphs[j..].iter().all(|g| !g.is_letter()); + if !is_last_word { + let pts = hyphen::hyphen_points(word, &lim); + for p in pts { + // char index p → byte → glyph whose cluster ends at that byte. + let byte = a + word.char_indices().nth(p).map(|(bi, _)| bi).unwrap_or(word.len()); + if let Some(k) = (i..j).find(|&k| glyphs[k].byte + glyphs[k].len == byte && glyphs[k].len > 0) { + out[k] = true; + } + } + } + } + i = j.max(i + 1); + } + out +} + +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) { + if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { + return; + } + // Per column. + let ncols = ft.columns.len().max(1) as u32; + for c in 0..ncols { + let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| l.column == c).map(|(i, _)| i).collect(); + let (Some(&a), Some(&z)) = (idx.first(), idx.last()) else { continue }; + let bottom = ft.lines[z].baseline + ft.lines[z].descent; + let space = f.area.y1 - bottom; + if space <= 0.0 { + continue; + } + let _ = a; + match f.opts.vertical_justification { + VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0), + VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space), + VerticalJustification::Justify if idx.len() > 1 => { + let per = space / (idx.len() - 1) as f64; + for (k, &i) in idx.iter().enumerate() { + ft.lines[i].baseline += per * k as f64; + } + } + _ => {} + } + } +} + +// ---------- caret and hit testing (Type tool) ---------- + +/// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent). +pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> { + let mut best: Option<(usize, &Line)> = None; + for (fi, ft) in cs.frames.iter().enumerate() { + for l in &ft.lines { + if pos >= l.range.start && pos <= l.range.end { + // Prefer the line where pos is not at its end (except the last line of a paragraph). + let at_end = pos == l.range.end && !l.last_in_para; + if best.is_none() || !at_end { + best = Some((fi, l)); + if !at_end { + break; + } + } + } + } + if best.is_some_and(|(_, l)| pos < l.range.end || l.last_in_para) { + break; + } + } + let (fi, l) = best?; + Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent)) +} + +fn caret_x(l: &Line, pos: usize) -> f64 { + for g in &l.glyphs { + if g.len > 0 && pos >= g.byte && pos < g.byte + g.len { + // Inside a multi-char cluster (ligature): interpolate. + let t = (pos - g.byte) as f64 / g.len as f64; + return g.x + g.adv * t; + } + if g.len > 0 && g.byte >= pos { + return g.x; + } + } + l.end_x +} + +/// Story byte nearest to point `p` (frame inner space) in frame `fi`. +pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option { + let ft = cs.frames.get(fi)?; + if ft.lines.is_empty() { + return Some(ft.range.start); + } + // Closest line vertically (within its column). + let l = ft + .lines + .iter() + .filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1) + .min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y))) + .or_else(|| ft.lines.first())?; + let mut best = l.range.start; + let mut prev_mid = f64::NEG_INFINITY; + for g in l.glyphs.iter().filter(|g| g.len > 0) { + let mid = g.x + g.adv / 2.0; + if p.x < mid && p.x >= prev_mid { + return Some(g.byte); + } + prev_mid = mid; + best = g.byte + g.len; + } + // After the last glyph: line end (before the paragraph separator / break). + Some(if l.last_in_para { l.range.end } else { best.min(l.range.end) }) +} + +fn line_dist(l: &Line, y: f64) -> f64 { + let top = l.baseline - l.ascent; + let bot = l.baseline + l.descent; + if y < top { + top - y + } else if y > bot { + y - bot + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs new file mode 100644 index 00000000..9f36ad02 --- /dev/null +++ b/crates/compose/src/shape.rs @@ -0,0 +1,334 @@ +//! Paragraph → styled, positioned glyphs (before line breaking). + +use std::sync::Arc; + +use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story}; +use designcraft_fonts::{Feature, FontDb, FontFace, ShapedGlyph, feature}; + +use crate::RunStyle; + +/// One glyph with every character attribute applied, in points. +#[derive(Clone, Debug)] +pub struct Glyph { + pub face: Arc, + pub gid: u32, + /// Story byte offset of the cluster and its byte length (0 for generated glyphs). + pub byte: usize, + pub len: usize, + /// First source character of the cluster (special characters keep their code). + pub ch: char, + /// Advance in points (tracking and horizontal scale applied). + pub adv: f64, + pub dx: f64, + pub dy: f64, + /// Font units → points (incl. horizontal / vertical scale). + pub sx: f64, + pub sy: f64, + /// Baseline shift (positive = up), including super/subscript. + pub shift: f64, + pub ascent: f64, + pub descent: f64, + /// The leading this character asks for (absolute, or auto = size × auto %). + pub leading: f64, + pub cap: f64, + pub xh: f64, + pub size: f64, + /// Index into the composed story's [`RunStyle`] table. + pub style: u32, + /// Unbreakable (No Break). + pub no_break: bool, +} + +impl Glyph { + pub fn is_space(&self) -> bool { + matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}' | '\u{3000}') + } + pub fn is_letter(&self) -> bool { + self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’') + } +} + +pub const SOFT_HYPHEN: char = '\u{AD}'; + +/// Shaped paragraph. +pub struct ShapedPara { + pub glyphs: Vec, + /// Byte range of the paragraph text (without its `\n`). + pub range: std::ops::Range, +} + +/// Context for substitutions (page number markers). +#[derive(Clone, Debug, Default)] +pub struct SubstCtx { + pub page_name: Option, + pub section_marker: Option, +} + +pub(crate) struct StyleTable<'a> { + pub styles: &'a mut Vec, +} + +impl StyleTable<'_> { + fn intern(&mut self, p: &CharProps) -> u32 { + let rs = RunStyle { + fill: p.fill.clone(), + fill_tint: p.fill_tint, + stroke: p.stroke.clone(), + stroke_tint: p.stroke_tint, + stroke_weight: p.stroke_weight, + underline: p.underline, + strikethrough: p.strikethrough, + skew: p.skew, + size: p.size, + }; + if let Some(i) = self.styles.iter().rposition(|s| *s == rs) { + return i as u32; + } + self.styles.push(rs); + (self.styles.len() - 1) as u32 + } +} + +/// Resolve and shape one paragraph. +pub(crate) fn shape_para( + db: &FontDb, + styles: &Styles, + story: &Story, + pi: usize, + range: std::ops::Range, + para_chars: &CharProps, + auto_leading: f64, + sub: &SubstCtx, + table: &mut StyleTable<'_>, +) -> ShapedPara { + let _ = pi; + let mut glyphs = Vec::with_capacity(range.len()); + for (rr, fmt) in story.runs() { + let a = rr.start.max(range.start); + let b = rr.end.min(range.end); + if a >= b { + continue; + } + let props = styles.resolve_char(para_chars, fmt); + let style = table.intern(&props); + shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs); + } + ShapedPara { glyphs, range } +} + +fn features_for(p: &CharProps) -> Vec { + let mut v = Vec::new(); + if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) { + v.extend(feature("-kern")); + } + if !p.ligatures || p.tracking.abs() > 1e-9 { + v.extend(feature("-liga")); + v.extend(feature("-clig")); + } + if p.capitalization == Capitalization::AllCaps { + v.extend(feature("case")); + } + if matches!(p.capitalization, Capitalization::SmallCaps | Capitalization::OpenTypeAllSmallCaps) { + v.extend(feature("smcp")); + } + if p.capitalization == Capitalization::OpenTypeAllSmallCaps { + v.extend(feature("c2sc")); + } + match p.position { + Position::OtSuperscript => v.extend(feature("sups")), + Position::OtSubscript => v.extend(feature("subs")), + Position::OtNumerator => v.extend(feature("numr")), + Position::OtDenominator => v.extend(feature("dnom")), + _ => {} + } + for f in &p.otf_features { + v.extend(feature(f)); + } + v +} + +fn is_mark(c: char) -> bool { + matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F) +} + +#[allow(clippy::too_many_arguments)] +fn shape_run(db: &FontDb, text: &str, range: std::ops::Range, p: &CharProps, auto_leading: f64, style: u32, sub: &SubstCtx, out: &mut Vec) { + let primary = db.face(&p.font_family, &p.font_style); + // Split into segments: font coverage changes and special characters (markers, tabs, breaks). + let mut seg_start = range.start; + let mut seg_face = primary.clone(); + let flush = |a: usize, b: usize, face: &Arc, out: &mut Vec| { + if a < b { + shape_segment(db, text, a..b, None, p, face, auto_leading, style, out); + } + }; + for (i, c) in text[range.clone()].char_indices() { + let i = range.start + i; + let special = matches!( + c, + '\t' | story::FORCED_LINE_BREAK + | story::PAGE_NUMBER + | story::NEXT_PAGE_NUMBER + | story::PREV_PAGE_NUMBER + | story::SECTION_MARKER + | story::COLUMN_BREAK + | story::FRAME_BREAK + | story::PAGE_BREAK + | story::INDENT_HERE + | story::RIGHT_INDENT_TAB + ); + if special { + flush(seg_start, i, &seg_face, out); + seg_start = i + c.len_utf8(); + match c { + story::PAGE_NUMBER | story::NEXT_PAGE_NUMBER | story::PREV_PAGE_NUMBER | story::SECTION_MARKER => { + let s = if c == story::SECTION_MARKER { + sub.section_marker.clone().unwrap_or_else(|| "Section".into()) + } else { + sub.page_name.clone().unwrap_or_else(|| "#".into()) + }; + shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out); + } + _ => { + // Zero-width control glyph carrying metrics (tabs get their width at line layout). + let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + if c == '\t' || c == story::RIGHT_INDENT_TAB { + g.adv = 0.0; + } + out.push(g); + } + } + continue; + } + let covered = c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN || primary.covers(c); + let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone()) }; + if face.id() != seg_face.id() { + flush(seg_start, i, &seg_face, out); + seg_start = i; + seg_face = face; + } + } + flush(seg_start, range.end, &seg_face, out); +} + +fn metrics(face: &FontFace, p: &CharProps, auto_leading: f64) -> (f64, f64, f64, f64, f64, f64, f64, f64) { + let (size, shift) = effective_size(p); + let k = size / face.upem; + let vs = p.v_scale; + let leading = match p.leading { + Leading::Auto => p.size * auto_leading, + Leading::Points(v) => v, + }; + (size, k, face.ascent * k * vs, face.descent * k * vs, leading, face.cap_height * k * vs, face.x_height * k * vs, shift) +} + +/// Super/subscript use 58.3% size and ±33.3% / −10% shift (InDesign's defaults). +fn effective_size(p: &CharProps) -> (f64, f64) { + match p.position { + Position::Superscript => (p.size * 0.583, p.baseline_shift + p.size * 0.333), + Position::Subscript => (p.size * 0.583, p.baseline_shift - p.size * 0.333 * 0.3), + _ => (p.size, p.baseline_shift), + } +} + +fn control_glyph(face: &Arc, p: &CharProps, auto_leading: f64, style: u32, byte: usize, ch: char) -> Glyph { + let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading); + Glyph { + face: face.clone(), + gid: face.glyph_for(' '), + byte, + len: ch.len_utf8(), + ch, + adv: 0.0, + dx: 0.0, + dy: 0.0, + sx: k * p.h_scale, + sy: k * p.v_scale, + shift, + ascent, + descent, + leading, + cap, + xh, + size, + style, + no_break: p.no_break, + } +} + +#[allow(clippy::too_many_arguments)] +fn shape_segment( + db: &FontDb, + text: &str, + range: std::ops::Range, + replacement: Option<&str>, + p: &CharProps, + face: &Arc, + auto_leading: f64, + style: u32, + out: &mut Vec, +) { + let _ = db; + let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading); + let hs = p.h_scale; + let tracking = p.tracking / 1000.0 * p.size; + let manual = if let Kerning::Manual(v) = p.kerning { v / 1000.0 * p.size } else { 0.0 }; + let src = replacement.unwrap_or(&text[range.clone()]); + let caps = p.capitalization == Capitalization::AllCaps; + let feats = features_for(p); + let shaped: Vec = designcraft_fonts::shape(face, src, &feats, |c| if caps { c.to_uppercase().next().unwrap_or(c) } else { c }); + let n = shaped.len(); + for (gi, sg) in shaped.iter().enumerate() { + let (byte, len, ch) = if replacement.is_some() { + (range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' ')) + } else { + let cl = range.start + sg.cluster; + let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end); + (cl, end.saturating_sub(cl), text[cl..].chars().next().unwrap_or(' ')) + }; + let last_in_cluster = gi + 1 == n || shaped[gi + 1].cluster != sg.cluster; + let mut adv = sg.x_advance as f64 * k * hs; + if ch == SOFT_HYPHEN { + adv = 0.0; + } else if last_in_cluster { + adv += tracking + manual; + } + // Only the first glyph of a cluster owns the bytes (so ranges partition the text). + let first_in_cluster = gi == 0 || shaped[gi - 1].cluster != sg.cluster; + out.push(Glyph { + face: face.clone(), + gid: sg.gid, + byte, + len: if first_in_cluster || replacement.is_some() { len } else { 0 }, + ch, + adv, + dx: sg.x_offset as f64 * k * hs, + dy: -(sg.y_offset as f64) * k * p.v_scale, + sx: k * hs, + sy: k * p.v_scale, + shift, + ascent, + descent, + leading, + cap, + xh, + size, + style, + no_break: p.no_break, + }); + } +} + +/// A hyphen glyph in the face/size of `g`, placed after it (zero source length). +pub(crate) fn hyphen_after(g: &Glyph) -> Glyph { + let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']); + let mut h = g.clone(); + h.gid = gid; + h.adv = g.face.advance(gid) * g.sx; + h.dx = 0.0; + h.dy = 0.0; + h.byte = g.byte + g.len; + h.len = 0; + h.ch = '-'; + h +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs new file mode 100644 index 00000000..05d7dc06 --- /dev/null +++ b/crates/compose/src/tests.rs @@ -0,0 +1,172 @@ +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, SpreadRef}; +use designcraft_geom::Rect; + +use super::*; + +const LOREM: &str = "Typography is the art and technique of arranging type to make written language legible, readable and appealing when displayed. \ +The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing, and adjusting the space between pairs of letters."; + +fn doc_with(text: &str, rect: Rect, para: ParaAttrs) -> (Document, StoryId, ItemId) { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, ParaFormat { para, ..Default::default() }).unwrap(); + (d, sid, fid) +} + +fn all_lines(cs: &ComposedStory) -> Vec<&Line> { + cs.frames.iter().flat_map(|f| f.lines.iter()).collect() +} + +#[test] +fn composes_simple_paragraph() { + let (d, sid, _) = doc_with(LOREM, Rect::new(36.0, 36.0, 300.0, 700.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let lines = all_lines(&cs); + assert!(lines.len() >= 5, "{}", lines.len()); + // Baselines increase by the auto leading (12 × 120% = 14.4). + for w in lines.windows(2) { + assert!((w[1].baseline - w[0].baseline - 14.4).abs() < 1e-6); + } + // Lines fit their measure. + for l in &lines { + assert!(l.end_x <= l.x1 + 0.5, "line overflows: {} > {}", l.end_x, l.x1); + } +} + +#[test] +fn line_ranges_partition_the_story() { + let text = format!("{LOREM}\n\nSecond paragraph here.\n{LOREM}"); + let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 200.0, 2000.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let mut pos = 0; + for l in &lines { + // Each line starts where the previous ended (or after a paragraph separator). + assert!(l.range.start == pos || l.range.start == pos + 1, "gap at {pos}: {:?}", l.range); + pos = l.range.end; + } + assert_eq!(pos, text.len()); + assert_eq!(lines.iter().filter(|l| l.first_in_para).count(), 4); +} + +#[test] +fn justified_lines_fill_the_measure() { + let (d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 220.0, 2000.0), ParaAttrs { align: Some(Align::LeftJustified), ..Default::default() }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + for l in &lines[..lines.len() - 1] { + assert!((l.end_x - l.x1).abs() < 0.6, "justified line ends at {} not {}", l.end_x, l.x1); + } + let last = lines.last().unwrap(); + assert!(last.end_x < last.x1 - 1.0, "last line is ragged"); +} + +#[test] +fn small_frame_oversets() { + let (d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 40.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(cs.is_overset()); + let shown = cs.frames[0].range.clone(); + assert_eq!(cs.overset_at.unwrap(), shown.end.max(cs.overset_at.unwrap()).min(cs.overset_at.unwrap())); + assert!(cs.frames[0].lines.len() <= 3); +} + +#[test] +fn text_flows_through_threaded_frames() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let long = [LOREM; 4].join("\n"); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 120.0), lid, &long, ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(250.0, 0.0, 450.0, 2000.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert!(!cs.frames[0].lines.is_empty() && !cs.frames[1].lines.is_empty()); + assert_eq!(cs.frames[0].range.end, cs.frames[1].range.start.min(cs.frames[0].range.end).max(cs.frames[0].range.end)); + // The second frame's lines start at its top. + assert!(cs.frames[1].lines[0].baseline < 20.0); +} + +#[test] +fn columns_fill_left_to_right() { + let (mut d, sid, fid) = doc_with(&[LOREM; 3].join(" "), Rect::new(0.0, 0.0, 400.0, 150.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.iter().any(|l| l.column == 1)); + let c1 = lines.iter().find(|l| l.column == 1).unwrap(); + assert!(c1.x0 > 200.0); +} + +#[test] +fn centered_text_is_centered() { + let (d, sid, _) = doc_with("Hello", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs { align: Some(Align::Center), ..Default::default() }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let left = l.glyphs[0].x - l.x0; + let right = l.x1 - l.end_x; + assert!((left - right).abs() < 0.5, "{left} vs {right}"); +} + +#[test] +fn caret_and_hit_roundtrip() { + let (d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 1000.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for pos in [0, 5, 40, 100, LOREM.len()] { + let (fi, x, b, _, _) = caret(&cs, pos).unwrap(); + let back = hit(&cs, fi, Point::new(x + 0.1, b - 2.0)).unwrap(); + assert!((back as i64 - pos as i64).abs() <= 1, "{pos} -> {back}"); + } +} + +#[test] +fn empty_story_has_one_line() { + let (d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(cs.line_count(), 1); + assert!(caret(&cs, 0).is_some()); +} + +#[test] +fn wrap_pushes_text_aside() { + let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0))); + it.wrap.mode = WrapMode::BoundingBox; + it.wrap.offsets = [0.0, 0.0, 6.0, 6.0]; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = &cs.frames[0].lines[0]; + assert!(first.x0 >= 126.0 - 1e-6, "{}", first.x0); + let below = cs.frames[0].lines.iter().find(|l| l.baseline - l.ascent > 66.0).unwrap(); + assert!(below.x0 < 1.0); +} + +#[test] +fn paragraph_composer_is_no_worse_than_greedy() { + // Sum of squared slack over lines (excluding last) should not exceed the greedy result. + let measure = 180.0; + let mk = |c: designcraft_doc::Composer| { + let (d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, measure, 4000.0), ParaAttrs { composer: Some(c), hyphenate: Some(false), ..Default::default() }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let n = lines.len(); + lines[..n - 1].iter().map(|l| (l.x1 - l.end_x).powi(2)).sum::() + }; + let kp = mk(designcraft_doc::Composer::Paragraph); + let gr = mk(designcraft_doc::Composer::SingleLine); + assert!(kp <= gr * 1.05 + 1.0, "kp {kp} greedy {gr}"); +} + +#[test] +fn tabs_align_to_stops() { + let mut pa = ParaAttrs::default(); + pa.tabs = Some(vec![designcraft_doc::TabStop { position: 100.0, align: TabAlign::Right, leader: String::new(), align_on: String::new() }]); + let (d, sid, _) = doc_with("Name\t42", Rect::new(0.0, 0.0, 300.0, 100.0), pa); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + assert!((l.end_x - 100.0).abs() < 0.5, "{}", l.end_x); +} diff --git a/crates/doc/proptest-regressions/story.txt b/crates/doc/proptest-regressions/story.txt new file mode 100644 index 00000000..c922cc41 --- /dev/null +++ b/crates/doc/proptest-regressions/story.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 3e642ee9cd6b3dfe88454002147ccd4c5d072f080b54f02b3e28f85dccbdf9cb # shrinks to ops = [Ins(0, ""), Ins(0, "éé"), Ins(21, "é"), Fmt(21, 1, 0)] diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs new file mode 100644 index 00000000..846ea25e --- /dev/null +++ b/crates/doc/src/build.rs @@ -0,0 +1,498 @@ +//! Creating documents (File → New → Document), presets, and page/spread structure edits +//! (insert/delete/move pages, repagination into spreads, parents). + +use std::collections::BTreeMap; +use std::sync::Arc; + +use designcraft_geom::{Affine, Unit, units}; +use serde::{Deserialize, Serialize}; + +use crate::ids::{ItemId, LayerId, PageId, SpreadId}; +use crate::item::Item; +use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread}; +use crate::styles::Styles; +use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, Result}; + +/// A New Document preset. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Preset { + pub name: &'static str, + pub intent: Intent, + pub width: f64, + pub height: f64, + pub units: Unit, +} + +const MM: f64 = units::PT_PER_MM; + +pub const PRESETS: &[Preset] = &[ + Preset { name: "Letter", intent: Intent::Print, width: 612.0, height: 792.0, units: Unit::Picas }, + Preset { name: "Legal", intent: Intent::Print, width: 612.0, height: 1008.0, units: Unit::Picas }, + Preset { name: "Tabloid", intent: Intent::Print, width: 792.0, height: 1224.0, units: Unit::Picas }, + Preset { name: "Letter - Half", intent: Intent::Print, width: 396.0, height: 612.0, units: Unit::Picas }, + Preset { name: "A3", intent: Intent::Print, width: 297.0 * MM, height: 420.0 * MM, units: Unit::Millimeters }, + Preset { name: "A4", intent: Intent::Print, width: 210.0 * MM, height: 297.0 * MM, units: Unit::Millimeters }, + Preset { name: "A5", intent: Intent::Print, width: 148.0 * MM, height: 210.0 * MM, units: Unit::Millimeters }, + Preset { name: "B5", intent: Intent::Print, width: 176.0 * MM, height: 250.0 * MM, units: Unit::Millimeters }, + Preset { name: "Business Card", intent: Intent::Print, width: 252.0, height: 144.0, units: Unit::Picas }, + Preset { name: "Postcard", intent: Intent::Print, width: 432.0, height: 288.0, units: Unit::Picas }, + Preset { name: "Web 1920 × 1080", intent: Intent::Web, width: 1920.0, height: 1080.0, units: Unit::Pixels }, + Preset { name: "Web 1366 × 768", intent: Intent::Web, width: 1366.0, height: 768.0, units: Unit::Pixels }, + Preset { name: "Web 1024 × 768", intent: Intent::Web, width: 1024.0, height: 768.0, units: Unit::Pixels }, + Preset { name: "Mobile 1080 × 1920", intent: Intent::Mobile, width: 1080.0, height: 1920.0, units: Unit::Pixels }, + Preset { name: "Mobile 390 × 844", intent: Intent::Mobile, width: 390.0, height: 844.0, units: Unit::Pixels }, + Preset { name: "Tablet 1024 × 1366", intent: Intent::Mobile, width: 1024.0, height: 1366.0, units: Unit::Pixels }, +]; + +/// File → New → Document parameters. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NewDocument { + pub title: String, + pub intent: Intent, + pub width: f64, + pub height: f64, + pub pages: usize, + pub start_page: u32, + pub facing_pages: bool, + pub primary_text_frame: bool, + pub columns: u32, + pub gutter: f64, + pub margins: Margins, + pub bleed: [f64; 4], + pub slug: [f64; 4], + pub units: Unit, +} + +impl Default for NewDocument { + fn default() -> Self { + NewDocument { + title: "Untitled-1".into(), + intent: Intent::Print, + width: 612.0, + height: 792.0, + pages: 1, + start_page: 1, + facing_pages: true, + primary_text_frame: false, + columns: 1, + gutter: 12.0, + margins: Margins::uniform(36.0), + bleed: [0.0; 4], + slug: [0.0; 4], + units: Unit::Picas, + } + } +} + +impl NewDocument { + pub fn from_preset(name: &str) -> Option { + let p = PRESETS.iter().find(|p| p.name.eq_ignore_ascii_case(name))?; + let web = p.intent != Intent::Print; + Some(NewDocument { + intent: p.intent, + width: p.width, + height: p.height, + units: p.units, + facing_pages: !web, + margins: if p.units == Unit::Millimeters { Margins::uniform(12.7 * MM) } else { Margins::uniform(36.0) }, + ..Default::default() + }) + } +} + +impl Document { + /// Build a new document. + pub fn new(nd: &NewDocument) -> Document { + let settings = DocSettings { + intent: nd.intent, + page_width: nd.width, + page_height: nd.height, + facing_pages: nd.facing_pages, + primary_text_frame: nd.primary_text_frame, + bleed: nd.bleed, + slug: nd.slug, + horizontal_units: nd.units, + vertical_units: nd.units, + ..Default::default() + }; + let mut d = Document { + title: nd.title.clone(), + settings, + spreads: vec![], + parents: vec![], + layers: vec![], + stories: BTreeMap::new(), + styles: Arc::new(Styles::default()), + swatches: designcraft_color::default_swatches(), + sections: vec![Section { + start: 0, + start_number: Some(nd.start_page.max(1)), + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }], + assets: BTreeMap::new(), + next_id: 0, + }; + let lid = LayerId(d.alloc()); + d.layers.push(Layer { + id: lid, + name: "Layer 1".into(), + color: LAYER_COLORS[0].1, + visible: true, + locked: false, + printable: true, + show_guides: true, + suppress_wrap_when_hidden: false, + }); + // A-Parent. + let parent_id = d.add_parent("A", "Parent", nd.columns, nd.gutter, nd.margins); + // Document pages. + let pages: Vec = (0..nd.pages.max(1)).map(|_| d.make_page(Some(parent_id), nd.columns, nd.gutter, nd.margins)).collect(); + d.spreads = vec![Arc::new(Spread { id: SpreadId(d.alloc()), pages, items: vec![], parent: None, allow_shuffle: true })]; + d.repaginate(); + d + } + + fn make_page(&mut self, parent: Option, columns: u32, gutter: f64, margins: Margins) -> Page { + Page { + id: PageId(self.alloc()), + width: self.settings.page_width, + height: self.settings.page_height, + x: 0.0, + margins, + columns: Columns { count: columns.max(1), gutter, positions: None }, + parent, + side: PageSide::Single, + overridden: vec![], + guides: vec![], + show_parent_items: true, + } + } + + /// Add a parent spread (`prefix`-`name`) and return its id. + pub fn add_parent(&mut self, prefix: &str, name: &str, columns: u32, gutter: f64, margins: Margins) -> SpreadId { + let n = if self.settings.facing_pages { 2 } else { 1 }; + let mut pages: Vec = (0..n).map(|_| self.make_page(None, columns, gutter, margins)).collect(); + if n == 2 { + pages[0].side = PageSide::Left; + pages[1].side = PageSide::Right; + } + let id = SpreadId(self.alloc()); + let mut sp = Spread { id, pages, items: vec![], parent: Some(ParentInfo { prefix: prefix.into(), name: name.into(), based_on: None }), allow_shuffle: true }; + sp.relayout(); + self.parents.push(Arc::new(sp)); + id + } + + pub fn parent_index(&self, id: SpreadId) -> Option { + self.parents.iter().position(|p| p.id == id) + } + + /// The next free parent prefix (A, B, C…). + pub fn next_parent_prefix(&self) -> String { + for c in 'A'..='Z' { + let s = c.to_string(); + if !self.parents.iter().any(|p| p.parent.as_ref().is_some_and(|i| i.prefix == s)) { + return s; + } + } + format!("P{}", self.parents.len() + 1) + } + + /// Redistribute document pages into spreads (facing: page 1 alone on the right, then pairs). + /// Items move with the page their centre is on. Spreads with `allow_shuffle == false` are kept. + pub fn repaginate(&mut self) { + // Collect pages with their items (items in page-relative coordinates). + let mut pages: Vec<(Page, Vec>)> = Vec::new(); + let mut kept: Vec<(usize, Arc)> = Vec::new(); + for sp in self.spreads.drain(..) { + if !sp.allow_shuffle && sp.pages.len() > 1 { + kept.push((pages.len(), sp)); + continue; + } + let mut sp = Arc::unwrap_or_clone(sp); + let mut per: Vec>> = vec![Vec::new(); sp.pages.len()]; + for it in std::mem::take(&mut sp.items) { + let cx = it.bounds().center().x; + let pi = sp.page_at_x(cx).unwrap_or(0); + let dx = sp.pages[pi].x; + let mut it = Arc::unwrap_or_clone(it); + it.xf = Affine::translate((-dx, 0.0)) * it.xf; + per[pi].push(Arc::new(it)); + } + for (p, items) in sp.pages.into_iter().zip(per) { + pages.push((p, items)); + } + } + let facing = self.settings.facing_pages; + let mut spreads: Vec = Vec::new(); + let mut cur: Vec<(Page, Vec>)> = Vec::new(); + let total = pages.len(); + let flush = |cur: &mut Vec<(Page, Vec>)>, spreads: &mut Vec, id: SpreadId| { + if cur.is_empty() { + return; + } + let mut sp = Spread { id, pages: vec![], items: vec![], parent: None, allow_shuffle: true }; + let mut x = 0.0; + let n = cur.len(); + for (i, (mut p, items)) in cur.drain(..).enumerate() { + p.side = if !facing { + PageSide::Single + } else if n == 1 { + // Lone page: right if odd page number position, decided by caller via side preset. + p.side + } else if i == 0 { + PageSide::Left + } else { + PageSide::Right + }; + p.x = x; + for it in items { + let mut it = Arc::unwrap_or_clone(it); + it.xf = Affine::translate((x, 0.0)) * it.xf; + sp.items.push(Arc::new(it)); + } + x += p.width; + sp.pages.push(p); + } + spreads.push(sp); + }; + for (i, (mut p, items)) in pages.into_iter().enumerate() { + if facing { + // Page i (0-based) is a right page when i is even (page 1, 3, 5 …). + p.side = if i % 2 == 0 { PageSide::Right } else { PageSide::Left }; + if p.side == PageSide::Left && !cur.is_empty() { + let id = SpreadId(self.alloc()); + flush(&mut cur, &mut spreads, id); + } + cur.push((p, items)); + if cur.len() == 2 || i + 1 == total { + let id = SpreadId(self.alloc()); + flush(&mut cur, &mut spreads, id); + } + } else { + cur.push((p, items)); + let id = SpreadId(self.alloc()); + flush(&mut cur, &mut spreads, id); + } + } + let mut out: Vec> = spreads.into_iter().map(Arc::new).collect(); + // Re-insert kept (non-shuffling) spreads at roughly their old page positions. + for (page_pos, sp) in kept.into_iter().rev() { + let mut n = 0; + let mut at = out.len(); + for (i, s) in out.iter().enumerate() { + if n >= page_pos { + at = i; + break; + } + n += s.pages.len(); + } + out.insert(at, sp); + } + self.spreads = out; + } + + /// Insert `count` pages after absolute page `after` (None = at the start), using `parent`. + pub fn insert_pages(&mut self, after: Option, count: usize, parent: Option) -> Result> { + if count == 0 { + return Ok(vec![]); + } + let n = self.page_count(); + let at = after.map_or(0, |a| (a + 1).min(n)); + let template = self.page(at.saturating_sub(1).min(n.saturating_sub(1))).cloned(); + let (cols, gutter, margins) = + template.as_ref().map(|t| (t.columns.count, t.columns.gutter, t.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0))); + let mut ids = Vec::new(); + let new_pages: Vec = (0..count) + .map(|_| { + let mut p = self.make_page(parent, cols, gutter, margins); + if let Some(t) = &template { + p.width = t.width; + p.height = t.height; + } + ids.push(p.id); + p + }) + .collect(); + // Splice into the spread holding page `at` (or append a new spread), then repaginate. + match self.page_loc(at) { + Some((si, pi)) => { + let sp = Arc::make_mut(&mut self.spreads[si]); + let x = sp.pages[pi].x; + let w: f64 = new_pages.iter().map(|p| p.width).sum(); + // Shift items right of the insertion point. + for it in &mut sp.items { + if it.bounds().center().x >= x { + let it = Arc::make_mut(it); + it.xf = Affine::translate((w, 0.0)) * it.xf; + } + } + for (k, p) in new_pages.into_iter().enumerate() { + sp.pages.insert(pi + k, p); + } + sp.relayout(); + } + None => { + let id = SpreadId(self.alloc()); + let mut sp = Spread { id, pages: new_pages, items: vec![], parent: None, allow_shuffle: true }; + sp.relayout(); + self.spreads.push(Arc::new(sp)); + } + } + self.repaginate(); + Ok(ids) + } + + /// Delete absolute pages (items whose centre is on a deleted page are deleted too). + pub fn delete_pages(&mut self, pages: &[usize]) -> Result<()> { + if pages.len() >= self.page_count() { + return Err(DocError::Invalid("a document needs at least one page".into())); + } + let mut doomed: Vec<(usize, usize)> = pages.iter().filter_map(|p| self.page_loc(*p)).collect(); + doomed.sort(); + doomed.dedup(); + let mut remove_items: Vec = Vec::new(); + for &(si, pi) in &doomed { + let sp = &self.spreads[si]; + for it in &sp.items { + if sp.page_at_x(it.bounds().center().x) == Some(pi) { + remove_items.push(it.id); + } + } + } + for id in remove_items { + let _ = self.remove_item(id); + } + for &(si, pi) in doomed.iter().rev() { + let sp = Arc::make_mut(&mut self.spreads[si]); + let w = sp.pages[pi].width; + let x = sp.pages[pi].x; + sp.pages.remove(pi); + for it in &mut sp.items { + if it.bounds().center().x >= x + w { + let it = Arc::make_mut(it); + it.xf = Affine::translate((-w, 0.0)) * it.xf; + } + } + sp.relayout(); + } + self.spreads.retain(|s| !s.pages.is_empty()); + self.repaginate(); + Ok(()) + } + + /// Move absolute page `from` so it ends up at index `to` (items travel with it). + pub fn move_page(&mut self, from: usize, to: usize) -> Result<()> { + let n = self.page_count(); + if from >= n || to >= n { + return Err(DocError::NoPage(from.max(to))); + } + if from == to { + return Ok(()); + } + // Flatten to single-page spreads, reorder, repaginate. + let facing = self.settings.facing_pages; + self.settings.facing_pages = false; + self.repaginate(); + let sp = self.spreads.remove(from); + self.spreads.insert(to, sp); + self.settings.facing_pages = facing; + self.repaginate(); + Ok(()) + } + + /// Apply a parent (None = [None]) to absolute pages. + pub fn apply_parent(&mut self, pages: &[usize], parent: Option) -> Result<()> { + if let Some(p) = parent + && self.parent_index(p).is_none() + { + return Err(DocError::Invalid(format!("no parent {p}"))); + } + for &abs in pages { + let (si, pi) = self.page_loc(abs).ok_or(DocError::NoPage(abs))?; + Arc::make_mut(&mut self.spreads[si]).pages[pi].parent = parent; + } + Ok(()) + } + + /// Parent page (spread index, page index) to show behind document page `abs`. + pub fn parent_page_for(&self, abs: usize) -> Option<(usize, usize)> { + let page = self.page(abs)?; + let pi = self.parent_index(page.parent?)?; + let ps = &self.parents[pi]; + let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len() - 1 }; + Some((pi, idx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_facing_document_layout() { + let d = Document::new(&NewDocument { pages: 4, ..Default::default() }); + assert_eq!(d.page_count(), 4); + // [1] [2 3] [4] + assert_eq!(d.spreads.iter().map(|s| s.pages.len()).collect::>(), vec![1, 2, 1]); + assert_eq!(d.spreads[0].pages[0].side, PageSide::Right); + assert_eq!(d.spreads[1].pages[0].side, PageSide::Left); + assert_eq!(d.spreads[1].pages[1].x, 612.0); + assert_eq!(d.parents.len(), 1); + assert_eq!(d.parents[0].pages.len(), 2); + d.check().unwrap(); + } + + #[test] + fn non_facing_one_page_per_spread() { + let d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() }); + assert_eq!(d.spreads.len(), 3); + assert!(d.spreads.iter().all(|s| s.pages.len() == 1 && s.pages[0].side == PageSide::Single)); + } + + #[test] + fn insert_and_delete_pages_reshuffle() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + d.insert_pages(Some(0), 3, d.parents.first().map(|p| p.id)).unwrap(); + assert_eq!(d.page_count(), 4); + assert_eq!(d.spreads.len(), 3); + d.delete_pages(&[1, 2]).unwrap(); + assert_eq!(d.page_count(), 2); + assert_eq!(d.spreads.iter().map(|s| s.pages.len()).collect::>(), vec![1, 1]); + assert!(d.delete_pages(&[0, 1]).is_err()); + } + + #[test] + fn items_travel_with_pages() { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + // Put a frame on page 3 (spread 1, page index 1). + let lid = d.default_layer(); + let r = designcraft_geom::Rect::new(612.0 + 100.0, 100.0, 612.0 + 200.0, 200.0); + let (id, _) = d.add_text_frame(crate::SpreadRef::Doc(1), r, lid, "hello", Default::default()).unwrap(); + d.move_page(2, 0).unwrap(); + // The frame's page is now page 1 (spread 0, single right page at x=0). + let loc = d.find(id).unwrap(); + assert_eq!(loc.spread, crate::SpreadRef::Doc(0)); + let b = d.item(id).unwrap().bounds(); + assert!((b.x0 - 100.0).abs() < 1e-9, "{b:?}"); + d.check().unwrap(); + } + + #[test] + fn presets_and_parents() { + let nd = NewDocument::from_preset("A4").unwrap(); + assert!((nd.width - 595.2756).abs() < 1e-3); + let mut d = Document::new(&nd); + assert_eq!(d.next_parent_prefix(), "B"); + let b = d.add_parent("B", "Parent", 2, 12.0, Margins::uniform(36.0)); + d.apply_parent(&[0], Some(b)).unwrap(); + assert_eq!(d.parent_page_for(0), Some((1, 1))); + d.apply_parent(&[0], None).unwrap(); + assert_eq!(d.parent_page_for(0), None); + } +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs new file mode 100644 index 00000000..95f685ee --- /dev/null +++ b/crates/doc/src/edit.rs @@ -0,0 +1,371 @@ +//! Locating and editing items, threading, and story management. + +use std::sync::Arc; + +use designcraft_geom::{Point, Rect, shapes}; +use serde::{Deserialize, Serialize}; + +use crate::ids::{ItemId, LayerId, StoryId}; +use crate::item::{Content, Item, Shape, TextFrame, TextFrameOptions}; +use crate::page::Spread; +use crate::story::{CharRun, ParaFormat, Story}; +use crate::{DocError, Document, Result}; + +/// Which spread list an item lives in. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "index")] +pub enum SpreadRef { + Doc(usize), + Parent(usize), +} + +/// Indices from `spread.items` down through nested groups. +pub type ItemPath = Vec; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ItemLoc { + pub spread: SpreadRef, + pub path: ItemPath, +} + +impl ItemLoc { + /// Top-level index in the spread. + pub fn top(&self) -> usize { + self.path[0] + } +} + +fn find_in(items: &[Arc], id: ItemId, path: &mut ItemPath) -> bool { + for (i, it) in items.iter().enumerate() { + path.push(i); + if it.id == id || find_in(it.children(), id, path) { + return true; + } + path.pop(); + } + false +} + +impl Document { + pub fn spread(&self, r: SpreadRef) -> Option<&Spread> { + match r { + SpreadRef::Doc(i) => self.spreads.get(i).map(|s| &**s), + SpreadRef::Parent(i) => self.parents.get(i).map(|s| &**s), + } + } + + pub fn spread_mut(&mut self, r: SpreadRef) -> Option<&mut Spread> { + match r { + SpreadRef::Doc(i) => self.spreads.get_mut(i).map(Arc::make_mut), + SpreadRef::Parent(i) => self.parents.get_mut(i).map(Arc::make_mut), + } + } + + /// All spread refs: document spreads then parents. + pub fn spread_refs(&self) -> impl Iterator { + (0..self.spreads.len()).map(SpreadRef::Doc).chain((0..self.parents.len()).map(SpreadRef::Parent)) + } + + pub fn find(&self, id: ItemId) -> Option { + for r in self.spread_refs() { + let mut path = Vec::new(); + if find_in(&self.spread(r)?.items, id, &mut path) { + return Some(ItemLoc { spread: r, path }); + } + } + None + } + + pub fn item_at(&self, loc: &ItemLoc) -> Option<&Item> { + let sp = self.spread(loc.spread)?; + let mut it: &Item = sp.items.get(*loc.path.first()?)?; + for &i in &loc.path[1..] { + it = it.children().get(i)?; + } + Some(it) + } + + pub fn item(&self, id: ItemId) -> Option<&Item> { + self.item_at(&self.find(id)?) + } + + pub fn item_mut_at(&mut self, loc: &ItemLoc) -> Option<&mut Item> { + let sp = self.spread_mut(loc.spread)?; + let mut it: &mut Item = Arc::make_mut(sp.items.get_mut(*loc.path.first()?)?); + for &i in &loc.path[1..] { + it = Arc::make_mut(it.children_mut()?.get_mut(i)?); + } + Some(it) + } + + pub fn item_mut(&mut self, id: ItemId) -> Option<&mut Item> { + let loc = self.find(id)?; + self.item_mut_at(&loc) + } + + /// The top-level item (group root) containing `id`. + pub fn top_level_of(&self, id: ItemId) -> Option { + let loc = self.find(id)?; + Some(self.spread(loc.spread)?.items[loc.top()].id) + } + + /// Spread-space transform of the parent group chain of the item at `loc` (identity at top level). + pub fn parent_xf(&self, loc: &ItemLoc) -> designcraft_geom::Affine { + let mut xf = designcraft_geom::Affine::IDENTITY; + if let Some(sp) = self.spread(loc.spread) { + let mut items = &sp.items; + for &i in &loc.path[..loc.path.len().saturating_sub(1)] { + let it = &items[i]; + xf *= it.xf; + items = match &it.content { + Content::Group { items } => items, + _ => break, + }; + } + } + xf + } + + /// Insert an item into a spread at `index` (None = front). + pub fn insert_item(&mut self, r: SpreadRef, item: Item, index: Option) -> Result { + let id = item.id; + let sp = self.spread_mut(r).ok_or_else(|| DocError::Invalid(format!("no spread {r:?}")))?; + let i = index.unwrap_or(sp.items.len()).min(sp.items.len()); + sp.items.insert(i, Arc::new(item)); + Ok(id) + } + + /// Remove an item (and its descendants) from the document. Text frames leave their threads; + /// stories that lose their last frame are deleted. + pub fn remove_item(&mut self, id: ItemId) -> Result> { + let loc = self.find(id).ok_or(DocError::NoItem(id))?; + let removed = { + let sp = self.spread_mut(loc.spread).ok_or(DocError::NoItem(id))?; + if loc.path.len() == 1 { + sp.items.remove(loc.path[0]) + } else { + let mut it: &mut Item = Arc::make_mut(&mut sp.items[loc.path[0]]); + for &i in &loc.path[1..loc.path.len() - 1] { + it = Arc::make_mut(&mut it.children_mut().ok_or(DocError::NoItem(id))?[i]); + } + it.children_mut().ok_or(DocError::NoItem(id))?.remove(*loc.path.last().expect("non-empty")) + } + }; + let mut frames = Vec::new(); + removed.walk(&mut |i| { + if let Some(t) = i.text_frame() { + frames.push((i.id, t.story)); + } + }); + for (fid, sid) in frames { + let empty = { + let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; + st.frames.retain(|f| *f != fid); + st.frames.is_empty() + }; + if empty { + self.stories.remove(&sid); + } + } + Ok(removed) + } + + /// Create a text frame with a new (or given) story at `rect` on spread `r`. + pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> { + let id = ItemId(self.alloc()); + let sid = StoryId(self.alloc()); + let mut story = Story::with_text(sid, text, para); + story.frames.push(id); + self.stories.insert(sid, Arc::new(story)); + let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); + item.object_style = self.styles.default_text_frame.clone(); + if let Some(os) = self.styles.object_style(&self.styles.default_text_frame).cloned() { + if let Some(f) = os.fill { + item.fill = f; + } + if let Some(s) = os.stroke { + item.stroke = s; + } + } + item.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() }); + self.insert_item(r, item, None)?; + Ok((id, sid)) + } + + /// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text, + /// that text is appended to the story (as a new paragraph) and its old story is removed. + pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> { + if from == to { + return Err(DocError::Invalid("cannot thread a frame to itself".into())); + } + let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?; + // Convert unassigned/graphic-less frames into text frames. + let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() { + Content::Text(t) => Some(t.story), + Content::Unassigned => None, + _ => return Err(DocError::Invalid("can only thread to text or empty frames".into())), + }; + if ts == Some(fs) { + return Err(DocError::Invalid("frames are already in the same story".into())); + } + if let Some(ts) = ts { + let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?; + let other = Arc::unwrap_or_clone(other); + // Frames of the other story after `to` follow along; frames before it are left as empty new stories. + let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); + let before: Vec = other.frames[..pos_to].to_vec(); + let after: Vec = other.frames[pos_to..].to_vec(); + { + let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + if !other.text.is_empty() { + st.text.push('\n'); + st.text.push_str(&other.text); + st.paras.extend(other.paras.iter().cloned()); + // Char runs: '\n' takes the last run's format, then the other story's runs. + if let Some(last) = st.chars.last_mut() { + last.len += 1; + } + st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned()); + st.rev += 1; + } + let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); + for (k, f) in after.iter().enumerate() { + st.frames.insert(at + k, *f); + } + } + for f in &after { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = fs; + } + } + if !before.is_empty() { + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = before.clone(); + for f in &before { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = nid; + } + } + self.stories.insert(nid, Arc::new(ns)); + } + } else { + let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); + st.frames.insert(at, to); + let it = self.item_mut(to).ok_or(DocError::NoItem(to))?; + it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() }); + } + if let Some(st) = self.story_mut(fs) { + st.rev += 1; + } + Ok(()) + } + + /// Break the thread after `frame`: the following frames become a new empty story (the text + /// stays with the first part and may become overset). + pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { + let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; + let tail = { + let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; + let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?; + let tail = st.frames.split_off(p + 1); + st.rev += 1; + tail + }; + if tail.is_empty() { + return Ok(()); + } + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = tail.clone(); + self.stories.insert(nid, Arc::new(ns)); + for f in tail { + if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) { + t.story = nid; + } + } + Ok(()) + } + + /// Next / previous frame in a thread. + pub fn next_frame(&self, frame: ItemId) -> Option { + let st = self.story(self.item(frame)?.text_frame()?.story)?; + let p = st.frames.iter().position(|f| *f == frame)?; + st.frames.get(p + 1).copied() + } + pub fn prev_frame(&self, frame: ItemId) -> Option { + let st = self.story(self.item(frame)?.text_frame()?.story)?; + let p = st.frames.iter().position(|f| *f == frame)?; + p.checked_sub(1).map(|i| st.frames[i]) + } + + /// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space). + /// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline. + pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option { + let sp = self.spreads.get(si)?; + let mut items: Vec<&Arc> = sp.items.iter().collect(); + items.sort_by_key(|i| self.layer_rank(i.layer)); + for it in items.into_iter().rev() { + let layer = self.layer(it.layer); + if it.hidden || layer.is_some_and(|l| !l.visible || l.locked) { + continue; + } + if item_hit(it, p, tol) { + return Some(it.id); + } + } + None + } + + /// Every item id (pre-order) on document spreads. + pub fn all_items(&self) -> Vec { + let mut v = Vec::new(); + for sp in &self.spreads { + for it in &sp.items { + it.walk(&mut |i| v.push(i.id)); + } + } + v + } + + /// Plain story text with frame/story stats (for agents). + pub fn story_summary(&self, sid: StoryId) -> Option { + let st = self.story(sid)?; + Some(serde_json::json!({ + "id": sid.0, + "length": st.text.len(), + "paragraphs": st.paras.len(), + "frames": st.frames.iter().map(|f| f.0).collect::>(), + "text": st.text, + })) + } + + /// Replace a story's text and formatting wholesale (agents / import). + pub fn set_story_text(&mut self, sid: StoryId, text: &str) -> Result<()> { + let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; + let para = st.paras.first().cloned().unwrap_or_default(); + let fmt = st.chars.first().map(|r| r.format.clone()).unwrap_or_default(); + let n = text.matches('\n').count() + 1; + st.text = text.to_string(); + st.paras = vec![para; n]; + st.chars = vec![CharRun { len: text.len(), format: fmt }]; + st.rev += 1; + Ok(()) + } +} + +/// Does `p` (in the item's parent space) hit `it`? +pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { + if let Content::Group { items } = &it.content { + let inner = it.xf.inverse() * p; + return items.iter().any(|c| item_hit(c, inner, tol)); + } + let inner = it.xf.inverse() * p; + let bp = it.path.to_bezpath(); + let area = !it.fill.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_)) || it.path.is_closed() && it.shape != Shape::Path; + if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) { + return true; + } + designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner) +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs new file mode 100644 index 00000000..749cc8e3 --- /dev/null +++ b/crates/doc/src/item.rs @@ -0,0 +1,599 @@ +//! Page items: frames (text / graphic / unassigned), paths, lines and groups. + +use std::sync::Arc; + +use designcraft_color::BlendMode; +use designcraft_geom::corners::CornerOptions; +use designcraft_geom::{Affine, PathData, Point, Rect}; +use serde::{Deserialize, Serialize}; + +use crate::ids::{AssetId, ItemId, LayerId, StoryId}; + +/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Fill { + pub swatch: String, + pub tint: f32, + /// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds). + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + pub overprint: bool, +} + +impl Default for Fill { + fn default() -> Self { + Fill::none() + } +} + +impl Fill { + pub fn none() -> Self { + Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, overprint: false } + } + pub fn swatch(name: &str) -> Self { + Fill { swatch: name.into(), ..Fill::none() } + } + pub fn is_none(&self) -> bool { + self.swatch == designcraft_color::swatch::NONE + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StrokeAlign { + #[default] + Center, + Inside, + Outside, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Cap { + #[default] + Butt, + Round, + Projecting, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Join { + #[default] + Miter, + Round, + Bevel, +} + +/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum StrokeType { + #[default] + Solid, + Dashed { + pattern: Vec, + }, + Dotted, + ThickThin, + ThinThick, + ThinThin, + ThickThick, + ThinThickThin, + ThickThinThick, + Wavy, + Hashed, +} + +impl StrokeType { + pub fn label(&self) -> &'static str { + match self { + StrokeType::Solid => "Solid", + StrokeType::Dashed { .. } => "Dashed", + StrokeType::Dotted => "Dotted", + StrokeType::ThickThin => "Thick - Thin", + StrokeType::ThinThick => "Thin - Thick", + StrokeType::ThinThin => "Thin - Thin", + StrokeType::ThickThick => "Thick - Thick", + StrokeType::ThinThickThin => "Thin - Thick - Thin", + StrokeType::ThickThinThick => "Thick - Thin - Thick", + StrokeType::Wavy => "Wavy", + StrokeType::Hashed => "Straight Hash", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Arrowhead { + #[default] + None, + SimpleWide, + Simple, + Triangle, + TriangleWide, + Barbed, + Curved, + Circle, + CircleSolid, + Square, + SquareSolid, + Bar, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Stroke { + pub swatch: String, + pub tint: f32, + pub weight: f64, + #[serde(rename = "type")] + pub kind: StrokeType, + pub align: StrokeAlign, + pub cap: Cap, + pub join: Join, + pub miter_limit: f64, + pub start: Arrowhead, + pub end: Arrowhead, + /// Gap colour for dashed/striped strokes. + pub gap_swatch: String, + pub gap_tint: f32, + pub overprint: bool, +} + +impl Default for Stroke { + /// InDesign's default for drawn shapes: 1 pt black. + fn default() -> Self { + Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + weight: 1.0, + kind: StrokeType::Solid, + align: StrokeAlign::Center, + cap: Cap::Butt, + join: Join::Miter, + miter_limit: 4.0, + start: Arrowhead::None, + end: Arrowhead::None, + gap_swatch: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + overprint: false, + } + } +} + +impl Stroke { + pub fn none() -> Self { + Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() } + } + pub fn is_none(&self) -> bool { + self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0 + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VerticalJustification { + #[default] + Top, + Center, + Bottom, + Justify, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FirstBaseline { + #[default] + Ascent, + CapHeight, + Leading, + XHeight, + Fixed, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutoSize { + #[default] + Off, + HeightOnly, + WidthOnly, + HeightAndWidth, + HeightAndWidthProportionally, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColumnsKind { + #[default] + FixedNumber, + FixedWidth, + FlexibleWidth, +} + +/// Text Frame Options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TextFrameOptions { + pub columns: u32, + pub columns_kind: ColumnsKind, + pub gutter: f64, + /// Column width for fixed/flexible width columns. + pub column_width: f64, + pub balance_columns: bool, + /// Inset spacing: top, left, bottom, right. + pub inset: [f64; 4], + pub vertical_justification: VerticalJustification, + pub vj_paragraph_spacing_limit: f64, + pub first_baseline: FirstBaseline, + pub first_baseline_min: f64, + pub ignore_wrap: bool, + pub auto_size: AutoSize, + /// Reference point (0..9) that stays fixed when auto-sizing. + pub auto_size_ref: u8, + pub column_rule: bool, + pub column_rule_weight: f64, + pub column_rule_color: String, + /// Use a custom baseline grid for this frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_grid: Option<(f64, f64)>, +} + +impl Default for TextFrameOptions { + fn default() -> Self { + TextFrameOptions { + columns: 1, + columns_kind: ColumnsKind::FixedNumber, + gutter: 12.0, + column_width: 0.0, + balance_columns: false, + inset: [0.0; 4], + vertical_justification: VerticalJustification::Top, + vj_paragraph_spacing_limit: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + ignore_wrap: false, + auto_size: AutoSize::Off, + auto_size_ref: 1, + column_rule: false, + column_rule_weight: 1.0, + column_rule_color: designcraft_color::swatch::BLACK.into(), + baseline_grid: None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WrapMode { + #[default] + None, + BoundingBox, + Contour, + JumpObject, + JumpToNextColumn, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WrapSide { + #[default] + BothSides, + RightSide, + LeftSide, + TowardsSpine, + AwayFromSpine, + LargestArea, +} + +/// Text Wrap panel settings. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TextWrap { + pub mode: WrapMode, + /// Top, left, bottom, right offsets. + pub offsets: [f64; 4], + pub invert: bool, + pub side: WrapSide, +} + +/// Frame fitting options (Object → Fitting → Frame Fitting Options). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Fitting { + #[default] + None, + FillProportionally, + FitProportionally, + FitContentToFrame, + CenterContent, +} + +/// A placed graphic inside a frame. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Graphic { + pub asset: AssetId, + /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). + pub size: (f64, f64), + /// Graphic space (0,0)-(w,h) → frame inner space. + pub xf: Affine, + #[serde(default)] + pub auto_fit: Fitting, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextFrame { + pub story: StoryId, + #[serde(default)] + pub options: TextFrameOptions, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum Content { + #[default] + Unassigned, + Text(TextFrame), + Graphic(Graphic), + Group { + items: Vec>, + }, +} + +/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Shape { + #[default] + Rectangle, + Oval, + Polygon, + GraphicLine, + Path, + Group, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DropShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + pub distance: f64, + pub size: f64, + pub spread: f64, +} + +impl Default for DropShadow { + fn default() -> Self { + DropShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 135.0, distance: 7.0, size: 5.0, spread: 0.0 } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Effects { + pub drop_shadow: DropShadow, + /// Basic feather width (0 = off). + pub feather: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Item { + pub id: ItemId, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + pub layer: LayerId, + #[serde(default)] + pub shape: Shape, + /// Geometry in the item's inner coordinate space. + pub path: PathData, + /// Inner space → spread space. + #[serde(default = "identity")] + pub xf: Affine, + #[serde(default)] + pub fill: Fill, + #[serde(default = "Stroke::none")] + pub stroke: Stroke, + #[serde(default)] + pub corners: CornerOptions, + #[serde(default = "one")] + pub opacity: f32, + #[serde(default)] + pub blend: BlendMode, + #[serde(default)] + pub effects: Effects, + #[serde(default)] + pub wrap: TextWrap, + #[serde(default)] + pub locked: bool, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub nonprinting: bool, + #[serde(default)] + pub object_style: String, + #[serde(default)] + pub content: Content, + /// For items on a document page that override a parent-page item. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +fn identity() -> Affine { + Affine::IDENTITY +} +fn one() -> f32 { + 1.0 +} + +impl Item { + pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self { + Item { + id, + name: String::new(), + layer, + shape, + path, + xf: Affine::IDENTITY, + fill: Fill::none(), + stroke: Stroke::none(), + corners: CornerOptions::default(), + opacity: 1.0, + blend: BlendMode::default(), + effects: Effects::default(), + wrap: TextWrap::default(), + locked: false, + hidden: false, + nonprinting: false, + object_style: crate::styles::NO_OBJECT_STYLE.into(), + content: Content::Unassigned, + overrides: None, + } + } + + pub fn is_text_frame(&self) -> bool { + matches!(self.content, Content::Text(_)) + } + pub fn text_frame(&self) -> Option<&TextFrame> { + match &self.content { + Content::Text(t) => Some(t), + _ => None, + } + } + pub fn text_frame_mut(&mut self) -> Option<&mut TextFrame> { + match &mut self.content { + Content::Text(t) => Some(t), + _ => None, + } + } + pub fn graphic(&self) -> Option<&Graphic> { + match &self.content { + Content::Graphic(g) => Some(g), + _ => None, + } + } + pub fn children(&self) -> &[Arc] { + match &self.content { + Content::Group { items } => items, + _ => &[], + } + } + pub fn children_mut(&mut self) -> Option<&mut Vec>> { + match &mut self.content { + Content::Group { items } => Some(items), + _ => None, + } + } + + /// Inner-space bounds of the path (the frame's "geometric bounds" before transform). + pub fn inner_bounds(&self) -> Rect { + self.path.bounds().unwrap_or(Rect::ZERO) + } + + /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). + pub fn bounds(&self) -> Rect { + if let Content::Group { items } = &self.content { + let mut r: Option = None; + for c in items { + let b = self.xf.transform_rect_bbox(c.bounds()); + r = Some(r.map_or(b, |r| r.union(b))); + } + return r.unwrap_or(Rect::ZERO); + } + self.path.transformed(self.xf).bounds().unwrap_or(Rect::ZERO) + } + + /// Visible bounds including stroke weight (approximate for inside/outside alignment). + pub fn visible_bounds(&self) -> Rect { + let b = self.bounds(); + if self.stroke.is_none() { + return b; + } + let w = match self.stroke.align { + StrokeAlign::Center => self.stroke.weight / 2.0, + StrokeAlign::Inside => 0.0, + StrokeAlign::Outside => self.stroke.weight, + }; + b.inflate(w, w) + } + + /// The frame's text area (inner space) after inset. + pub fn text_area(&self) -> Rect { + let r = self.inner_bounds(); + let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); + Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0])) + } + + /// Spread-space centre. + pub fn center(&self) -> Point { + self.bounds().center() + } + + /// The Layers-panel label: ``, the first words of a text frame, the placed file name… + pub fn default_label(&self) -> &'static str { + match (&self.content, self.shape) { + (Content::Group { .. }, _) => "", + (Content::Text(_), _) => "", + (Content::Graphic(_), _) => "", + (_, Shape::Rectangle) => "", + (_, Shape::Oval) => "", + (_, Shape::Polygon) => "", + (_, Shape::GraphicLine) => "", + (_, Shape::Path) | (_, Shape::Group) => "", + } + } + + /// Visit this item and descendants (pre-order). + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Item)) { + f(self); + for c in self.children() { + c.walk(f); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_geom::shapes; + + #[test] + fn bounds_and_text_area() { + let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); + it.xf = Affine::translate((10.0, 20.0)); + assert_eq!(it.bounds(), Rect::new(10.0, 20.0, 110.0, 70.0)); + it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } }); + assert_eq!(it.text_area(), Rect::new(6.0, 5.0, 92.0, 43.0)); + it.stroke = Stroke { weight: 4.0, ..Stroke::default() }; + assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0)); + } + + #[test] + fn group_bounds_union_children() { + let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + let b = Item::new(ItemId(2), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(20.0, 20.0, 30.0, 40.0))); + let mut g = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default()); + g.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] }; + assert_eq!(g.bounds(), Rect::new(0.0, 0.0, 30.0, 40.0)); + let mut n = 0; + g.walk(&mut |_| n += 1); + assert_eq!(n, 3); + } + + #[test] + fn serde_roundtrip() { + let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))); + it.fill = Fill::swatch("[Black]"); + it.content = Content::Text(TextFrame { story: StoryId(9), options: TextFrameOptions::default() }); + let s = serde_json::to_string(&it).unwrap(); + let back: Item = serde_json::from_str(&s).unwrap(); + assert_eq!(it, back); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 00000000..def4d380 --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,387 @@ +//! The DesignCraft document model (pure data + serde). +//! +//! A [`Document`] holds spreads of pages, parent ("master") spreads, layers, stories, styles, +//! swatches, sections and embedded assets. Spreads, items and stories are `Arc`-shared so an +//! edit clones only what it touches and undo snapshots are O(1). +//! +//! Coordinates are points, y down. Each spread has its own space: pages sit side by side from +//! x = 0 with their tops at y = 0. Items live in spread space via `Item::xf`. +#![forbid(unsafe_code)] + +pub mod attrs; +pub mod build; +mod edit; +pub mod ids; +pub mod item; +pub mod page; +pub mod selection; +pub mod story; +pub mod styles; + +use std::collections::BTreeMap; +use std::sync::Arc; + +pub use attrs::*; +pub use designcraft_color as color; +pub use designcraft_geom as geom; +pub use edit::{ItemLoc, ItemPath, SpreadRef}; +pub use ids::*; +pub use item::*; +pub use page::*; +pub use selection::*; +pub use story::*; +pub use styles::*; + +use designcraft_color::Swatch; +use designcraft_geom::Unit; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum DocError { + #[error("no such item {0}")] + NoItem(ItemId), + #[error("no such story {0}")] + NoStory(StoryId), + #[error("no such page {0}")] + NoPage(usize), + #[error("no such layer {0}")] + NoLayer(LayerId), + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Intent { + #[default] + Print, + Web, + Mobile, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridRelative { + #[default] + TopOfPage, + TopMargin, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BaselineGrid { + pub start: f64, + pub increment: f64, + pub relative_to: GridRelative, + pub color: [u8; 3], + /// Hidden below this zoom (1.0 = 100%). + pub view_threshold: f64, +} + +impl Default for BaselineGrid { + fn default() -> Self { + BaselineGrid { start: 36.0, increment: 12.0, relative_to: GridRelative::TopOfPage, color: [140, 205, 230], view_threshold: 0.75 } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DocumentGrid { + pub horizontal: f64, + pub vertical: f64, + pub subdivisions: u32, + pub color: [u8; 3], + pub in_back: bool, +} + +impl Default for DocumentGrid { + fn default() -> Self { + DocumentGrid { horizontal: 72.0, vertical: 72.0, subdivisions: 8, color: [200, 200, 200], in_back: true } + } +} + +/// File → Document Setup + the guide/grid/unit preferences stored with the document. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DocSettings { + pub intent: Intent, + pub page_width: f64, + pub page_height: f64, + pub facing_pages: bool, + pub primary_text_frame: bool, + /// Bleed: top, bottom, inside, outside. + pub bleed: [f64; 4], + pub slug: [f64; 4], + pub horizontal_units: Unit, + pub vertical_units: Unit, + pub baseline_grid: BaselineGrid, + pub grid: DocumentGrid, + /// Pasteboard extent around spreads (horizontal, vertical). + pub pasteboard: (f64, f64), + pub margin_color: [u8; 3], + pub column_color: [u8; 3], + pub bleed_color: [u8; 3], + pub slug_color: [u8; 3], + pub keyboard_increment: f64, +} + +impl Default for DocSettings { + fn default() -> Self { + DocSettings { + intent: Intent::Print, + page_width: 612.0, + page_height: 792.0, + facing_pages: true, + primary_text_frame: false, + bleed: [0.0; 4], + slug: [0.0; 4], + horizontal_units: Unit::Picas, + vertical_units: Unit::Picas, + baseline_grid: BaselineGrid::default(), + grid: DocumentGrid::default(), + pasteboard: (72.0, 72.0), + margin_color: [255, 0, 255], + column_color: [148, 112, 255], + bleed_color: [255, 0, 0], + slug_color: [60, 120, 255], + keyboard_increment: 1.0, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Layer { + pub id: LayerId, + pub name: String, + /// Selection / frame-edge colour. + pub color: [u8; 3], + #[serde(default = "yes")] + pub visible: bool, + #[serde(default)] + pub locked: bool, + #[serde(default = "yes")] + pub printable: bool, + #[serde(default = "yes")] + pub show_guides: bool, + #[serde(default)] + pub suppress_wrap_when_hidden: bool, +} + +fn yes() -> bool { + true +} + +/// InDesign's layer colour sequence (names are the conventional colour names). +pub const LAYER_COLORS: &[(&str, [u8; 3])] = &[ + ("Light Blue", [79, 153, 255]), + ("Red", [255, 0, 0]), + ("Green", [79, 255, 79]), + ("Blue", [0, 0, 255]), + ("Yellow", [255, 255, 79]), + ("Magenta", [255, 79, 255]), + ("Cyan", [0, 255, 255]), + ("Gray", [128, 128, 128]), + ("Black", [0, 0, 0]), + ("Orange", [255, 102, 0]), + ("Dark Green", [0, 84, 0]), + ("Teal", [0, 153, 153]), + ("Tan", [204, 153, 102]), + ("Brown", [153, 51, 0]), + ("Violet", [153, 51, 255]), + ("Gold", [255, 153, 0]), +]; + +/// An embedded file (placed image / PDF). Bytes are stored next to the JSON in the native format. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub id: AssetId, + pub name: String, + pub mime: String, + /// Original location on disk (Links panel), if linked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link: Option, + #[serde(skip)] + pub data: Arc>, + /// Pixel size for raster images. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pixels: Option<(u32, u32)>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Document { + pub title: String, + pub settings: DocSettings, + pub spreads: Vec>, + pub parents: Vec>, + /// Top of the list = frontmost layer (Layers panel order). + pub layers: Vec, + pub stories: BTreeMap>, + pub styles: Arc, + pub swatches: Vec, + pub sections: Vec
, + #[serde(default)] + pub assets: BTreeMap>, + pub next_id: u64, +} + +impl Document { + /// Allocate a fresh id. + pub fn alloc(&mut self) -> u64 { + self.next_id += 1; + self.next_id + } + + pub fn page_count(&self) -> usize { + self.spreads.iter().map(|s| s.pages.len()).sum() + } + + /// (spread index, page index within spread) for an absolute page index. + pub fn page_loc(&self, abs: usize) -> Option<(usize, usize)> { + let mut n = 0; + for (si, s) in self.spreads.iter().enumerate() { + if abs < n + s.pages.len() { + return Some((si, abs - n)); + } + n += s.pages.len(); + } + None + } + + /// Absolute index of the first page of spread `si`. + pub fn first_page_of_spread(&self, si: usize) -> usize { + self.spreads[..si.min(self.spreads.len())].iter().map(|s| s.pages.len()).sum() + } + + pub fn page(&self, abs: usize) -> Option<&Page> { + let (s, p) = self.page_loc(abs)?; + self.spreads[s].pages.get(p) + } + + /// Iterate all pages with their absolute index and spread index. + pub fn pages(&self) -> impl Iterator { + self.spreads.iter().enumerate().flat_map(|(si, s)| s.pages.iter().map(move |p| (si, p))).enumerate().map(|(i, (si, p))| (i, si, p)) + } + + /// The displayed page name ("1", "iv", "A-3"…) for an absolute page index, per sections. + pub fn page_name(&self, abs: usize) -> String { + let mut sec: Option<&Section> = None; + for s in &self.sections { + if s.start <= abs && sec.is_none_or(|c| s.start >= c.start) { + sec = Some(s); + } + } + let Some(sec) = sec else { return (abs + 1).to_string() }; + // Numbers continue from the previous section when start_number is None. + let start_number = match sec.start_number { + Some(n) => n, + None => { + let prev: Vec<&Section> = self.sections.iter().filter(|s| s.start < sec.start).collect(); + if prev.is_empty() { + sec.start as u32 + 1 + } else { + // Approximate continuation: absolute position + 1. + sec.start as u32 + 1 + } + } + }; + let n = start_number + (abs - sec.start) as u32; + let num = sec.style.format(n); + if sec.include_prefix { format!("{}{}", sec.prefix, num) } else { num } + } + + pub fn layer(&self, id: LayerId) -> Option<&Layer> { + self.layers.iter().find(|l| l.id == id) + } + pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { + self.layers.iter_mut().find(|l| l.id == id) + } + /// Stacking rank of a layer: 0 = backmost. + pub fn layer_rank(&self, id: LayerId) -> usize { + self.layers.iter().position(|l| l.id == id).map(|i| self.layers.len() - 1 - i).unwrap_or(0) + } + pub fn default_layer(&self) -> LayerId { + self.layers.first().map(|l| l.id).unwrap_or_default() + } + + pub fn story(&self, id: StoryId) -> Option<&Story> { + self.stories.get(&id).map(|s| &**s) + } + pub fn story_mut(&mut self, id: StoryId) -> Option<&mut Story> { + self.stories.get_mut(&id).map(Arc::make_mut) + } + + pub fn swatch(&self, name: &str) -> Option<&Swatch> { + self.swatches.iter().find(|s| s.name == name) + } + + /// Resolve a swatch reference to a display colour. + pub fn resolve_color(&self, swatch: &str, tint: f32) -> Option { + designcraft_color::swatch::resolve(&self.swatches, swatch, tint) + } + + /// Validate structural invariants (ids unique, threads consistent, stories well-formed). + pub fn check(&self) -> Result<()> { + let mut ids = std::collections::HashSet::new(); + for sp in self.spreads.iter().chain(self.parents.iter()) { + for it in &sp.items { + let mut dup = None; + it.walk(&mut |i| { + if !ids.insert(i.id.0) { + dup = Some(i.id); + } + }); + if let Some(d) = dup { + return Err(DocError::Invalid(format!("duplicate item id {d}"))); + } + } + } + for (sid, st) in &self.stories { + st.check().map_err(DocError::Invalid)?; + if st.id != *sid { + return Err(DocError::Invalid(format!("story key {sid} != id {}", st.id))); + } + for f in &st.frames { + let item = self.find(*f).ok_or(DocError::NoItem(*f))?; + match self.item_at(&item).and_then(|i| i.text_frame().map(|t| t.story)) { + Some(s) if s == *sid => {} + other => return Err(DocError::Invalid(format!("frame {f} in story {sid} points at {other:?}"))), + } + } + } + // Every text frame is in its story's thread. + for sp in self.spreads.iter().chain(self.parents.iter()) { + for it in &sp.items { + let mut bad = None; + it.walk(&mut |i| { + if let Some(t) = i.text_frame() + && !self.stories.get(&t.story).is_some_and(|s| s.frames.contains(&i.id)) + { + bad = Some(i.id); + } + }); + if let Some(b) = bad { + return Err(DocError::Invalid(format!("text frame {b} not in its story's thread"))); + } + } + } + if self.next_id < ids.iter().copied().max().unwrap_or(0) { + return Err(DocError::Invalid("next_id behind existing ids".into())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; + +impl Document { + /// Mutable styles (copy-on-write). + pub fn styles_mut(&mut self) -> &mut Styles { + Arc::make_mut(&mut self.styles) + } +} diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs new file mode 100644 index 00000000..3855104c --- /dev/null +++ b/crates/doc/src/page.rs @@ -0,0 +1,262 @@ +//! Spreads, pages, margins, columns, guides; parent ("master") spreads; sections. + +use std::sync::Arc; + +use designcraft_geom::Rect; +use serde::{Deserialize, Serialize}; + +use crate::attrs::NumberStyle; +use crate::ids::{ItemId, PageId, SpreadId}; +use crate::item::Item; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Margins { + pub top: f64, + pub bottom: f64, + /// Inside (facing pages) / left. + pub inside: f64, + /// Outside (facing pages) / right. + pub outside: f64, +} + +impl Margins { + pub fn uniform(v: f64) -> Self { + Margins { top: v, bottom: v, inside: v, outside: v } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Columns { + pub count: u32, + pub gutter: f64, + /// Custom column positions (after dragging column guides); `None` = evenly spaced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub positions: Option>, +} + +impl Default for Columns { + fn default() -> Self { + Columns { count: 1, gutter: 12.0, positions: None } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Orientation { + Horizontal, + Vertical, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Guide { + pub orientation: Orientation, + /// Spread coordinate (x for vertical guides, y for horizontal). + pub position: f64, + /// Spread guides cross the whole pasteboard; page guides only their page. + #[serde(default)] + pub spread: bool, + #[serde(default)] + pub locked: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PageSide { + #[default] + Single, + Left, + Right, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Page { + pub id: PageId, + pub width: f64, + pub height: f64, + /// Position of the page's left edge in spread coordinates (top is y = 0). + pub x: f64, + pub margins: Margins, + pub columns: Columns, + /// Applied parent spread (`None` = [None]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + #[serde(default)] + pub side: PageSide, + /// Parent items overridden on this page (hidden from the parent rendering). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub overridden: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, + /// Show parent items on this page. + #[serde(default = "yes")] + pub show_parent_items: bool, +} + +fn yes() -> bool { + true +} + +impl Page { + pub fn bounds(&self) -> Rect { + Rect::new(self.x, 0.0, self.x + self.width, self.height) + } + /// Left and right margins resolved for the page side (inside is at the spine). + pub fn left_right_margins(&self) -> (f64, f64) { + match self.side { + PageSide::Left => (self.margins.outside, self.margins.inside), + _ => (self.margins.inside, self.margins.outside), + } + } + /// The live area inside the margins. + pub fn margin_rect(&self) -> Rect { + let (l, r) = self.left_right_margins(); + let b = self.bounds(); + Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top)) + } + /// Column rects within the margins. + pub fn column_rects(&self) -> Vec { + let m = self.margin_rect(); + column_rects(m, self.columns.count.max(1), self.columns.gutter) + } +} + +/// Split `area` into `n` equal columns separated by `gutter`. +pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec { + let n = n.max(1) as usize; + let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); + (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() +} + +/// Parent spread metadata. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParentInfo { + /// `A`, `B`… + pub prefix: String, + /// `Parent`. + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub based_on: Option, +} + +impl ParentInfo { + pub fn label(&self) -> String { + format!("{}-{}", self.prefix, self.name) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Spread { + pub id: SpreadId, + pub pages: Vec, + /// Items in z-order (last = front). Coordinates are spread space. + pub items: Vec>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + #[serde(default = "yes")] + pub allow_shuffle: bool, +} + +impl Spread { + /// Union of the page rects. + pub fn bounds(&self) -> Rect { + self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) + } + /// x of the spine (between left and right pages) or the page centre for single pages. + pub fn spine_x(&self) -> f64 { + match self.pages.iter().position(|p| p.side == PageSide::Right) { + Some(i) => self.pages[i].x, + None => self.bounds().center().x, + } + } + /// Index of the page under spread x (nearest page for the pasteboard). + pub fn page_at_x(&self, x: f64) -> Option { + if self.pages.is_empty() { + return None; + } + self.pages + .iter() + .position(|p| x >= p.x && x < p.x + p.width) + .or_else(|| if x < self.pages[0].x { Some(0) } else { Some(self.pages.len() - 1) }) + } + /// Lay the pages out left to right with no gaps, starting at x = 0. + pub fn relayout(&mut self) { + let mut x = 0.0; + for p in &mut self.pages { + p.x = x; + x += p.width; + } + } +} + +/// A numbering section starting at a document page. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Section { + /// Absolute index of the first page of the section. + pub start: usize, + /// Start numbering at (None = continue from the previous section). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_number: Option, + #[serde(default)] + pub style: NumberStyle, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub prefix: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub marker: String, + #[serde(default)] + pub include_prefix: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(side: PageSide, x: f64) -> Page { + Page { + id: PageId(1), + width: 612.0, + height: 792.0, + x, + margins: Margins { top: 36.0, bottom: 36.0, inside: 54.0, outside: 36.0 }, + columns: Columns { count: 2, gutter: 12.0, positions: None }, + parent: None, + side, + overridden: vec![], + guides: vec![], + show_parent_items: true, + } + } + + #[test] + fn margins_mirror_on_left_pages() { + let l = page(PageSide::Left, 0.0); + let r = page(PageSide::Right, 612.0); + assert_eq!(l.margin_rect(), Rect::new(36.0, 36.0, 558.0, 756.0)); + assert_eq!(r.margin_rect(), Rect::new(666.0, 36.0, 1188.0, 756.0)); + } + + #[test] + fn columns_divide_live_area() { + let p = page(PageSide::Single, 0.0); + let cols = p.column_rects(); + assert_eq!(cols.len(), 2); + let total: f64 = cols.iter().map(|c| c.width()).sum::() + 12.0; + assert!((total - p.margin_rect().width()).abs() < 1e-9); + assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9); + } + + #[test] + fn spread_spine_and_hit() { + let s = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0), page(PageSide::Right, 612.0)], items: vec![], parent: None, allow_shuffle: true }; + assert_eq!(s.spine_x(), 612.0); + assert_eq!(s.page_at_x(700.0), Some(1)); + assert_eq!(s.page_at_x(-50.0), Some(0)); + assert_eq!(s.page_at_x(5000.0), Some(1)); + } +} diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs new file mode 100644 index 00000000..1f31dfe7 --- /dev/null +++ b/crates/doc/src/selection.rs @@ -0,0 +1,65 @@ +//! What is selected: page items (containers or their content) or a text range in a story. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{ItemId, StoryId}; + +/// A caret or text range in a story. `anchor` stays put while `focus` moves (Shift-arrows). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextSel { + pub story: StoryId, + pub anchor: usize, + pub focus: usize, + /// The frame the caret is shown in (for carets at frame boundaries). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frame: Option, +} + +impl TextSel { + pub fn caret(story: StoryId, pos: usize) -> Self { + TextSel { story, anchor: pos, focus: pos, frame: None } + } + pub fn range(&self) -> std::ops::Range { + self.anchor.min(self.focus)..self.anchor.max(self.focus) + } + pub fn is_caret(&self) -> bool { + self.anchor == self.focus + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Selection { + /// Selected items (top-level or, with the Direct Selection tool, nested items). + pub items: Vec, + /// Content selection (graphic inside its frame) instead of the container. + #[serde(default)] + pub content: bool, + /// Direct selection: selected anchors as (item, subpath, anchor). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub anchors: Vec<(ItemId, usize, usize)>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// Key object for Align. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, +} + +impl Selection { + pub fn is_empty(&self) -> bool { + self.items.is_empty() && self.text.is_none() + } + pub fn len(&self) -> usize { + self.items.len() + } + pub fn items(ids: Vec) -> Self { + Selection { items: ids, ..Default::default() } + } + pub fn text(t: TextSel) -> Self { + Selection { text: Some(t), ..Default::default() } + } + pub fn contains(&self, id: ItemId) -> bool { + self.items.contains(&id) + } +} diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 4b2a0297..de79ad82 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -211,7 +211,8 @@ impl Story { /// Apply `f` to the character format of every run intersecting `range` (splitting runs at the /// range boundaries). With an empty range nothing changes (callers set typing formats instead). pub fn format_chars(&mut self, range: std::ops::Range, mut f: impl FnMut(&mut CharFormat)) { - let (a, b) = (range.start.min(self.text.len()), range.end.min(self.text.len())); + let a = floor_char_boundary(&self.text, range.start); + let b = floor_char_boundary(&self.text, range.end); if a >= b { return; } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs new file mode 100644 index 00000000..d07804d9 --- /dev/null +++ b/crates/doc/src/tests.rs @@ -0,0 +1,85 @@ +use designcraft_geom::Rect; + +use super::*; +use crate::build::NewDocument; + +fn doc() -> Document { + Document::new(&NewDocument { pages: 2, ..Default::default() }) +} + +#[test] +fn threading_moves_frames_and_text() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + d.check().unwrap(); + assert!(d.story(sb).is_none()); + let st = d.story(sa).unwrap(); + assert_eq!(st.text, "first\nsecond"); + assert_eq!(st.frames, vec![a, b]); + assert_eq!(d.next_frame(a), Some(b)); + assert_eq!(d.prev_frame(b), Some(a)); + d.unthread_after(a).unwrap(); + d.check().unwrap(); + assert_eq!(d.story(sa).unwrap().frames, vec![a]); + assert_ne!(d.item(b).unwrap().text_frame().unwrap().story, sa); +} + +#[test] +fn thread_to_empty_frame_converts_it() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap(); + let id = ItemId(d.alloc()); + let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0))); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d.thread(a, id).unwrap(); + assert!(d.item(id).unwrap().is_text_frame()); + assert_eq!(d.story(sa).unwrap().frames, vec![a, id]); + d.check().unwrap(); + assert!(d.thread(a, a).is_err()); +} + +#[test] +fn removing_last_frame_deletes_story() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap(); + d.remove_item(a).unwrap(); + assert!(d.story(sa).is_none()); + d.check().unwrap(); +} + +#[test] +fn hit_testing_respects_layers() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap(); + assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), Some(a)); + assert_eq!(d.hit_item(0, designcraft_geom::Point::new(300.0, 300.0), 2.0), None); + d.layer_mut(lid).unwrap().locked = true; + assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), None); +} + +#[test] +fn page_names_follow_sections() { + let mut d = Document::new(&NewDocument { pages: 6, ..Default::default() }); + assert_eq!(d.page_name(0), "1"); + d.sections.push(Section { start: 2, start_number: Some(1), style: NumberStyle::LowerRoman, prefix: "A-".into(), marker: String::new(), include_prefix: true }); + assert_eq!(d.page_name(1), "2"); + assert_eq!(d.page_name(2), "A-i"); + assert_eq!(d.page_name(4), "A-iii"); +} + +#[test] +fn document_serde_roundtrip() { + let mut d = doc(); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "héllo\nworld", ParaFormat::default()).unwrap(); + let s = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&s).unwrap(); + assert_eq!(d, back); + back.check().unwrap(); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml new file mode 100644 index 00000000..664ea1df --- /dev/null +++ b/crates/fonts/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "designcraft-fonts" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft fonts: font database (bundled OFL + system), metrics, glyph outlines, shaping" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +skrifa = { workspace = true } +harfrust = { workspace = true } +log = { workspace = true } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs new file mode 100644 index 00000000..28c1167d --- /dev/null +++ b/crates/fonts/src/fontdb.rs @@ -0,0 +1,558 @@ +//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use kurbo::BezPath; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; +use skrifa::string::StringId; +use skrifa::{GlyphId, MetadataProvider}; + +/// The family used when a requested family is unknown (the UI sans). +pub const FALLBACK_FAMILY: &str = "Source Sans 3"; + +static BUNDLED: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"), + include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"), + include_bytes!("../../../assets/fonts/SourceSans3-Bold.ttf"), + include_bytes!("../../../assets/fonts/SourceSans3-It.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-It.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-Semibold.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-Bold.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-BoldIt.ttf"), + include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), + include_bytes!("../../../assets/fonts/Inter-Medium.ttf"), + include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"), + include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), +]; + +enum FontBytes { + Static(&'static [u8]), + Owned(Arc>), +} + +/// One loaded font face. +pub struct FontFace { + id: u32, + /// Typographic family name (e.g. "Source Sans 3"). + pub family: String, + /// Typographic style name (e.g. "Semibold", "Italic"). + pub style: String, + /// usWeightClass-style weight (400 = regular). + pub weight: f32, + pub italic: bool, + bytes: FontBytes, + index: u32, + pub upem: f64, + /// Ascender in font units (positive = up). + pub ascent: f64, + /// Descender in font units (positive = down). + pub descent: f64, + /// Cap height and x height in font units (estimated from the ascent when the font has no OS/2 + /// values). + pub cap_height: f64, + pub x_height: f64, + pub shaper: harfrust::ShaperData, +} + +impl std::fmt::Debug for FontFace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FontFace({} {})", self.family, self.style) + } +} + +impl FontFace { + pub fn data(&self) -> &[u8] { + match &self.bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + } + } + pub fn skrifa(&self) -> Option> { + skrifa::FontRef::from_index(self.data(), self.index).ok() + } + pub fn hb(&self) -> Option> { + harfrust::FontRef::from_index(self.data(), self.index).ok() + } + /// Unique id of this face within the process. + pub fn id(&self) -> u32 { + self.id + } + /// Does the face map `c` to a glyph? + pub fn covers(&self, c: char) -> bool { + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some()) + } + /// Units per em. + pub fn units_per_em(&self) -> f64 { + self.upem + } + /// (ascent, descent) in font units, both positive. + pub fn vertical_metrics(&self) -> (f64, f64) { + (self.ascent, self.descent) + } + /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). + pub fn chars(&self) -> Vec<(char, u32)> { + let Some(f) = self.skrifa() else { return vec![] }; + let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect(); + v.sort_unstable_by_key(|x| x.0); + v.dedup_by_key(|x| x.0); + v + } + /// Advance width of glyph `gid` in font units. + pub fn advance(&self, gid: u32) -> f64 { + self.skrifa() + .and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid))) + .map(|a| a as f64) + .unwrap_or(self.upem * 0.5) + } + /// Glyph id for `c` (0 = .notdef). + pub fn glyph_for(&self, c: char) -> u32 { + self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +struct CatalogEntry { + family: String, + style: String, + path: std::path::PathBuf, +} + +/// Process-wide font database. +pub struct FontDb { + faces: RwLock>>, + outlines: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock>, + /// System fallback state: scanned yet, and characters no system font covers. + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct SysFallback { + enabled: bool, + scanned: bool, + misses: std::collections::HashSet, +} + +/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. +#[cfg(not(target_arch = "wasm32"))] +const SYSTEM_FALLBACKS: &[&str] = &[ + "Helvetica Neue", + "Arial", + "Segoe UI", + "Noto Sans", + "DejaVu Sans", + "PingFang SC", + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Apple SD Gothic Neo", + "Heiti SC", + "STHeiti", + "Microsoft YaHei", + "Yu Gothic", + "Malgun Gothic", + "Noto Sans CJK SC", + "Noto Sans CJK JP", + "Arial Unicode MS", + "Apple Symbols", + "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols2", + "Noto Emoji", + "Segoe UI Emoji", + "Apple Color Emoji", + "Noto Color Emoji", +]; + +static NEXT_ID: AtomicU32 = AtomicU32::new(1); +const OUTLINE_CACHE_MAX: usize = 50_000; + +fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`. +fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len(), + Err(_) => 0, + }; + (0..count) + .filter_map(|i| { + let f = skrifa::FontRef::from_index(data, i).ok()?; + let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((i, family, style)) + }) + .collect() +} + +fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Option { + let data: &[u8] = match &bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + }; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(Size::unscaled(), LocationRef::default()); + let a = f.attributes(); + let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?); + Some(FontFace { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + family, + style, + weight: a.weight.value(), + italic: !matches!(a.style, skrifa::attribute::Style::Normal), + upem: m.units_per_em.max(1) as f64, + ascent: m.ascent as f64, + descent: -(m.descent as f64), + cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), + x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + shaper, + bytes, + index, + }) +} + +fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() +} + +/// Weight implied by a style name. +fn style_weight(style: &str) -> f32 { + let s = norm(style); + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} + +impl FontDb { + fn new_bundled() -> Self { + let mut faces = Vec::new(); + for data in BUNDLED { + for (i, family, style) in enumerate_faces(data) { + if let Some(f) = make_face(FontBytes::Static(data), i, family, style) { + faces.push(Arc::new(f)); + } + } + } + Self { + faces: RwLock::new(faces), + outlines: Mutex::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock::new(Vec::new()), + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + } + } + + /// Enable or disable the lazy system-font fallback for characters the loaded fonts lack + /// (native only; on by default). + pub fn set_system_fallback(&self, on: bool) { + #[cfg(not(target_arch = "wasm32"))] + { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on; + } + #[cfg(target_arch = "wasm32")] + let _ = on; + } + + /// Process-wide database preloaded with the bundled fonts. + pub fn global() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(FontDb::new_bundled) + } + + fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { + self.faces.read().unwrap_or_else(|e| e.into_inner()) + } + + /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. + pub fn families(&self) -> Vec { + let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// Style names available for `family` (Regular first, then by weight). + pub fn styles(&self, family: &str) -> Vec { + let mut v: Vec<(bool, f32, String)> = + self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); + #[cfg(not(target_arch = "wasm32"))] + for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() { + if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + } + } + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|t| t.2).collect() + } + + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or + /// every face was already present). + pub fn add_font(&self, bytes: Vec) -> usize { + let data = Arc::new(bytes); + let mut added = 0; + for (i, family, style) in enumerate_faces(&data) { + if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style) { + self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + added += 1; + } + } + added + } + + /// Scan the platform's font directories and catalog the faces found (native only; not called by + /// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the + /// number of faces cataloged. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_system_fonts(&self) -> usize { + let mut dirs: Vec = Vec::new(); + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + let mut found = Vec::new(); + let mut stack = dirs; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + for (_, family, style) in enumerate_faces(&data) { + found.push(CatalogEntry { family, style, path: p.clone() }); + } + } + } + let n = found.len(); + log::debug!("cataloged {n} system font faces"); + *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + n + } + + #[cfg(not(target_arch = "wasm32"))] + fn load_cataloged(&self, family: &str) -> bool { + let paths: Vec = { + let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner()); + let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + p.dedup(); + p + }; + let mut any = false; + for p in paths { + if let Ok(data) = std::fs::read(&p) { + any |= self.add_font(data) > 0; + } + } + any + } + + /// Resolve a family + style to a face, falling back to the closest style of the family, then to + /// Source Sans 3 Regular. + pub fn face(&self, family: &str, style: &str) -> Arc { + if let Some(f) = self.find(family, style) { + return f; + } + #[cfg(not(target_arch = "wasm32"))] + if self.load_cataloged(family) + && let Some(f) = self.find(family, style) + { + return f; + } + self.find(FALLBACK_FAMILY, style) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.read_faces().first().cloned()) + .expect("bundled fonts are always present") + } + + /// Is `family` available (loaded)? + pub fn has_family(&self, family: &str) -> bool { + self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + } + + fn find(&self, family: &str, style: &str) -> Option> { + let faces = self.read_faces(); + let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + if cands.is_empty() { + return None; + } + let ns = norm(style); + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + return Some((*f).clone()); + } + let (tw, ti) = (style_weight(style), style_italic(style)); + cands + .iter() + .min_by(|a, b| { + let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; + let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; + sa.total_cmp(&sb) + }) + .map(|f| (*f).clone()) + } + + /// First face (fallback family first, then load order) that covers `c`; on native, system + /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. + pub fn fallback_for(&self, c: char, exclude: u32) -> Option> { + if let Some(f) = self.loaded_fallback(c, exclude) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + None + } + + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + let faces = self.read_faces(); + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); + order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + + /// Load a system font covering `c` (preferred fallback families first, then any cataloged + /// file under 40 MB). Returns true if one was loaded. Misses are remembered. + #[cfg(not(target_arch = "wasm32"))] + fn system_fallback(&self, c: char) -> bool { + if c.is_control() || c.is_whitespace() { + return false; + } + { + let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if !sys.enabled || sys.misses.contains(&c) { + return false; + } + if !sys.scanned { + sys.scanned = true; + if self.catalog.read().unwrap_or_else(|e| e.into_inner()).is_empty() { + drop(sys); + self.load_system_fonts(); + } + } + } + let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); + let cataloged: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.family.clone()).collect(); + for fam in SYSTEM_FALLBACKS { + if !self.has_family(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { + self.load_cataloged(fam); + if covered(self) { + return true; + } + } + } + let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + for p in paths { + if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + let hit = + enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); + if hit && self.add_font(data) > 0 && covered(self) { + return true; + } + } + self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + false + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { + let key = (face.id, gid); + if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return p.clone(); + } + let mut pen = FlipPen(BezPath::new()); + if let Some(f) = face.skrifa() + && 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(|e| e.into_inner()); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } +} + +struct FlipPen(BezPath); + +impl OutlinePen for FlipPen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, -y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, -y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } + fn close(&mut self) { + self.0.close_path(); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs new file mode 100644 index 00000000..ee29ed21 --- /dev/null +++ b/crates/fonts/src/lib.rs @@ -0,0 +1,139 @@ +//! DesignCraft fonts: the font database (bundled OFL families + user/system fonts), vertical +//! metrics, glyph outlines and OpenType shaping. +//! +//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters +//! and advances in font units. `designcraft-compose` applies sizes, tracking, scaling and +//! justification on top. +#![forbid(unsafe_code)] + +mod fontdb; + +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace}; +pub use harfrust::Feature; +use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; +pub use kurbo::BezPath; +use skrifa::MetadataProvider; +use skrifa::instance::{LocationRef, Size}; + +/// InDesign's default text font is a serif; ours is Source Serif 4. +pub const DEFAULT_FAMILY: &str = "Source Serif 4"; + +/// One shaped glyph, in font units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShapedGlyph { + pub gid: u32, + /// Byte offset (in the shaped string) of the cluster this glyph belongs to. + pub cluster: usize, + pub x_advance: i32, + pub x_offset: i32, + pub y_offset: i32, +} + +/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. +pub fn feature(tag: &str) -> Option { + let (on, t) = match tag.strip_prefix('-') { + Some(r) => (false, r), + None => (true, tag.strip_prefix('+').unwrap_or(tag)), + }; + let b = t.as_bytes(); + if b.len() != 4 { + return None; + } + Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..)) +} + +/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All +/// Caps) while keeping clusters pointing into the original string. +pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec { + let mut out = Vec::with_capacity(text.len()); + let shaped = face.hb().map(|hb| { + let shaper = face.shaper.shaper(&hb).build(); + let mut buf = UnicodeBuffer::new(); + for (i, c) in text.char_indices() { + buf.add(map(c), i as u32); + } + buf.set_direction(Direction::LeftToRight); + buf.guess_segment_properties(); + let gb = shaper.shape(buf, ShapeOptions::new().features(features)); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + out.push(ShapedGlyph { + gid: info.glyph_id, + cluster: info.cluster as usize, + x_advance: pos.x_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + }); + } + }); + if shaped.is_none() + && let Some(f) = face.skrifa() + { + let cmap = f.charmap(); + let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default()); + for (i, c) in text.char_indices() { + let g = cmap.map(map(c)).unwrap_or_default(); + let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); + out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 }); + } + } + out +} + +/// Glyph id of the first of `chars` the face has (0 = .notdef). +pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { + chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_families_load() { + let db = FontDb::global(); + let fams = db.families(); + assert!(fams.iter().any(|f| f == DEFAULT_FAMILY), "{fams:?}"); + assert!(fams.iter().any(|f| f == "Source Sans 3")); + let styles = db.styles(DEFAULT_FAMILY); + for s in ["Regular", "Italic", "Bold", "Semibold"] { + assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}"); + } + } + + #[test] + fn shaping_produces_clusters_and_advances() { + let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); + let g = shape(&face, "Hello", &[], |c| c); + assert_eq!(g.len(), 5); + assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); + assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); + } + + #[test] + fn ligatures_can_be_disabled() { + let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); + let on = shape(&face, "office", &[], |c| c); + let off = shape(&face, "office", &[feature("-liga").unwrap()], |c| c); + assert!(on.len() <= off.len()); + assert_eq!(off.len(), 6); + } + + #[test] + fn mapping_keeps_clusters() { + let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); + let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); + assert_eq!(g[0].gid, face.glyph_for('A')); + assert_eq!(g[1].cluster, 1); + } + + #[test] + fn outlines_and_metrics() { + let db = FontDb::global(); + let face = db.face(DEFAULT_FAMILY, "Bold"); + assert_eq!(face.style, "Bold"); + let o = db.outline(&face, face.glyph_for('O')); + assert!(!o.elements().is_empty()); + assert!(face.ascent > 0.0 && face.descent > 0.0 && face.cap_height > face.x_height); + assert!(feature("abc").is_none()); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 00000000..b3e52cfd --- /dev/null +++ b/crates/render/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "designcraft-render" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft renderer: spreads, pages, frames, composed text and images to pixels (vello_cpu)" + +[lints] +workspace = true + +[dependencies] +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-doc = { workspace = true } +designcraft-fonts = { workspace = true } +designcraft-compose = { workspace = true } +vello_cpu = { workspace = true } +vello_common = { workspace = true } +image = { workspace = true } +log = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +vello_cpu = { workspace = true, features = ["multithreading"] } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 00000000..d4b6f364 --- /dev/null +++ b/crates/render/src/lib.rs @@ -0,0 +1,674 @@ +//! DesignCraft renderer: spreads → premultiplied RGBA pixels with `vello_cpu`. +//! +//! The renderer draws *document content only* (paper, page items, parent items, composed text, +//! placed images). Non-printing chrome — guides, frame edges, ports, selection — is drawn by the +//! UI as vector overlays so it stays crisp at any zoom. +//! +//! Callers pass a list of spreads with their placement on a shared canvas and a canvas → pixel +//! view transform, so the UI can render exactly its viewport with every visible spread. +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::sync::Arc; + +use designcraft_color::BlendMode as DcBlend; +use designcraft_compose::{Cache, ComposedStory, FrameText}; +use designcraft_doc::{AssetId, Content, Document, Item, PageSide, SpreadRef, StrokeAlign, StrokeType}; +use designcraft_fonts::FontDb; +use designcraft_geom::corners; +use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; +use vello_cpu::kurbo; +use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; +use vello_cpu::{Pixmap, RenderContext, Resources}; + +pub use vello_cpu; + +/// A rendered image (premultiplied RGBA8, row-major). +#[derive(Clone)] +pub struct Rendered { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Rendered { + pub fn to_straight(&self) -> Vec { + let mut out = self.pixels.clone(); + for px in out.chunks_exact_mut(4) { + let a = px[3] as u32; + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + out + } + pub fn to_png(&self) -> Vec { + let mut buf = Vec::new(); + let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size"); + img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode"); + buf + } + pub fn to_jpeg(&self, quality: u8) -> Vec { + let rgba = self.to_straight(); + let rgb: Vec = rgba + .chunks_exact(4) + .flat_map(|p| { + let a = p[3] as u32; + let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8; + [mix(p[0]), mix(p[1]), mix(p[2])] + }) + .collect(); + let mut buf = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8); + buf + } + /// Straight-alpha RGBA at (x, y). + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + let p = &self.pixels[i..i + 4]; + let a = p[3] as u32; + if a == 0 { + return [0, 0, 0, 0]; + } + let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8; + [un(p[0]), un(p[1]), un(p[2]), p[3]] + } +} + +/// What to draw. +#[derive(Clone, Debug)] +pub struct RenderOptions { + /// Canvas background (premultiplied RGBA8); `None` = transparent. + pub background: Option<[u8; 4]>, + /// Fill pages with paper colour. + pub paper: bool, + /// Skip non-printing items and layers (Preview mode, export). + pub printing_only: bool, + /// Items not to draw (live drag previews). + pub hidden: Vec, + /// Draw text smaller than this many pixels as grey bars ("greeking"); 0 = never. + pub greek_below_px: f64, + /// Drop shadow on pages (screen view). + pub page_shadow: bool, +} + +impl Default for RenderOptions { + fn default() -> Self { + Self { background: None, paper: true, printing_only: false, hidden: vec![], greek_below_px: 0.0, page_shadow: false } + } +} + +/// A spread placed on the canvas: canvas = spread + offset. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placed { + pub spread: SpreadRef, + pub offset: Vec2, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameStats { + pub items: usize, + pub glyphs: usize, + pub micros: u64, +} + +/// Reusable renderer (keeps the render context and decoded images). +pub struct Renderer { + ctx: Option, + resources: Resources, + images: HashMap<(AssetId, usize), Arc>, + pub threads: u16, + pub stats: FrameStats, +} + +impl Default for Renderer { + fn default() -> Self { + Self::new() + } +} + +struct Frame<'a> { + doc: &'a Document, + cache: &'a Cache, + view: Affine, + visible: Rect, + px: f64, + opts: &'a RenderOptions, +} + +pub fn default_threads() -> u16 { + #[cfg(target_arch = "wasm32")] + { + 0 + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(n) = std::env::var("DESIGNCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()) { + return n; + } + std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).clamp(0, 8) as u16).unwrap_or(0) + } +} + +impl Renderer { + pub fn new() -> Self { + Self { ctx: None, resources: Resources::new(), images: HashMap::new(), threads: default_threads(), stats: FrameStats::default() } + } + + /// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels). + pub fn render(&mut self, doc: &Document, cache: &Cache, spreads: &[Placed], width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + let start = now(); + let w = width.clamp(1, u16::MAX as u32) as u16; + let h = height.clamp(1, u16::MAX as u32) as u16; + let mut ctx = match self.ctx.take() { + Some(mut c) if c.width() == w && c.height() == h => { + c.reset(); + c + } + _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }), + }; + self.stats = FrameStats::default(); + if let Some(bg) = opts.background { + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); + ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); + } + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let canvas_visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + for pl in spreads { + let Some(sp) = doc.spread(pl.spread) else { continue }; + let sview = view * Affine::translate(pl.offset); + let visible = canvas_visible - pl.offset; + let bounds = sp.bounds(); + // Cull spreads far outside the viewport (pasteboard items may extend; allow a margin). + if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) { + continue; + } + let f = Frame { doc, cache, view: sview, visible, px, opts }; + self.draw_spread(&mut ctx, &f, pl.spread); + } + ctx.flush(); + let mut pm = Pixmap::new(w, h); + ctx.render(&mut pm, &mut self.resources); + self.ctx = Some(ctx); + self.stats.micros = now().saturating_sub(start); + Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() } + } + + /// Render one document page (absolute index) at `scale` px/pt, optionally including bleed. + pub fn render_page(&mut self, doc: &Document, cache: &Cache, abs: usize, scale: f64, bleed: bool, opts: &RenderOptions) -> Option { + let (si, pi) = doc.page_loc(abs)?; + let page = &doc.spreads[si].pages[pi]; + let mut r = page.bounds(); + if bleed { + let b = doc.settings.bleed; + let (l, rr) = if page.side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) }; + r = Rect::new(r.x0 - l, r.y0 - b[0], r.x1 + rr, r.y1 + b[1]); + } + let w = (r.width() * scale).round().max(1.0) as u32; + let h = (r.height() * scale).round().max(1.0) as u32; + let view = Affine::scale(scale) * Affine::translate(-r.origin().to_vec2()); + let mut o = opts.clone(); + o.background = Some([255, 255, 255, 255]); + Some(self.render(doc, cache, &[Placed { spread: SpreadRef::Doc(si), offset: Vec2::ZERO }], w, h, view, &o)) + } + + fn draw_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) { + let doc = f.doc; + let Some(sp) = doc.spread(sr) else { return }; + // Paper. + if f.opts.paper { + let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE); + if f.opts.page_shadow { + ctx.set_transform(f.view); + ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 90)); + for p in &sp.pages { + let s = 3.0 * f.px; + ctx.fill_rect(&(p.bounds() + Vec2::new(s, s))); + } + } + ctx.set_transform(f.view); + ctx.set_paint(color_of(&paper, 1.0)); + for p in &sp.pages { + ctx.fill_rect(&p.bounds()); + } + } + // Layers back to front; within each layer parent items first (document spreads only). + for layer in doc.layers.iter().rev() { + if !layer.visible || f.opts.printing_only && !layer.printable { + continue; + } + if let SpreadRef::Doc(si) = sr { + let first = doc.first_page_of_spread(si); + for (pi, page) in sp.pages.iter().enumerate() { + if !page.show_parent_items { + continue; + } + let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; + let parent = &doc.parents[ppi]; + let dx = page.x - parent.pages[ppage].x; + let page_name = doc.page_name(first + pi); + let pr = parent.pages[ppage].bounds(); + for it in &parent.items { + if it.layer != layer.id || page.overridden.contains(&it.id) { + continue; + } + // Parent items belong to the parent page they sit on. + if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { + continue; + } + let _ = pr; + self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name)); + } + } + } + for it in &sp.items { + if it.layer == layer.id { + self.draw_item(ctx, f, it, Affine::IDENTITY, None); + } + } + } + } + + fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { + if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting { + return; + } + let xf = parent * it.xf; + let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.weight + 2.0, it.stroke.weight + 2.0); + if !it.children().is_empty() { + // Groups: children carry their own transforms relative to the group. + let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); + } + for c in it.children() { + self.draw_item(ctx, f, c, xf, page_name); + } + if layered { + ctx.pop_layer(); + } + return; + } + if !rect_overlaps(vb, f.visible) && !matches!(it.content, Content::Text(_)) { + return; + } + self.stats.items += 1; + let doc = f.doc; + let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; + let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); + } + // Drop shadow (simple offset silhouette). + if it.effects.drop_shadow.on { + let ds = &it.effects.drop_shadow; + let a = ds.angle.to_radians(); + let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance); + if let Some(c) = doc.resolve_color(&ds.color, 1.0) { + ctx.set_transform(f.view * Affine::translate(off) * xf); + ctx.set_paint(color_of(&c, ds.opacity)); + ctx.fill_path(&bp); + } + } + // Fill. + if !it.fill.is_none() { + ctx.set_transform(f.view * xf); + if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() { + ctx.fill_path(&bp); + } + ctx.reset_paint_transform(); + } + // Content. + match &it.content { + Content::Graphic(g) => { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(&bp); + self.draw_graphic(ctx, f, g, xf); + ctx.pop_layer(); + } + Content::Text(tf) => { + let cs = f.cache.get(doc, tf.story, page_name); + if let Some(ft) = cs.frame(it.id) { + self.draw_text(ctx, f, &cs, ft, xf); + } + } + _ => {} + } + // Stroke. + if !it.stroke.is_none() { + self.draw_stroke(ctx, f, it, &bp, xf); + } + if layered { + ctx.pop_layer(); + } + } + + fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { + let st = &it.stroke; + let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return }; + let mut stroke = kurbo::Stroke::new(st.weight) + .with_caps(match st.cap { + designcraft_doc::Cap::Butt => kurbo::Cap::Butt, + designcraft_doc::Cap::Round => kurbo::Cap::Round, + designcraft_doc::Cap::Projecting => kurbo::Cap::Square, + }) + .with_join(match st.join { + designcraft_doc::Join::Miter => kurbo::Join::Miter, + designcraft_doc::Join::Round => kurbo::Join::Round, + designcraft_doc::Join::Bevel => kurbo::Join::Bevel, + }) + .with_miter_limit(st.miter_limit); + match &st.kind { + StrokeType::Dashed { pattern } if !pattern.is_empty() => stroke = stroke.with_dashes(0.0, pattern.iter().copied()), + StrokeType::Dotted => stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round), + _ => {} + } + ctx.set_transform(f.view * xf); + ctx.set_paint(color_of(&c, 1.0)); + let closed = it.path.is_closed(); + match st.align { + StrokeAlign::Inside if closed => { + // Clip a double-width stroke to the path. + ctx.push_clip_layer(bp); + ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke }); + ctx.stroke_path(bp); + ctx.pop_layer(); + } + StrokeAlign::Outside if closed => { + let outline = kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px); + let mut p = outline; + // Remove the interior: even-odd with the path itself. + for el in bp.elements() { + p.push(*el); + } + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.fill_path(&p); + ctx.set_fill_rule(peniko::Fill::NonZero); + } + _ => { + ctx.set_stroke(stroke); + ctx.stroke_path(bp); + } + } + } + + fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) { + let Some(asset) = f.doc.assets.get(&g.asset) else { return }; + // Pick a mip level close to the on-screen size. + let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); + let full = match self.images.get(&(g.asset, 0)) { + Some(p) => p.clone(), + None => { + let Some(pm) = decode_pixmap(&asset.data) else { return }; + let pm = Arc::new(pm); + self.images.insert((g.asset, 0), pm.clone()); + pm + } + }; + let ppt = full.width() as f64 / g.size.0.max(1e-6); // pixels per point + let need = on_screen * 1.0; // pixels per point on screen + let mut level = 0usize; + let mut pm = full.clone(); + while ppt / (1 << (level + 1)) as f64 >= need * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 { + level += 1; + pm = match self.images.get(&(g.asset, level)) { + Some(p) => p.clone(), + None => { + let half = Arc::new(halve(&pm)); + self.images.insert((g.asset, level), half.clone()); + half + } + }; + } + let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); + ctx.set_transform(f.view * xf * g.xf); + let sx = g.size.0 / pm.width().max(1) as f64; + let sy = g.size.1 / pm.height().max(1) as f64; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium) }); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&rect); + ctx.reset_paint_transform(); + } + + fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &ComposedStory, ft: &FrameText, xf: Affine) { + let db = FontDb::global(); + let doc = f.doc; + // Decorations under text (shading) and rules. + for d in &ft.decos { + if let Some(c) = doc.resolve_color(&d.color, d.tint) { + ctx.set_transform(f.view * xf); + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(&d.rect); + } + } + let scale = (f.view * xf).determinant().abs().sqrt(); + // Batch glyph outlines per run style into one path (in frame inner space). + let mut batches: HashMap = HashMap::new(); + let mut lines_deco: Vec<(u32, kurbo::Rect)> = Vec::new(); + for l in &ft.lines { + let greek = f.opts.greek_below_px > 0.0 && l.ascent * scale < f.opts.greek_below_px; + if greek { + if let (Some(a), Some(_)) = (l.glyphs.first(), l.glyphs.last()) { + let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline); + lines_deco.push((u32::MAX, r)); + } + continue; + } + for g in &l.glyphs { + if !g.visible { + continue; + } + let style = &cs.styles[g.style as usize]; + if style.underline || style.strikethrough { + let w = style.size / 14.0; + if style.underline { + lines_deco.push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w))); + } + if style.strikethrough { + lines_deco.push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w))); + } + } + let outline = db.outline(&g.face, g.gid); + if outline.elements().is_empty() { + continue; + } + let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); + let b = batches.entry(g.style).or_default(); + for el in outline.elements() { + b.push(transform_el(a, *el)); + } + self.stats.glyphs += 1; + } + } + ctx.set_transform(f.view * xf); + for (si, bp) in &batches { + let st = &cs.styles[*si as usize]; + if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_path(bp); + } + if st.stroke != designcraft_color::swatch::NONE + && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + { + ctx.set_paint(color_of(&c, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); + ctx.stroke_path(bp); + } + } + for (si, r) in lines_deco { + let c = if si == u32::MAX { + designcraft_color::Color::gray(0.35) + } else { + let st = &cs.styles[si as usize]; + doc.resolve_color(&st.fill, st.fill_tint).unwrap_or(designcraft_color::Color::BLACK) + }; + ctx.set_paint(color_of(&c, if si == u32::MAX { 0.5 } else { 1.0 })); + ctx.fill_rect(&r); + } + } +} + +fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl { + use kurbo::PathEl::*; + match el { + 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, + } +} + +fn rect_overlaps(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0 +} + +pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { + let [r, g, b, a] = c.to_rgba8(alpha); + peniko::Color::from_rgba8(r, g, b, a) +} + +/// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown. +fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, swatch: &str, tint: f32, bounds: Rect, angle: Option) -> bool { + if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { + let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); + if stops.is_empty() { + return false; + } + let c = bounds.center(); + let grad = match g.kind { + designcraft_color::GradientKind::Radial => { + let r = bounds.width().max(bounds.height()) / 2.0; + peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice()) + } + _ => { + let a = angle.unwrap_or(0.0).to_radians(); + let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0; + let d = Vec2::new(a.cos(), -a.sin()) * half; + peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice()) + } + }; + ctx.set_paint(grad); + return true; + } + match doc.resolve_color(swatch, tint) { + Some(c) => { + ctx.set_paint(color_of(&c, 1.0)); + true + } + None => false, + } +} + +pub fn blend_mode(b: DcBlend) -> BlendMode { + use DcBlend as B; + let mix = match b { + B::Normal => Mix::Normal, + B::Darken => Mix::Darken, + B::Multiply => Mix::Multiply, + B::ColorBurn => Mix::ColorBurn, + B::Lighten => Mix::Lighten, + B::Screen => Mix::Screen, + B::ColorDodge => Mix::ColorDodge, + B::Overlay => Mix::Overlay, + B::SoftLight => Mix::SoftLight, + B::HardLight => Mix::HardLight, + B::Difference => Mix::Difference, + B::Exclusion => Mix::Exclusion, + B::Hue => Mix::Hue, + B::Saturation => Mix::Saturation, + B::Color => Mix::Color, + B::Luminosity => Mix::Luminosity, + }; + BlendMode::new(mix, Compose::SrcOver) +} + +/// Decode encoded image bytes into a premultiplied pixmap. +pub fn decode_pixmap(bytes: &[u8]) -> Option { + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { + return None; + } + let data: Vec = img + .pixels() + .map(|p| { + let a = p[3] as u16; + let m = |c: u8| ((c as u16 * a + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } + }) + .collect(); + Some(Pixmap::from_parts(data, w as u16, h as u16)) +} + +/// Box-filter downsample by 2. +fn halve(pm: &Pixmap) -> Pixmap { + let (w, h) = (pm.width() as usize, pm.height() as usize); + let (nw, nh) = ((w / 2).max(1), (h / 2).max(1)); + let src = pm.data(); + let mut out = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let mut acc = [0u32; 4]; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let sx = (2 * x + dx).min(w - 1); + let sy = (2 * y + dy).min(h - 1); + let p = src[sy * w + sx]; + acc[0] += p.r as u32; + acc[1] += p.g as u32; + acc[2] += p.b as u32; + acc[3] += p.a as u32; + } + out.push(vello_cpu::color::PremulRgba8 { r: (acc[0] / 4) as u8, g: (acc[1] / 4) as u8, b: (acc[2] / 4) as u8, a: (acc[3] / 4) as u8 }); + } + } + Pixmap::from_parts(out, nw as u16, nh as u16) +} + +#[cfg(not(target_arch = "wasm32"))] +fn now() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0) +} +#[cfg(target_arch = "wasm32")] +fn now() -> u64 { + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Fill, ParaFormat}; + + #[test] + fn renders_page_with_text_and_fill() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, "Hello DesignCraft", ParaFormat::default()).unwrap(); + let id = designcraft_doc::ItemId(d.alloc()); + let mut box_ = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 136.0, 500.0))); + box_.fill = Fill::swatch("C=100 M=0 Y=0 K=0"); + d.insert_item(SpreadRef::Doc(0), box_, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + assert_eq!((img.width, img.height), (612, 792)); + // Paper is white, the box is cyan-ish, text drew some dark pixels. + assert_eq!(img.pixel(300, 700), [255, 255, 255, 255]); + let c = img.pixel(80, 450); + assert!(c[0] < 80 && c[2] > 150, "{c:?}"); + let dark = (36..300).flat_map(|x| (36..60).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count(); + assert!(dark > 50, "text pixels: {dark}"); + let _ = fid; + assert!(r.stats.glyphs >= 15); + } +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml new file mode 100644 index 00000000..747a74e2 --- /dev/null +++ b/crates/tools/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "designcraft-tools" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft tools: pointer events in, commands and overlays out (Selection, Direct Selection, frames, Type, Hand, Zoom…)" + +[lints] +workspace = true + +[dependencies] +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-doc = { workspace = true } +designcraft-compose = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs new file mode 100644 index 00000000..7d145244 --- /dev/null +++ b/crates/tools/src/catalog.rs @@ -0,0 +1,71 @@ +//! The tool catalogue: ids, labels, shortcuts and Tools-panel groups (InDesign's order). + +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Serialize)] +pub struct ToolInfo { + pub id: &'static str, + pub label: &'static str, + pub shortcut: Option<&'static str>, + pub icon: &'static str, +} + +const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str>, icon: &'static str) -> ToolInfo { + ToolInfo { id, label, shortcut, icon } +} + +/// Tools-panel groups in order (the first tool of each group shows by default). A `None` group +/// marks a separator. +pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ + &[t("selection", "Selection Tool", Some("V"), "tool-selection")], + &[t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct")], + &[t("page", "Page Tool", Some("Shift+P"), "tool-page")], + &[t("gap", "Gap Tool", Some("U"), "tool-gap")], + &[t("contentCollector", "Content Collector Tool", Some("B"), "tool-collector"), t("contentPlacer", "Content Placer Tool", Some("B"), "tool-placer")], + &[], + &[ + t("type", "Type Tool", Some("T"), "tool-type"), + t("typeOnPath", "Type on a Path Tool", Some("Shift+T"), "tool-type-path"), + t("verticalType", "Vertical Type Tool", None, "tool-type-vertical"), + t("verticalTypeOnPath", "Vertical Type on a Path Tool", None, "tool-type-path"), + ], + &[t("line", "Line Tool", Some("\\"), "tool-line")], + &[ + t("pen", "Pen Tool", Some("P"), "tool-pen"), + t("addAnchor", "Add Anchor Point Tool", Some("="), "tool-pen-add"), + t("deleteAnchor", "Delete Anchor Point Tool", Some("-"), "tool-pen-delete"), + t("convertDirection", "Convert Direction Point Tool", Some("Shift+C"), "tool-anchor"), + ], + &[t("pencil", "Pencil Tool", Some("N"), "tool-pencil"), t("smooth", "Smooth Tool", None, "tool-smooth"), t("erase", "Erase Tool", None, "tool-erase")], + &[ + t("rectangleFrame", "Rectangle Frame Tool", Some("F"), "tool-rect-frame"), + t("ellipseFrame", "Ellipse Frame Tool", None, "tool-ellipse-frame"), + t("polygonFrame", "Polygon Frame Tool", None, "tool-polygon-frame"), + ], + &[t("rectangle", "Rectangle Tool", Some("M"), "tool-rect"), t("ellipse", "Ellipse Tool", Some("L"), "tool-ellipse"), t("polygon", "Polygon Tool", None, "tool-polygon")], + &[t("scissors", "Scissors Tool", Some("C"), "tool-scissors")], + &[ + t("freeTransform", "Free Transform Tool", Some("E"), "tool-free-transform"), + t("rotate", "Rotate Tool", Some("R"), "tool-rotate"), + t("scale", "Scale Tool", Some("S"), "tool-scale"), + t("shear", "Shear Tool", Some("O"), "tool-shear"), + ], + &[t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"), t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather")], + &[t("note", "Note Tool", None, "tool-note")], + &[ + t("colorTheme", "Color Theme Tool", Some("Shift+I"), "tool-color-theme"), + t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), + t("measure", "Measure Tool", Some("K"), "tool-measure"), + ], + &[t("hand", "Hand Tool", Some("H"), "tool-hand")], + &[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")], +]; + +pub fn tool_info(id: &str) -> Option { + TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied() +} + +/// Tool for a single-key shortcut (`"T"`, `"Shift+P"`). +pub fn tool_for_shortcut(key: &str) -> Option<&'static str> { + TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut == Some(key)).map(|t| t.id) +} diff --git a/crates/tools/src/layout.rs b/crates/tools/src/layout.rs new file mode 100644 index 00000000..3d433c8f --- /dev/null +++ b/crates/tools/src/layout.rs @@ -0,0 +1,101 @@ +//! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0, +//! parents shown on their own canvas when a parent spread is being edited. + +use designcraft_doc::{Document, SpreadRef}; +use designcraft_geom::{Point, Rect, Vec2}; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct SpreadSlot { + pub spread: SpreadRef, + /// canvas = spread + offset + pub offset: Vec2, + /// Spread page bounds in canvas coordinates. + pub bounds: Rect, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct CanvasLayout { + pub slots: Vec, + /// Pasteboard extent (canvas coordinates). + pub pasteboard: Rect, +} + +/// Vertical gap between spreads (points). +pub const SPREAD_GAP: f64 = 72.0; + +impl CanvasLayout { + /// Layout of the document spreads (or the parent spreads when `parents` is true). + pub fn new(doc: &Document, parents: bool) -> Self { + let list: Vec = if parents { (0..doc.parents.len()).map(SpreadRef::Parent).collect() } else { (0..doc.spreads.len()).map(SpreadRef::Doc).collect() }; + let mut slots = Vec::with_capacity(list.len()); + let mut y = 0.0; + let mut pb: Option = None; + let (px, py) = doc.settings.pasteboard; + for r in list { + let Some(sp) = doc.spread(r) else { continue }; + let b = sp.bounds(); + let offset = Vec2::new(-sp.spine_x(), y - b.y0); + let cb = b + offset; + let pr = Rect::new(cb.x0 - px.max(b.width()), cb.y0 - py * 0.5, cb.x1 + px.max(b.width()), cb.y1 + py * 0.5); + pb = Some(pb.map_or(pr, |p| p.union(pr))); + slots.push(SpreadSlot { spread: r, offset, bounds: cb }); + y += b.height() + SPREAD_GAP; + } + let pasteboard = pb.unwrap_or(Rect::new(-500.0, -500.0, 500.0, 500.0)).inflate(0.0, py * 0.5); + CanvasLayout { slots, pasteboard } + } + + /// The spread under (or nearest to) a canvas point, and the point in spread coordinates. + pub fn spread_at(&self, p: Point) -> Option<(SpreadRef, Point)> { + let s = self.slots.iter().min_by(|a, b| dist_y(a.bounds, p.y).total_cmp(&dist_y(b.bounds, p.y)))?; + Some((s.spread, p - s.offset)) + } + + pub fn slot(&self, r: SpreadRef) -> Option<&SpreadSlot> { + self.slots.iter().find(|s| s.spread == r) + } + + pub fn offset(&self, r: SpreadRef) -> Vec2 { + self.slot(r).map(|s| s.offset).unwrap_or(Vec2::ZERO) + } + + /// Canvas rect of a document page. + pub fn page_rect(&self, doc: &Document, abs: usize) -> Option { + let (si, pi) = doc.page_loc(abs)?; + let off = self.offset(SpreadRef::Doc(si)); + Some(doc.spreads[si].pages[pi].bounds() + off) + } +} + +fn dist_y(r: Rect, y: f64) -> f64 { + if y < r.y0 - SPREAD_GAP / 2.0 { + r.y0 - y + } else if y > r.y1 + SPREAD_GAP / 2.0 { + y - r.y1 + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_doc::build::NewDocument; + + #[test] + fn spreads_stack_on_the_spine() { + let d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let l = CanvasLayout::new(&d, false); + assert_eq!(l.slots.len(), 2); + // Page 1 is a right page: it starts at the spine (x = 0). + assert_eq!(l.slots[0].bounds.x0, 0.0); + // Spread 2 (pages 2–3) straddles the spine. + assert_eq!(l.slots[1].bounds.x0, -612.0); + assert_eq!(l.slots[1].bounds.y0, 792.0 + SPREAD_GAP); + let (r, p) = l.spread_at(Point::new(-100.0, 900.0)).unwrap(); + assert_eq!(r, SpreadRef::Doc(1)); + assert_eq!(p, Point::new(512.0, 900.0 - 792.0 - SPREAD_GAP)); + assert_eq!(l.page_rect(&d, 2).unwrap().x0, 0.0); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs new file mode 100644 index 00000000..af8c0b1d --- /dev/null +++ b/crates/tools/src/lib.rs @@ -0,0 +1,249 @@ +//! DesignCraft tools: pointer events in → commands and overlays out. +//! +//! Tools never mutate the document. They emit [`Action`]s the engine executes: `Begin` snapshots +//! the document, each `Preview` re-applies one command on top of the snapshot, `Commit` records +//! the last preview as one undo step. `Exec` runs a command immediately. Every gesture is therefore +//! replayable by the control channel and MCP, and tools are testable without a UI. +//! +//! Pointer positions are **canvas** coordinates (see [`layout::CanvasLayout`]). +#![forbid(unsafe_code)] + +pub mod catalog; +mod frame; +pub mod layout; +mod nav; +mod select; +mod text; + +use designcraft_compose::Cache; +use designcraft_doc::{Document, Item, ItemId, Selection, SpreadRef}; +use designcraft_geom::{Affine, BezPath, Point, Rect}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; +pub use layout::CanvasLayout; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mods { + #[serde(default)] + pub shift: bool, + #[serde(default)] + pub alt: bool, + /// Command on macOS, Ctrl elsewhere. + #[serde(default)] + pub cmd: bool, + #[serde(default)] + pub ctrl: bool, + #[serde(default)] + pub space: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PointerKind { + Down, + Drag, + Up, + Move, + DoubleClick, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PointerEvent { + pub kind: PointerKind, + /// Canvas coordinates. + pub pos: Point, + #[serde(default)] + pub mods: Mods, +} + +impl PointerEvent { + pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { + Self { kind, pos: Point::new(x, y), mods: Mods::default() } + } + pub fn with_mods(mut self, m: Mods) -> Self { + self.mods = m; + self + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolKey { + Enter, + Escape, + Backspace, + Delete, + Up, + Down, + Left, + Right, + Home, + End, + Tab, + PageUp, + PageDown, +} + +/// What a tool asks the engine to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + Begin(String), + Preview(String, Value), + Commit, + Cancel, + Exec(String, Value), + /// Ask the UI to open a dialog. + Dialog(String, Value), + SwitchTool(String), + /// Ask the UI to pan/zoom: `{"pan":[dx,dy]}` (screen px) or `{"zoomAt":[x,y], "factor":f}` (canvas). + View(Value), +} + +/// Read-only context a tool sees. +pub struct ToolContext<'a> { + pub doc: &'a Document, + pub selection: &'a Selection, + pub cache: &'a Cache, + pub layout: &'a CanvasLayout, + /// Screen pixels per point. + pub zoom: f64, + /// Layer new items go on. + pub layer: designcraft_doc::LayerId, + pub snap: bool, +} + +impl ToolContext<'_> { + pub fn tol(&self, px: f64) -> f64 { + px / self.zoom.max(1e-9) + } + + /// Frontmost unlocked item under a canvas point. + pub fn hit(&self, p: Point) -> Option<(SpreadRef, ItemId)> { + let (sr, sp) = self.layout.spread_at(p)?; + let SpreadRef::Doc(si) = sr else { + return self.hit_parent(sr, sp); + }; + self.doc.hit_item(si, sp, self.tol(4.0)).map(|id| (sr, id)) + } + + fn hit_parent(&self, sr: SpreadRef, sp: Point) -> Option<(SpreadRef, ItemId)> { + let s = self.doc.spread(sr)?; + s.items.iter().rev().find(|it| !it.locked && designcraft_doc::edit_hit(it, sp, self.tol(4.0))).map(|it| (sr, it.id)) + } + + /// Spread-space → canvas transform of the spread holding item `id`. + pub fn item_canvas_xf(&self, id: ItemId) -> Option { + let loc = self.doc.find(id)?; + let off = self.layout.offset(loc.spread); + Some(Affine::translate(off) * self.doc.parent_xf(&loc)) + } + + pub fn item(&self, id: ItemId) -> Option<&Item> { + self.doc.item(id) + } + + /// Canvas bounds of the selected items. + pub fn selection_bounds(&self) -> Option { + let mut r: Option = None; + for id in &self.selection.items { + let (Some(it), Some(xf)) = (self.doc.item(*id), self.item_canvas_xf(*id)) else { continue }; + let b = xf.transform_rect_bbox(it.bounds()); + r = Some(r.map_or(b, |r| r.union(b))); + } + r + } +} + +/// Visual feedback (canvas coordinates) drawn by the UI. +#[derive(Clone, Debug, PartialEq)] +pub enum Overlay { + Marquee(Rect), + /// Preview outline of a frame being drawn. + Path { path: BezPath, color: [u8; 3], dashed: bool }, + Line { a: Point, b: Point, color: [u8; 3], dashed: bool }, + /// Measurement pill near the cursor. + Measure { p: Point, text: String }, + /// Smart guide line in the smart-guide colour. + Guide { a: Point, b: Point }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Cursor { + #[default] + Arrow, + ArrowHollow, + Move, + Crosshair, + ResizeH, + ResizeV, + ResizeNwSe, + ResizeNeSw, + Rotate, + Pen, + Text, + Hand, + HandGrab, + ZoomIn, + ZoomOut, + Eyedropper, + LoadedText, + LoadedGraphic, + NotAllowed, +} + +pub trait Tool: Send { + fn id(&self) -> &'static str; + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec; + fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec { + vec![] + } + fn overlays(&self, _cx: &ToolContext) -> Vec { + vec![] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Arrow + } + fn busy(&self) -> bool { + false + } + /// The tool takes typed text (Type tool with a caret): single-key shortcuts are suppressed. + fn wants_text(&self, _cx: &ToolContext) -> bool { + false + } +} + +pub fn create(id: &str) -> Box { + match id { + "selection" => Box::new(select::SelectionTool::new(false)), + "directSelection" => Box::new(select::SelectionTool::new(true)), + "rectangleFrame" | "ellipseFrame" | "polygonFrame" | "rectangle" | "ellipse" | "polygon" | "line" => Box::new(frame::FrameTool::new(id)), + "type" => Box::new(text::TypeTool::default()), + "hand" => Box::new(nav::HandTool::default()), + "zoom" => Box::new(nav::ZoomTool::default()), + other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))), + } +} + +/// Placeholder for tools whose behaviour hasn't landed yet. +pub struct NoopTool(&'static str); + +impl Tool for NoopTool { + fn id(&self) -> &'static str { + self.0 + } + fn pointer(&mut self, _cx: &ToolContext, _ev: &PointerEvent) -> Vec { + vec![] + } +} + +pub(crate) fn rect_json(r: Rect) -> Value { + serde_json::json!([r.x0, r.y0, r.x1, r.y1]) +} + +pub(crate) fn spread_json(r: SpreadRef) -> Value { + serde_json::to_value(r).unwrap_or(Value::Null) +} + +#[cfg(test)] +mod tests;