diff --git a/.gitignore b/.gitignore index 2287989..ab32027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ -plan/ -plans/ *~ +plan/ +/target +/dist +/corpus +/log +.DS_Store +/.claude/ +/target-*/ +/craft-fonts/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..72255a9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1016 @@ +# 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 = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[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 = "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +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 = "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", + "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.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[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 = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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 = "wordcraft-doc" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "thiserror", + "unicode-segmentation", + "wordcraft-geom", +] + +[[package]] +name = "wordcraft-fonts" +version = "0.1.0" +dependencies = [ + "harfrust", + "kurbo", + "log", + "skrifa 0.47.0", + "unicode-bidi", +] + +[[package]] +name = "wordcraft-geom" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "wordcraft-layout" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "unicode-linebreak", + "unicode-segmentation", + "wordcraft-doc", + "wordcraft-fonts", + "wordcraft-geom", +] + +[[package]] +name = "wordcraft-render" +version = "0.1.0" +dependencies = [ + "image", + "log", + "vello_cpu", + "wordcraft-doc", + "wordcraft-fonts", + "wordcraft-geom", + "wordcraft-layout", +] + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +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 new file mode 100644 index 0000000..56a8707 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,77 @@ +[workspace] +resolver = "3" +members = ["crates/*", "xtask"] +default-members = ["crates/*", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +repository = "https://github.com/storytold/wordcraft" + +[workspace.dependencies] +# internal +wordcraft-geom = { path = "crates/geom" } +wordcraft-doc = { path = "crates/doc" } +wordcraft-fonts = { path = "crates/fonts" } +wordcraft-layout = { path = "crates/layout" } +wordcraft-docx = { path = "crates/docx" } +wordcraft-formats = { path = "crates/formats" } +wordcraft-render = { path = "crates/render" } +wordcraft-pdf = { path = "crates/pdf" } +wordcraft-proof = { path = "crates/proof" } +wordcraft-engine = { path = "crates/engine" } +wordcraft-mcp = { path = "crates/mcp" } +wordcraft-ui-egui = { path = "crates/ui-egui" } + +# foundation +kurbo = { version = "0.13", features = ["serde"] } +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" +proptest = "1" +rayon = "1" +unicode-bidi = "0.3" +unicode-segmentation = "1" +unicode-linebreak = "0.1" +regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } + +# text / rendering / formats +skrifa = "0.47" +harfrust = "0.13" +vello_cpu = "0.2" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp"] } +krilla = { version = "0.8", features = ["pdf"] } +quick-xml = "0.38" +zip = { version = "3", default-features = false, features = ["deflate"] } + +# ui +egui = "0.36" +egui_extras = { version = "0.36", default-features = false } +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" +rfd = "0.17" +arboard = { version = "3.6", default-features = false } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 16 +debug = 0 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..c2c4a1c --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the WordCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-JetBrainsMono.txt b/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-SourceSans3.txt b/assets/fonts/OFL-SourceSans3.txt new file mode 100644 index 0000000..22c601b --- /dev/null +++ b/assets/fonts/OFL-SourceSans3.txt @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-SourceSerif4.txt b/assets/fonts/OFL-SourceSerif4.txt new file mode 100644 index 0000000..5871e1f --- /dev/null +++ b/assets/fonts/OFL-SourceSerif4.txt @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/SourceSans3-Bold.ttf b/assets/fonts/SourceSans3-Bold.ttf new file mode 100644 index 0000000..85693cc Binary files /dev/null and b/assets/fonts/SourceSans3-Bold.ttf differ diff --git a/assets/fonts/SourceSans3-It.ttf b/assets/fonts/SourceSans3-It.ttf new file mode 100644 index 0000000..8e0576f Binary files /dev/null and b/assets/fonts/SourceSans3-It.ttf differ diff --git a/assets/fonts/SourceSans3-Regular.ttf b/assets/fonts/SourceSans3-Regular.ttf new file mode 100644 index 0000000..c5dd0b8 Binary files /dev/null and b/assets/fonts/SourceSans3-Regular.ttf differ diff --git a/assets/fonts/SourceSans3-Semibold.ttf b/assets/fonts/SourceSans3-Semibold.ttf new file mode 100644 index 0000000..4aff9a3 Binary files /dev/null and b/assets/fonts/SourceSans3-Semibold.ttf differ diff --git a/assets/fonts/SourceSerif4-Bold.ttf b/assets/fonts/SourceSerif4-Bold.ttf new file mode 100644 index 0000000..c4159ff 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 0000000..40f3863 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 0000000..e818087 Binary files /dev/null and b/assets/fonts/SourceSerif4-It.ttf differ diff --git a/assets/fonts/SourceSerif4-Regular.ttf b/assets/fonts/SourceSerif4-Regular.ttf new file mode 100644 index 0000000..964a2bf Binary files /dev/null and b/assets/fonts/SourceSerif4-Regular.ttf differ diff --git a/assets/fonts/SourceSerif4-Semibold.ttf b/assets/fonts/SourceSerif4-Semibold.ttf new file mode 100644 index 0000000..151e9a9 Binary files /dev/null and b/assets/fonts/SourceSerif4-Semibold.ttf differ diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4b6c79f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 0000000..f0111eb --- /dev/null +++ b/crates/doc/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "wordcraft-doc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WordCraft document model: stories, paragraphs, runs, tables, sections, styles, numbering, fields, notes, comments, revisions; edit primitives" + +[lints] +workspace = true + +[dependencies] +wordcraft-geom = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +unicode-segmentation = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs new file mode 100644 index 0000000..3bc11e8 --- /dev/null +++ b/crates/doc/src/edit.rs @@ -0,0 +1,390 @@ +//! Range editing on a [`Document`]: insert text, split paragraphs, delete ranges across +//! paragraphs and tables, apply character/paragraph formatting, copy a range to a fragment and +//! paste a fragment. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::para::InlineObject; +use crate::props::{CharProps, ParaProps}; +use crate::{Block, Blocks, DocError, Document, Paragraph, Path, Pos, Result, para_block}; + +/// A copied piece of a document: blocks, where the first and last paragraphs may be partial. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Fragment { + pub blocks: Vec, +} + +impl Fragment { + pub fn plain_text(&self) -> String { + let mut d = Document::new(); + d.body = self.blocks.iter().cloned().map(Arc::new).collect(); + d.plain_text(crate::StoryRef::Body) + } + pub fn from_text(s: &str) -> Fragment { + let lines: Vec<&str> = s.split('\n').collect(); + Fragment { blocks: lines.iter().map(|l| Block::Para(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect() } + } +} + +fn order(a: &Pos, b: &Pos) -> (Pos, Pos) { + if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } +} + +impl Document { + /// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use + /// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it. + pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result { + let p = self.para_mut(pos.story, &pos.path)?; + let n = p.insert_text(pos.off, s, props)?; + Ok(Pos { off: pos.off + n, ..pos.clone() }) + } + + /// Insert an inline object at `pos`; returns the position after it. + pub fn insert_object(&mut self, pos: &Pos, obj: InlineObject, props: &CharProps) -> Result { + let p = self.para_mut(pos.story, &pos.path)?; + p.insert_object(pos.off, obj, props)?; + Ok(Pos { off: pos.off + crate::para::OBJ.len_utf8(), ..pos.clone() }) + } + + /// Split the paragraph at `pos`; returns the start of the new (second) paragraph. + pub fn split_paragraph(&mut self, pos: &Pos) -> Result { + let i = pos.path.last(); + let tail = self.para_mut(pos.story, &pos.path)?.split_off(pos.off)?; + let bl = self.container_mut(pos.story, &pos.path)?; + bl.insert((i + 1).min(bl.len()), para_block(tail)); + Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 }) + } + + /// Insert a block before the block at `path` (same container). + pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> { + let i = path.last(); + let bl = self.container_mut(story, path)?; + if i > bl.len() { + return Err(DocError::BadPath(path.to_string())); + } + bl.insert(i, Arc::new(block)); + Ok(()) + } + + /// Remove the block at `path` (keeps at least one paragraph in the container). + pub fn remove_block(&mut self, story: crate::StoryRef, path: &Path) -> Result { + let i = path.last(); + let bl = self.container_mut(story, path)?; + if i >= bl.len() { + return Err(DocError::BadPath(path.to_string())); + } + let b = bl.remove(i); + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + Ok(Arc::try_unwrap(b).unwrap_or_else(|a| (*a).clone())) + } + + /// Paragraph paths between `a` and `b` inclusive (same story), document order. + pub fn paths_between(&self, a: &Pos, b: &Pos) -> Vec { + let (a, b) = order(a, b); + if a.story != b.story { + return Vec::new(); + } + self.para_paths(a.story).into_iter().filter(|p| *p >= a.path && *p <= b.path).collect() + } + + /// Delete `a..b`. Returns the position where the deletion collapsed to. + pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result { + let (a, b) = order(a, b); + if a == b { + return Ok(a); + } + if a.story != b.story { + return Err(DocError::Invalid("range spans stories".into())); + } + if a.path == b.path { + self.para_mut(a.story, &a.path)?.delete(a.off, b.off)?; + return Ok(a); + } + if a.path.parent() == b.path.parent() { + // Same container: trim the ends, drop the blocks between, join. + let (ia, ib) = (a.path.last(), b.path.last()); + { + let pa = self.para_mut(a.story, &a.path)?; + let end = pa.len(); + pa.delete(a.off, end)?; + } + let mut tail = { + let pb = self.para_mut(b.story, &b.path)?; + pb.delete(0, b.off)?; + pb.clone() + }; + let bl = self.container_mut(a.story, &a.path)?; + if ib < bl.len() && ia < ib { + bl.drain(ia + 1..=ib); + } + tail.props = ParaProps::default(); + let pa = self.para_mut(a.story, &a.path)?; + let keep_props = pa.props.clone(); + pa.append(tail); + pa.props = keep_props; + return Ok(a); + } + // Different containers (range enters or leaves a table): clear text in every paragraph + // inside; remove whole tables that are entirely covered when at the same level as `a`. + let paths = self.paths_between(&a, &b); + for p in paths.iter().rev() { + let (from, to) = { + let Some(para) = self.para(a.story, p) else { continue }; + let from = if *p == a.path { a.off } else { 0 }; + let to = if *p == b.path { b.off } else { para.len() }; + (from, to) + }; + self.para_mut(a.story, p)?.delete(from, to)?; + } + // Drop top-level tables strictly between a and b at a's level. + if a.path.depth() == 0 && b.path.depth() > 0 { + let first_b = b.path.0.first().copied().unwrap_or(0) as usize; + let ia = a.path.last(); + let bl = self.container_mut(a.story, &a.path)?; + let mut i = first_b; + while i > ia + 1 { + i -= 1; + if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) { + bl.remove(i); + } + } + } + Ok(a) + } + + /// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the + /// engine keeps "pending" formatting for the caret instead). + pub fn format_range(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut CharProps)) -> Result<()> { + let (a, b) = order(a, b); + for p in self.paths_between(&a, &b) { + let para = self.para_mut(a.story, &p)?; + let from = if p == a.path { a.off } else { 0 }; + let to = if p == b.path { b.off } else { para.len() }; + para.format(from, to, f)?; + // A fully selected paragraph also gets the mark formatted (Word does). + if to == para.len() && (from == 0 || p != a.path) { + f(&mut para.mark); + } + } + Ok(()) + } + + /// Apply `f` to the paragraph properties of every paragraph touched by `a..b`. + pub fn format_paragraphs(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut ParaProps)) -> Result<()> { + for p in self.paths_between(a, b) { + let para = self.para_mut(a.story, &p)?; + f(&mut para.props); + para.touch(); + } + Ok(()) + } + + /// Copy `a..b` into a fragment. + pub fn copy_range(&self, a: &Pos, b: &Pos) -> Fragment { + let (a, b) = order(a, b); + let mut out = Vec::new(); + if a.story != b.story { + return Fragment::default(); + } + if a.path.parent() == b.path.parent() { + let Some(bl) = self.container(a.story, &a.path) else { return Fragment::default() }; + for i in a.path.last()..=b.path.last() { + let Some(blk) = bl.get(i) else { break }; + match &**blk { + Block::Para(p) => { + let mut p = p.clone(); + let to = if i == b.path.last() { b.off } else { p.len() }; + let from = if i == a.path.last() { a.off } else { 0 }; + let _ = p.delete(to.min(p.len()), p.len()); + let _ = p.delete(0, from.min(p.len())); + if i == b.path.last() && to < self.para(a.story, &b.path).map(Paragraph::len).unwrap_or(0) { + p.section = None; + } + out.push(Block::Para(p)); + } + Block::Table(t) => out.push(Block::Table(t.clone())), + } + } + return Fragment { blocks: out }; + } + for p in self.paths_between(&a, &b) { + if let Some(para) = self.para(a.story, &p) { + let mut q = para.clone(); + let to = if p == b.path { b.off } else { q.len() }; + let from = if p == a.path { a.off } else { 0 }; + let _ = q.delete(to.min(q.len()), q.len()); + let _ = q.delete(0, from.min(q.len())); + q.section = None; + out.push(Block::Para(q)); + } + } + Fragment { blocks: out } + } + + /// Insert a fragment at `pos`; returns the position after the pasted content. + pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { + let mut blocks = frag.blocks.clone(); + if blocks.is_empty() { + return Ok(pos.clone()); + } + // Single paragraph: inline insert keeping runs. + if blocks.len() == 1 + && let Some(Block::Para(src)) = blocks.first() + { + let src = src.clone(); + let target = self.para_mut(pos.story, &pos.path)?; + let tail = target.split_off(pos.off)?; + let added = src.len(); + let keep = target.props.clone(); + let sect = tail.section.clone(); + let mut src = src; + src.section = None; + target.append(src); + target.append(tail); + target.props = keep; + target.section = sect; + return Ok(Pos { off: pos.off + added, ..pos.clone() }); + } + // Multi-block: split, merge first into left part, last into right part. + let after = self.split_paragraph(pos)?; + let left = pos.path.clone(); + let first = blocks.remove(0); + let last = blocks.pop(); + let mut idx = left.last() + 1; + match first { + Block::Para(p) => { + let l = self.para_mut(pos.story, &left)?; + let keep = l.props.clone(); + let mut p2 = p; + let pprops = p2.props.clone(); + p2.section = None; + l.append(p2); + l.props = if keep.is_empty() { pprops } else { keep }; + } + t @ Block::Table(_) => { + self.insert_block(pos.story, &left.with_last(idx), t)?; + idx += 1; + } + } + for b in blocks { + self.insert_block(pos.story, &left.with_last(idx), b)?; + idx += 1; + } + let right = after.path.with_last(idx); + match last { + Some(Block::Para(p)) => { + let r = self.para_mut(pos.story, &right)?; + let tail = std::mem::replace(r, p.clone()); + let off = r.len(); + let sect = tail.section.clone(); + r.append(tail); + r.section = sect; + Ok(Pos { story: pos.story, path: right, off }) + } + Some(t @ Block::Table(_)) => { + self.insert_block(pos.story, &right, t)?; + Ok(Pos { story: pos.story, path: right.with_last(idx + 1), off: 0 }) + } + None => Ok(Pos { story: pos.story, path: right, off: 0 }), + } + } + + /// Replace every block of a story. + pub fn set_story(&mut self, story: crate::StoryRef, blocks: Blocks) -> Result<()> { + let s = self.story_mut(story)?; + *s = blocks; + if s.is_empty() { + s.push(para_block(Paragraph::new())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{StoryRef, Table}; + + fn doc(lines: &[&str]) -> Document { + Document::from_text(&lines.join("\n")) + } + + #[test] + fn split_and_join() { + let mut d = doc(&["Hello world"]); + let p = d.split_paragraph(&Pos::body(0, 5)).unwrap(); + assert_eq!(p, Pos::body(1, 0)); + assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world"); + let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap(); + assert_eq!(at, Pos::body(0, 5)); + assert_eq!(d.plain_text(StoryRef::Body), "Hello world"); + } + + #[test] + fn delete_multi() { + let mut d = doc(&["one", "two", "three"]); + d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap(); + assert_eq!(d.plain_text(StoryRef::Body), "oree"); + assert_eq!(d.body.len(), 1); + } + + #[test] + fn format_across() { + let mut d = doc(&["abc", "def"]); + d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap(); + let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p0.props_of_char(0).bold, None); + assert_eq!(p0.props_of_char(1).bold, Some(true)); + let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap(); + assert_eq!(p1.props_of_char(1).bold, Some(true)); + assert_eq!(p1.props_of_char(2).bold, None); + } + + #[test] + fn copy_paste_round_trip() { + let mut d = doc(&["alpha", "beta", "gamma"]); + let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3)); + assert_eq!(f.plain_text(), "pha\nbeta\ngam"); + let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap(); + assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam"); + assert_eq!(end, Pos::body(4, 3)); + let one = Fragment::from_text("XY"); + let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap(); + assert_eq!(e, Pos::body(0, 2)); + assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha")); + } + + #[test] + fn tables_in_paths() { + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap(); + let paths = d.para_paths(StoryRef::Body); + assert_eq!(paths.len(), 6); + assert_eq!(paths[1], Path(vec![1, 0, 0, 0])); + let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 }; + let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap(); + assert_eq!(e.off, 1); + assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0]))); + assert!(d.plain_text(StoryRef::Body).contains("\tx")); + // Delete from before the table into a cell: text cleared, table kept (partially covered). + d.delete_range(&Pos::body(0, 2), &cell).unwrap(); + assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be"); + assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some()); + assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1))); + } + + #[test] + fn bad_positions_error() { + let mut d = doc(&["é"]); + assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err()); + assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err()); + assert!(d.split_paragraph(&Pos::body(0, 99)).is_err()); + let c = d.clamp(&Pos::body(9, 9)); + assert_eq!(c, Pos::body(0, 2)); + assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 0000000..2cc240b --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,651 @@ +//! WordCraft document model. +//! +//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers, +//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables; +//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is +//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they +//! touch (`Arc::make_mut`). +//! +//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths +//! compare in document order. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod edit; +pub mod numbering; +pub mod para; +pub mod props; +pub mod resolve; +pub mod section; +pub mod styles; +pub mod table; + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +pub use numbering::{ListKind, Numbering}; +pub use para::{InlineObject, Paragraph, Run}; +pub use props::{Align, CharProps, ParaProps, Rgb, TextColor}; +pub use section::SectionProps; +pub use styles::{Style, StyleKind, StyleSheet}; +pub use table::{Cell, Row, Table}; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum DocError { + #[error("offset {0} is not a character boundary in the paragraph")] + BadOffset(usize), + #[error("no paragraph at {0}")] + BadPath(String), + #[error("no story {0:?}")] + BadStory(StoryRef), + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +/// A block in a story. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Block { + Para(Paragraph), + Table(Table), +} + +impl Block { + pub fn as_para(&self) -> Option<&Paragraph> { + match self { + Block::Para(p) => Some(p), + Block::Table(_) => None, + } + } + pub fn as_table(&self) -> Option<&Table> { + match self { + Block::Table(t) => Some(t), + Block::Para(_) => None, + } + } +} + +pub type Blocks = Vec>; + +pub fn para_block(p: Paragraph) -> Arc { + Arc::new(Block::Para(p)) +} + +/// Which story a position is in. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum StoryRef { + #[default] + Body, + Part(u32), +} + +/// Path to a paragraph: `[block]` in a story, `[block, row, cell, block]` inside a table cell, +/// three more indexes per nesting level. Lexicographic order = document order. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +pub struct Path(pub Vec); + +impl Path { + pub fn top(i: usize) -> Path { + Path(vec![i as u32]) + } + /// The block index within its container. + pub fn last(&self) -> usize { + self.0.last().copied().unwrap_or(0) as usize + } + pub fn with_last(&self, i: usize) -> Path { + let mut p = self.clone(); + if let Some(l) = p.0.last_mut() { + *l = i as u32; + } + p + } + /// The container prefix (everything but the last index). + pub fn parent(&self) -> &[u32] { + self.0.split_last().map(|(_, r)| r).unwrap_or(&[]) + } + /// Nesting depth (0 = top-level story block). + pub fn depth(&self) -> usize { + self.0.len().saturating_sub(1) / 3 + } + /// The enclosing table cell (table block path, row, cell) if any. + pub fn cell(&self) -> Option<(Path, usize, usize)> { + if self.0.len() < 4 { + return None; + } + let n = self.0.len(); + let t = Path(self.0.get(..n - 3)?.to_vec()); + Some((t, *self.0.get(n - 3)? as usize, *self.0.get(n - 2)? as usize)) + } +} + +impl std::fmt::Display for Path { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s: Vec = self.0.iter().map(|i| i.to_string()).collect(); + write!(f, "{}", s.join("/")) + } +} + +/// A position in a document. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +pub struct Pos { + pub story: StoryRef, + pub path: Path, + pub off: usize, +} + +impl Pos { + pub fn new(story: StoryRef, path: Path, off: usize) -> Pos { + Pos { story, path, off } + } + pub fn body(block: usize, off: usize) -> Pos { + Pos { story: StoryRef::Body, path: Path::top(block), off } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum PartKind { + #[default] + Header, + Footer, + Footnote, + Endnote, + Comment, + TextBox, +} + +/// A secondary story. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +pub struct Part { + pub kind: PartKind, + pub blocks: Blocks, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct Comment { + pub author: String, + pub initials: String, + /// ISO 8601. + pub date: String, + /// Reply to this comment id. + pub parent: Option, + pub resolved: bool, + /// `Document::parts` id of the comment text. + pub part: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum RevisionKind { + #[default] + Insert, + Delete, + Format, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Revision { + pub kind: RevisionKind, + pub author: String, + pub date: String, +} + +/// Document properties (File › Info). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct CoreProps { + pub title: String, + pub subject: String, + pub creator: String, + pub keywords: String, + pub description: String, + pub category: String, + pub last_modified_by: String, + pub created: String, + pub modified: String, + pub revision: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + pub track_changes: bool, + /// Default tab stop interval, points. + pub default_tab: f32, + pub even_odd_headers: bool, + pub mirror_margins: bool, + pub auto_hyphenation: bool, + pub page_color: Option, + pub watermark: Option, + /// Theme fonts (major = headings, minor = body). + pub major_font: String, + pub minor_font: String, + /// Theme colours: dk1 lt1 dk2 lt2 accent1..6 hlink folHlink. + pub theme_colors: Vec, + pub theme_name: String, + pub footnote_format: section::NumFormat, + pub endnote_format: section::NumFormat, + pub protection: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Watermark { + pub text: String, + pub font: String, + pub color: Rgb, + pub diagonal: bool, + pub semitransparent: bool, +} + +impl Default for Watermark { + fn default() -> Self { + Watermark { text: "DRAFT".into(), font: styles::BODY_FONT.into(), color: Rgb(0xC0, 0xC0, 0xC0), diagonal: true, semitransparent: true } + } +} + +/// Our default theme colours. +pub const THEME_COLORS: [Rgb; 12] = [ + Rgb(0x00, 0x00, 0x00), + Rgb(0xFF, 0xFF, 0xFF), + Rgb(0x0E, 0x28, 0x41), + Rgb(0xE8, 0xE8, 0xE8), + Rgb(0x15, 0x60, 0x82), + Rgb(0xE9, 0x71, 0x32), + Rgb(0x19, 0x6B, 0x24), + Rgb(0x0F, 0x9E, 0xD5), + Rgb(0xA0, 0x2B, 0x93), + Rgb(0x4E, 0xA7, 0x2E), + Rgb(0x46, 0x78, 0x86), + Rgb(0x96, 0x60, 0x7D), +]; + +impl Default for Settings { + fn default() -> Self { + Settings { + track_changes: false, + default_tab: 36.0, + even_odd_headers: false, + mirror_margins: false, + auto_hyphenation: false, + page_color: None, + watermark: None, + major_font: styles::HEADING_FONT.into(), + minor_font: styles::BODY_FONT.into(), + theme_colors: THEME_COLORS.to_vec(), + theme_name: "Craft".into(), + footnote_format: section::NumFormat::Decimal, + endnote_format: section::NumFormat::LowerRoman, + protection: None, + } + } +} + +/// A document. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Document { + pub body: Blocks, + /// Properties of the last section (OOXML's body-level `w:sectPr`). + pub last_section: SectionProps, + pub parts: BTreeMap, + pub styles: StyleSheet, + pub numbering: Numbering, + pub comments: BTreeMap, + pub revisions: Vec, + pub settings: Settings, + pub core: CoreProps, + /// Embedded media (images) by key. + #[serde(skip)] + pub media: BTreeMap>>, + /// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name. + #[serde(skip)] + pub passthrough: BTreeMap>>, +} + +impl Default for Document { + fn default() -> Self { + Document::new() + } +} + +impl Document { + /// A blank document: one empty Normal paragraph, Letter, built-in styles. + pub fn new() -> Self { + Document { + body: vec![para_block(Paragraph::new())], + last_section: SectionProps::default(), + parts: BTreeMap::new(), + styles: StyleSheet::builtin(), + numbering: Numbering::default(), + comments: BTreeMap::new(), + revisions: Vec::new(), + settings: Settings::default(), + core: CoreProps::default(), + media: BTreeMap::new(), + passthrough: BTreeMap::new(), + } + } + + /// A document from plain text, one paragraph per line. + pub fn from_text(text: &str) -> Self { + let mut d = Document::new(); + d.body = text.split('\n').map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect(); + d.ensure_nonempty(); + d + } + + /// Every story must hold at least one paragraph, and every cell too. + pub fn ensure_nonempty(&mut self) { + fn fix(bl: &mut Blocks) { + if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + for b in bl.iter_mut() { + if let Block::Table(t) = &**b { + let needs = t.rows.iter().any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_))))); + if needs && let Block::Table(t) = Arc::make_mut(b) { + for r in &mut t.rows { + for c in &mut r.cells { + fix_cell(&mut c.blocks); + } + } + } + } + } + } + fn fix_cell(bl: &mut Blocks) { + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + } + fix(&mut self.body); + for p in self.parts.values_mut() { + if p.blocks.is_empty() { + p.blocks.push(para_block(Paragraph::new())); + } + } + } + + pub fn story(&self, s: StoryRef) -> Option<&Blocks> { + match s { + StoryRef::Body => Some(&self.body), + StoryRef::Part(id) => self.parts.get(&id).map(|p| &p.blocks), + } + } + pub fn story_mut(&mut self, s: StoryRef) -> Result<&mut Blocks> { + match s { + StoryRef::Body => Ok(&mut self.body), + StoryRef::Part(id) => self.parts.get_mut(&id).map(|p| &mut p.blocks).ok_or(DocError::BadStory(s)), + } + } + + /// The block list that contains the block at `path`. + pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> { + let mut bl = self.story(s)?; + let parent = path.parent(); + if parent.len() % 3 != 0 { + return None; + } + for ch in parent.chunks(3) { + let [b, r, c] = ch else { return None }; + let Block::Table(t) = &**bl.get(*b as usize)? else { return None }; + bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks; + } + Some(bl) + } + + pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> { + let bad = || DocError::BadPath(path.to_string()); + let parent: Vec = path.parent().to_vec(); + if parent.len() % 3 != 0 { + return Err(bad()); + } + let mut bl = self.story_mut(s)?; + for ch in parent.chunks(3) { + let [b, r, c] = ch else { return Err(bad()) }; + let blk = bl.get_mut(*b as usize).ok_or_else(bad)?; + let Block::Table(t) = Arc::make_mut(blk) else { return Err(bad()) }; + bl = &mut t.rows.get_mut(*r as usize).ok_or_else(bad)?.cells.get_mut(*c as usize).ok_or_else(bad)?.blocks; + } + Ok(bl) + } + + pub fn block(&self, s: StoryRef, path: &Path) -> Option<&Block> { + self.container(s, path)?.get(path.last()).map(|b| &**b) + } + + pub fn para(&self, s: StoryRef, path: &Path) -> Option<&Paragraph> { + self.block(s, path)?.as_para() + } + pub fn para_at(&self, p: &Pos) -> Option<&Paragraph> { + self.para(p.story, &p.path) + } + + pub fn para_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Paragraph> { + let i = path.last(); + let bl = self.container_mut(s, path)?; + match bl.get_mut(i).map(Arc::make_mut) { + Some(Block::Para(p)) => Ok(p), + _ => Err(DocError::BadPath(path.to_string())), + } + } + + pub fn table(&self, s: StoryRef, path: &Path) -> Option<&Table> { + self.block(s, path)?.as_table() + } + pub fn table_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Table> { + let i = path.last(); + let bl = self.container_mut(s, path)?; + match bl.get_mut(i).map(Arc::make_mut) { + Some(Block::Table(t)) => Ok(t), + _ => Err(DocError::BadPath(path.to_string())), + } + } + + /// Paths of every paragraph in a story, in document order (tables descended, depth-limited). + pub fn para_paths(&self, s: StoryRef) -> Vec { + let mut out = Vec::new(); + if let Some(bl) = self.story(s) { + walk(bl, &mut Vec::new(), &mut out, 0); + } + out + } + + /// First and last positions of a story. + pub fn start_of(&self, s: StoryRef) -> Pos { + let path = self.para_paths(s).into_iter().next().unwrap_or_else(|| Path::top(0)); + Pos { story: s, path, off: 0 } + } + pub fn end_of(&self, s: StoryRef) -> Pos { + let path = self.para_paths(s).pop().unwrap_or_else(|| Path::top(0)); + let off = self.para(s, &path).map(|p| p.len()).unwrap_or(0); + Pos { story: s, path, off } + } + + /// The next / previous paragraph path in document order. + pub fn next_para(&self, s: StoryRef, path: &Path) -> Option { + let all = self.para_paths(s); + let i = all.binary_search(path).ok()?; + all.get(i + 1).cloned() + } + pub fn prev_para(&self, s: StoryRef, path: &Path) -> Option { + let all = self.para_paths(s); + let i = all.binary_search(path).ok()?; + i.checked_sub(1).and_then(|j| all.get(j).cloned()) + } + + /// Clamp a position into the document (valid path, char boundary). + pub fn clamp(&self, p: &Pos) -> Pos { + if let Some(para) = self.para_at(p) { + return Pos { story: p.story, path: p.path.clone(), off: para.clamp(p.off) }; + } + let story = if self.story(p.story).is_some() { p.story } else { StoryRef::Body }; + let all = self.para_paths(story); + let path = all.iter().rev().find(|q| **q <= p.path).or(all.first()).cloned().unwrap_or_else(|| Path::top(0)); + let off = self.para(story, &path).map(|q| q.clamp(p.off)).unwrap_or(0); + Pos { story, path, off } + } + + /// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`). + pub fn plain_text(&self, s: StoryRef) -> String { + let mut out = String::new(); + if let Some(bl) = self.story(s) { + text_of(bl, &mut out, 0); + } + if out.ends_with('\n') { + out.pop(); + } + out + } + + /// Section properties in effect for each top-level body block (index → section number), and + /// the list of sections. + pub fn sections(&self) -> Vec<(usize, &SectionProps)> { + let mut v = Vec::new(); + for (i, b) in self.body.iter().enumerate() { + if let Block::Para(p) = &**b + && let Some(s) = &p.section + { + v.push((i, &**s)); + } + } + v.push((self.body.len().saturating_sub(1), &self.last_section)); + v + } + + /// The section that contains top-level body block `block` (index into `sections()`). + pub fn section_index_of(&self, block: usize) -> usize { + self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0) + } + + /// Mutable section properties for the section containing body block `block`. + pub fn section_mut(&mut self, block: usize) -> &mut SectionProps { + let ends: Vec = self.sections().iter().map(|(e, _)| *e).collect(); + let idx = ends.iter().position(|e| block <= *e).unwrap_or(ends.len().saturating_sub(1)); + if idx + 1 < ends.len() + && let Some(e) = ends.get(idx) + && let Some(Block::Para(p)) = self.body.get_mut(*e).map(Arc::make_mut) + && let Some(s) = p.section.as_mut() + { + return s; + } + &mut self.last_section + } + + /// Allocate a new part id and insert the part. + pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 { + let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1); + let mut part = Part { kind, blocks }; + if part.blocks.is_empty() { + part.blocks.push(para_block(Paragraph::new())); + } + self.parts.insert(id, part); + id + } + + /// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit). + pub fn word_count(&self) -> usize { + count_words(&self.plain_text(StoryRef::Body)) + } + + /// Number of paragraphs in the body (all depths). + pub fn paragraph_count(&self) -> usize { + self.para_paths(StoryRef::Body).len() + } + + /// Add a media item; returns its key (deduplicated by content). + pub fn add_media(&mut self, bytes: Vec, ext: &str) -> String { + if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) { + return k.clone(); + } + let n = self.media.len() + 1; + let mut key = format!("image{n}.{ext}"); + let mut i = n; + while self.media.contains_key(&key) { + i += 1; + key = format!("image{i}.{ext}"); + } + self.media.insert(key.clone(), Arc::new(bytes)); + key + } + + /// Bookmark names in the body, in order. + pub fn bookmarks(&self) -> Vec<(String, Pos)> { + let mut out = Vec::new(); + for path in self.para_paths(StoryRef::Body) { + if let Some(p) = self.para(StoryRef::Body, &path) { + for off in p.object_offsets() { + if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) { + out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off })); + } + } + } + } + out + } +} + +/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char). +pub fn count_words(s: &str) -> usize { + s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count() +} + +fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { + if depth > 16 { + return; + } + for (i, b) in bl.iter().enumerate() { + prefix.push(i as u32); + match &**b { + Block::Para(_) => out.push(Path(prefix.clone())), + Block::Table(t) => { + for (r, row) in t.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate() { + prefix.push(r as u32); + prefix.push(c as u32); + walk(&cell.blocks, prefix, out, depth + 1); + prefix.pop(); + prefix.pop(); + } + } + } + } + prefix.pop(); + } +} + +fn text_of(bl: &Blocks, out: &mut String, depth: usize) { + if depth > 16 { + return; + } + for b in bl { + match &**b { + Block::Para(p) => { + out.push_str(&p.plain_text()); + out.push('\n'); + } + Block::Table(t) => { + for row in &t.rows { + let mut cells = Vec::new(); + for c in &row.cells { + let mut s = String::new(); + text_of(&c.blocks, &mut s, depth + 1); + cells.push(s.trim_end_matches('\n').replace('\n', " ")); + } + out.push_str(&cells.join("\t")); + out.push('\n'); + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs new file mode 100644 index 0000000..d90d281 --- /dev/null +++ b/crates/doc/src/numbering.rs @@ -0,0 +1,311 @@ +//! Lists: abstract numbering definitions (9 levels each), numbering instances, and the counter +//! state used to compute list labels. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::props::{Align, CharProps}; +use crate::section::NumFormat; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LevelSuffix { + #[default] + Tab, + Space, + Nothing, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Level { + pub start: u32, + pub format: NumFormat, + /// Label template: `%1.`, `%1.%2.`, or the bullet character. + pub text: String, + pub align: Align, + /// Left indent of the text, points. + pub indent: f32, + /// Hanging indent (label sits this far left of `indent`), points. + pub hanging: f32, + pub suffix: LevelSuffix, + /// Label formatting (bullet font etc.). + pub chr: CharProps, + /// Restart after a higher level (true = after any higher level, the default). + pub restart: bool, + /// Show all levels as decimal (legal numbering). + pub legal: bool, + /// Paragraph style linked to this level (e.g. Heading 1 for outline numbering). + pub style: Option, +} + +impl Default for Level { + fn default() -> Self { + Level { + start: 1, + format: NumFormat::Decimal, + text: "%1.".into(), + align: Align::Left, + indent: 36.0, + hanging: 18.0, + suffix: LevelSuffix::Tab, + chr: CharProps::default(), + restart: true, + legal: false, + style: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct AbstractNum { + pub id: u32, + pub name: Option, + pub levels: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct Num { + pub id: u32, + pub abstract_id: u32, + /// Per-level start overrides (level, start). + pub start_overrides: Vec<(u8, u32)>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Numbering { + pub abstracts: Vec, + pub nums: Vec, +} + +/// Built-in list kinds for the Bullets / Numbering / Multilevel galleries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListKind { + /// • ○ ▪ repeating. + Bullet, + /// A specific bullet character. + BulletChar(char), + /// 1. a. i. + Numbered, + /// 1) a) i) + NumberedParen, + /// I. A. 1. + Outline, + /// A. B. C. + UpperLetter, + /// a) b) c) + LowerLetter, + /// i. ii. iii. + LowerRoman, + /// 1. 1.1. 1.1.1. + Legal, +} + +impl ListKind { + pub fn parse(s: &str) -> Option { + Some(match s { + "bullet" => ListKind::Bullet, + "numbered" | "number" => ListKind::Numbered, + "numberedParen" => ListKind::NumberedParen, + "outline" => ListKind::Outline, + "upperLetter" => ListKind::UpperLetter, + "lowerLetter" => ListKind::LowerLetter, + "lowerRoman" => ListKind::LowerRoman, + "legal" | "multilevel" => ListKind::Legal, + s if s.chars().count() == 1 => ListKind::BulletChar(s.chars().next()?), + _ => return None, + }) + } + pub fn is_bullet(self) -> bool { + matches!(self, ListKind::Bullet | ListKind::BulletChar(_)) + } +} + +fn bullet_level(i: usize, c: char) -> Level { + Level { + format: NumFormat::Bullet, + text: c.to_string(), + indent: 36.0 * (i as f32 + 1.0), + hanging: 18.0, + chr: CharProps { font: Some(if c == '•' || c == '▪' || c == '○' { "Arial".into() } else { "Segoe UI Symbol".into() }), ..CharProps::default() }, + ..Level::default() + } +} + +/// Nine levels for a list kind. +pub fn levels_for(kind: ListKind) -> Vec { + (0..9) + .map(|i| { + let ind = 36.0 * (i as f32 + 1.0); + let cyc = |a: NumFormat, b: NumFormat, c: NumFormat| match i % 3 { + 0 => a, + 1 => b, + _ => c, + }; + let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() }; + let n = i + 1; + match kind { + ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]), + ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }), + ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")), + ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")), + ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")), + ListKind::LowerLetter => lvl(cyc(NumFormat::LowerLetter, NumFormat::LowerRoman, NumFormat::Decimal), format!("%{n})")), + ListKind::LowerRoman => lvl(cyc(NumFormat::LowerRoman, NumFormat::LowerLetter, NumFormat::Decimal), format!("%{n}.")), + ListKind::Outline => { + let f = [NumFormat::UpperRoman, NumFormat::UpperLetter, NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman] + .get(i) + .copied() + .unwrap_or(NumFormat::Decimal); + lvl(f, format!("%{n}.")) + } + ListKind::Legal => { + let text: String = (1..=n).map(|k| format!("%{k}.")).collect(); + Level { format: NumFormat::Decimal, text, indent: 18.0 + 25.0 * i as f32, hanging: 18.0 + 7.0 * i as f32, ..Level::default() } + } + } + }) + .collect() +} + +impl Numbering { + pub fn num(&self, id: u32) -> Option<&Num> { + self.nums.iter().find(|n| n.id == id) + } + pub fn abstract_of(&self, num: u32) -> Option<&AbstractNum> { + let n = self.num(num)?; + self.abstracts.iter().find(|a| a.id == n.abstract_id) + } + pub fn level(&self, num: u32, level: u8) -> Option<&Level> { + self.abstract_of(num)?.levels.get(level as usize) + } + /// Create a new list of `kind`; returns the `num` id. + pub fn add_list(&mut self, kind: ListKind) -> u32 { + let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0); + self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) }); + let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); + self.nums.push(Num { id: nid, abstract_id: aid, start_overrides: Vec::new() }); + nid + } + /// A new `num` that restarts numbering of an existing list. + pub fn restart(&mut self, num: u32) -> Option { + let a = self.num(num)?.abstract_id; + let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); + let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect()); + self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default() }); + Some(nid) + } + /// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it. + pub fn find_kind(&self, kind: ListKind) -> Option { + let want = levels_for(kind); + let first = want.first()?; + self.nums.iter().find(|n| self.abstract_of(n.id).and_then(|a| a.levels.first()).is_some_and(|l| l.format == first.format && l.text == first.text)).map(|n| n.id) + } +} + +/// Running list counters while walking a document in order. +#[derive(Default, Debug, Clone)] +pub struct Counters { + /// Per abstract list: current value per level. + state: HashMap, + /// Which nums have started (to apply start overrides once). + seen: HashMap, +} + +impl Counters { + /// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`). + pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> { + let n = numbering.num(num)?; + let abs = numbering.abstract_of(num)?; + let lv = (level as usize).min(8); + let def = abs.levels.get(lv)?.clone(); + let st = self.state.entry(abs.id).or_insert([0; 9]); + if !self.seen.contains_key(&num) { + self.seen.insert(num, true); + for (l, start) in &n.start_overrides { + if let Some(s) = st.get_mut(*l as usize) { + *s = start.saturating_sub(1); + } + } + } + if let Some(v) = st.get_mut(lv) { + *v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) }; + if *v == 0 { + *v = def.start; + } + } + for deeper in st.iter_mut().skip(lv + 1) { + *deeper = 0; + } + let vals = *st; + if def.format == NumFormat::Bullet { + return Some((def.text.clone(), def)); + } + let mut label = String::new(); + let mut chars = def.text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' + && let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) + { + chars.next(); + let k = (d as usize).saturating_sub(1).min(8); + let val = vals.get(k).copied().unwrap_or(0); + let val = if val == 0 { abs.levels.get(k).map(|l| l.start).unwrap_or(1) } else { val }; + let fmt = if def.legal && k != lv { NumFormat::Decimal } else { abs.levels.get(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) }; + label.push_str(&fmt.format(val)); + } else { + label.push(c); + } + } + Some((label, def)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbered_labels() { + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let mut c = Counters::default(); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1."); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "2."); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "a."); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "b."); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "3."); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "a."); + assert!(c.next_label(&n, 999, 0).is_none()); + } + + #[test] + fn legal_labels() { + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Legal); + let mut c = Counters::default(); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1."); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "1.1."); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, "1.1.1."); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "1.2."); + } + + #[test] + fn restart_and_bullets() { + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let mut c = Counters::default(); + c.next_label(&n, id, 0); + c.next_label(&n, id, 0); + let r = n.restart(id).unwrap(); + assert_eq!(c.next_label(&n, r, 0).unwrap().0, "1."); + let b = n.add_list(ListKind::Bullet); + assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•"); + assert_eq!(n.find_kind(ListKind::Bullet), Some(b)); + } +} diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs new file mode 100644 index 0000000..07a1342 --- /dev/null +++ b/crates/doc/src/para.rs @@ -0,0 +1,805 @@ +//! Paragraphs: text with formatting runs and inline objects. +//! +//! A paragraph's text is one `String`. Formatting is a list of [`Run`]s whose byte lengths sum to +//! the text length. Inline objects (pictures, fields, note references, bookmarks, comment +//! anchors…) sit in the text as U+FFFC; the k-th U+FFFC is `objects[k]`. Special characters: +//! `\t` tab, `\n` manual line break, U+000C page break, U+000E column break. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use crate::props::{CharProps, ParaProps}; +use crate::section::SectionProps; +use crate::{DocError, Result}; + +/// Object replacement character: marks an inline object in paragraph text. +pub const OBJ: char = '\u{FFFC}'; +/// Manual line break (Shift+Enter). +pub const LINE_BREAK: char = '\n'; +/// Page break (Ctrl+Enter). +pub const PAGE_BREAK: char = '\u{000C}'; +/// Column break (Ctrl+Shift+Enter). +pub const COLUMN_BREAK: char = '\u{000E}'; +/// Optional hyphen (Ctrl+-). +pub const SOFT_HYPHEN: char = '\u{00AD}'; +/// Nonbreaking hyphen (Ctrl+Shift+-). +pub const NB_HYPHEN: char = '\u{2011}'; +/// Nonbreaking space (Ctrl+Shift+Space). +pub const NBSP: char = '\u{00A0}'; + +static REV: AtomicU64 = AtomicU64::new(1); + +/// A fresh revision number (process-unique), used by layout caches to detect edits. +pub fn next_rev() -> u64 { + REV.fetch_add(1, Ordering::Relaxed) +} + +/// A formatting run: `len` bytes of text sharing `props`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Run { + pub len: usize, + #[serde(default, skip_serializing_if = "CharProps::is_empty")] + pub props: CharProps, +} + +/// How a floating object is placed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Wrap { + /// In line with text (not floating). + #[default] + Inline, + Square, + Tight, + Through, + TopAndBottom, + BehindText, + InFrontOfText, +} + +/// What a floating object's offset is relative to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Anchor { + #[default] + Column, + Margin, + Page, + Paragraph, +} + +/// Floating placement (ignored when `wrap` is `Inline`). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct Float { + pub wrap: Wrap, + pub h_rel: Anchor, + pub v_rel: Anchor, + /// Offsets, points. + pub x: f32, + pub y: f32, + /// Distance from surrounding text (points). + pub dist: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ShapeKind { + #[default] + Rectangle, + RoundedRectangle, + Ellipse, + Triangle, + Diamond, + Line, + Arrow, + Star, + Heart, + /// A text box (rectangle with a text story). + TextBox, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum NoteKind { + #[default] + Footnote, + Endnote, +} + +/// An inline object anchored at a U+FFFC in the paragraph text. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum InlineObject { + Image { + /// Key in `Document::media`. + media: String, + /// Display size, points. + w: f32, + h: f32, + #[serde(default)] + alt: String, + #[serde(default)] + float: Float, + /// Crop (left, top, right, bottom) fractions 0..1. + #[serde(default)] + crop: [f32; 4], + }, + Shape { + kind: ShapeKind, + w: f32, + h: f32, + fill: Option, + stroke: Option, + #[serde(default)] + stroke_width: f32, + #[serde(default)] + float: Float, + /// Text box content: `Document::parts` id. + #[serde(default)] + story: Option, + }, + /// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…); + /// `result` the cached display text. + Field { + instr: String, + #[serde(default)] + result: String, + #[serde(default)] + locked: bool, + }, + NoteRef { + kind: NoteKind, + /// `Document::parts` id of the note's story. + id: u32, + /// Custom mark (empty = automatic number). + #[serde(default)] + custom: String, + }, + BookmarkStart { + name: String, + }, + BookmarkEnd { + name: String, + }, + CommentStart { + id: u32, + }, + CommentEnd { + id: u32, + }, + /// An equation in linear format (`x=(-b±√(b^2-4ac))/2a`). + Equation { + linear: String, + #[serde(default)] + display: bool, + }, + /// Something we don't model, kept for round-trip (raw XML of the source format). + Opaque { + format: String, + xml: String, + #[serde(default)] + text: String, + }, +} + +impl InlineObject { + /// Zero-width markers don't take part in layout. + pub fn is_marker(&self) -> bool { + matches!( + self, + InlineObject::BookmarkStart { .. } | InlineObject::BookmarkEnd { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. } + ) + } + pub fn is_floating(&self) -> bool { + match self { + InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline, + _ => false, + } + } + /// The text this object contributes to plain-text extraction. + pub fn plain_text(&self) -> &str { + match self { + InlineObject::Field { result, .. } => result, + InlineObject::Equation { linear, .. } => linear, + InlineObject::Opaque { text, .. } => text, + _ => "", + } + } +} + +/// A paragraph. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Paragraph { + pub text: String, + pub runs: Vec, + #[serde(default, skip_serializing_if = "ParaProps::is_empty")] + pub props: ParaProps, + /// Formatting of the paragraph mark (used when typing into an empty paragraph). + #[serde(default, skip_serializing_if = "CharProps::is_empty")] + pub mark: CharProps, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub objects: Vec, + /// Set when this paragraph ends a section (OOXML keeps `w:sectPr` on the last paragraph). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section: Option>, + /// Change counter for caches (not part of equality). + #[serde(skip, default = "next_rev")] + pub rev: u64, +} + +impl PartialEq for Paragraph { + fn eq(&self, o: &Self) -> bool { + self.text == o.text && self.runs == o.runs && self.props == o.props && self.mark == o.mark && self.objects == o.objects && self.section == o.section + } +} + +impl Default for Paragraph { + fn default() -> Self { + Paragraph::new() + } +} + +impl Paragraph { + pub fn new() -> Self { + Paragraph { text: String::new(), runs: Vec::new(), props: ParaProps::default(), mark: CharProps::default(), objects: Vec::new(), section: None, rev: next_rev() } + } + /// A paragraph of plain text in one run. + pub fn with_text(text: &str, props: CharProps) -> Self { + let mut p = Paragraph::new(); + let clean: String = text.chars().filter(|c| *c != OBJ).collect(); + if !clean.is_empty() { + p.runs.push(Run { len: clean.len(), props: props.clone() }); + } + p.text = clean; + p.mark = props; + p + } + pub fn styled(mut self, style: &str) -> Self { + self.props.style = Some(style.to_string()); + self + } + pub fn touch(&mut self) { + self.rev = next_rev(); + } + pub fn len(&self) -> usize { + self.text.len() + } + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + /// The text with objects removed or replaced by their text (fields → result). + pub fn plain_text(&self) -> String { + let mut out = String::with_capacity(self.text.len()); + let mut k = 0; + for c in self.text.chars() { + if c == OBJ { + if let Some(o) = self.objects.get(k) { + out.push_str(o.plain_text()); + } + k += 1; + } else { + out.push(c); + } + } + out + } + + fn check(&self, off: usize) -> Result<()> { + if off > self.text.len() || !self.text.is_char_boundary(off) { + return Err(DocError::BadOffset(off)); + } + Ok(()) + } + + /// Clamp `off` to the text and back to a char boundary. + pub fn clamp(&self, off: usize) -> usize { + let mut o = off.min(self.text.len()); + while o > 0 && !self.text.is_char_boundary(o) { + o -= 1; + } + o + } + + /// Index of the run containing byte `off` and the offset where that run starts. At a run + /// boundary the earlier run wins (typing continues the formatting to the left). + fn run_at(&self, off: usize) -> Option<(usize, usize)> { + let mut start = 0; + for (i, r) in self.runs.iter().enumerate() { + let end = start + r.len; + if off < end || (off == end && off > start) { + return Some((i, start)); + } + start = end; + } + None + } + + /// Formatting in effect at `off` (what typing there would get): the run to the left, or the + /// first run at offset 0, or the paragraph mark when empty. + pub fn props_at(&self, off: usize) -> &CharProps { + if off == 0 { + return self.runs.first().map(|r| &r.props).unwrap_or(&self.mark); + } + self.run_at(off).and_then(|(i, _)| self.runs.get(i)).map(|r| &r.props).unwrap_or(&self.mark) + } + + /// Formatting of the character starting at `off`. + pub fn props_of_char(&self, off: usize) -> &CharProps { + let mut start = 0; + for r in &self.runs { + if off < start + r.len { + return &r.props; + } + start += r.len; + } + &self.mark + } + + /// Runs as (byte range, props). + pub fn run_ranges(&self) -> impl Iterator, &CharProps)> { + let mut s = 0; + self.runs.iter().map(move |r| { + let a = s; + s += r.len; + (a..s, &r.props) + }) + } + + /// Number of objects in `text[..off]`. + fn objects_before(&self, off: usize) -> usize { + self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0) + } + + /// The object at byte `off` (which must hold U+FFFC). + pub fn object_at(&self, off: usize) -> Option<&InlineObject> { + if !self.text.get(off..)?.starts_with(OBJ) { + return None; + } + self.objects.get(self.objects_before(off)) + } + pub fn object_at_mut(&mut self, off: usize) -> Option<&mut InlineObject> { + if !self.text.get(off..)?.starts_with(OBJ) { + return None; + } + let k = self.objects_before(off); + self.objects.get_mut(k) + } + /// Byte offsets of all objects, in order. + pub fn object_offsets(&self) -> Vec { + self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect() + } + + /// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length. + pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result { + self.check(off)?; + let clean: std::borrow::Cow = if s.contains(OBJ) { s.chars().filter(|c| *c != OBJ).collect::().into() } else { s.into() }; + if clean.is_empty() { + return Ok(0); + } + self.text.insert_str(off, &clean); + self.insert_run(off, clean.len(), props.clone()); + self.touch(); + Ok(clean.len()) + } + + /// Insert an object at `off`. + pub fn insert_object(&mut self, off: usize, obj: InlineObject, props: &CharProps) -> Result<()> { + self.check(off)?; + let k = self.objects_before(off); + self.text.insert(off, OBJ); + self.objects.insert(k.min(self.objects.len()), obj); + self.insert_run(off, OBJ.len_utf8(), props.clone()); + self.touch(); + Ok(()) + } + + fn insert_run(&mut self, off: usize, len: usize, props: CharProps) { + // Split the run at `off`, insert, merge neighbours. + let mut start = 0; + let mut idx = self.runs.len(); + for (i, r) in self.runs.iter().enumerate() { + if off <= start { + idx = i; + break; + } + if off < start + r.len { + // split + let left = off - start; + let right = r.len - left; + let p = r.props.clone(); + if let Some(r) = self.runs.get_mut(i) { + r.len = left; + } + self.runs.insert(i + 1, Run { len: right, props: p }); + idx = i + 1; + break; + } + start += r.len; + } + self.runs.insert(idx.min(self.runs.len()), Run { len, props }); + self.normalize(); + } + + /// Merge adjacent equal runs and drop empty ones. + pub fn normalize(&mut self) { + let mut out: Vec = Vec::with_capacity(self.runs.len()); + for r in self.runs.drain(..) { + if r.len == 0 { + continue; + } + match out.last_mut() { + Some(l) if l.props == r.props => l.len += r.len, + _ => out.push(r), + } + } + self.runs = out; + // Repair a run list that doesn't cover the text exactly (never expected, but files lie). + let total: usize = self.runs.iter().map(|r| r.len).sum(); + if total != self.text.len() { + if total < self.text.len() { + let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone()); + self.runs.push(Run { len: self.text.len() - total, props: p }); + } else { + let mut keep = self.text.len(); + for r in &mut self.runs { + r.len = r.len.min(keep); + keep -= r.len; + } + self.runs.retain(|r| r.len > 0); + } + } + } + + /// Delete `a..b` (bytes). Objects inside are removed. + pub fn delete(&mut self, a: usize, b: usize) -> Result<()> { + let (a, b) = (a.min(b), a.max(b)); + self.check(a)?; + self.check(b)?; + if a == b { + return Ok(()); + } + if self.runs.is_empty() || a == 0 { + // Keep the formatting of what's deleted for the mark when emptying. + if b == self.text.len() { + self.mark = self.props_of_char(a).clone(); + } + } + let k0 = self.objects_before(a); + let k1 = self.objects_before(b); + if k1 > k0 && k1 <= self.objects.len() { + self.objects.drain(k0..k1); + } + self.text.replace_range(a..b, ""); + let mut start = 0; + for r in &mut self.runs { + let (rs, re) = (start, start + r.len); + start = re; + let ov = re.min(b).saturating_sub(rs.max(a)); + r.len -= ov.min(r.len); + } + self.normalize(); + self.touch(); + Ok(()) + } + + /// Apply `f` to the formatting of `a..b`. + pub fn format(&mut self, a: usize, b: usize, f: &dyn Fn(&mut CharProps)) -> Result<()> { + let (a, b) = (a.min(b), a.max(b)); + self.check(a)?; + self.check(b)?; + if a == b { + return Ok(()); + } + let mut out = Vec::with_capacity(self.runs.len() + 2); + let mut start = 0; + for r in self.runs.drain(..) { + let (rs, re) = (start, start + r.len); + start = re; + let cut = [rs, a.clamp(rs, re), b.clamp(rs, re), re]; + for w in cut.windows(2) { + let (x, y) = (w[0], w[1]); + if y <= x { + continue; + } + let mut p = r.props.clone(); + if x >= a && y <= b { + f(&mut p); + } + out.push(Run { len: y - x, props: p }); + } + } + self.runs = out; + self.normalize(); + self.touch(); + Ok(()) + } + + /// Split at `off`: `self` keeps `..off`, the returned paragraph gets `off..` with the same + /// paragraph properties (the section break, if any, moves to the second half). + pub fn split_off(&mut self, off: usize) -> Result { + self.check(off)?; + let k = self.objects_before(off); + let tail_text = self.text.split_off(off); + let tail_objs = if k <= self.objects.len() { self.objects.split_off(k) } else { Vec::new() }; + let mut tail_runs = Vec::new(); + let mut start = 0; + let mut keep = Vec::new(); + for r in self.runs.drain(..) { + let (rs, re) = (start, start + r.len); + start = re; + if re <= off { + keep.push(r); + } else if rs >= off { + tail_runs.push(r); + } else { + keep.push(Run { len: off - rs, props: r.props.clone() }); + tail_runs.push(Run { len: re - off, props: r.props }); + } + } + self.runs = keep; + let carry = self.props_at(off).clone(); + let mut tail = Paragraph { + text: tail_text, + runs: tail_runs, + props: self.props.clone(), + mark: if self.text.is_empty() && off == 0 { self.mark.clone() } else { carry }, + objects: tail_objs, + section: self.section.take(), + rev: next_rev(), + }; + if tail.text.is_empty() { + tail.mark = self.props_at(off).clone(); + } + self.normalize(); + tail.normalize(); + self.touch(); + Ok(tail) + } + + /// Append `other`'s content (its paragraph properties are dropped; its section break kept). + pub fn append(&mut self, other: Paragraph) { + let Paragraph { text, runs, objects, section, .. } = other; + self.text.push_str(&text); + self.runs.extend(runs); + self.objects.extend(objects); + if section.is_some() { + self.section = section; + } + self.normalize(); + self.touch(); + } + + /// Previous grapheme boundary before `off` (0 at start). + pub fn prev_boundary(&self, off: usize) -> usize { + use unicode_segmentation::GraphemeCursor; + let off = self.clamp(off); + let mut c = GraphemeCursor::new(off, self.text.len(), true); + c.prev_boundary(&self.text, 0).ok().flatten().unwrap_or(0) + } + /// Next grapheme boundary after `off` (len at end). + pub fn next_boundary(&self, off: usize) -> usize { + use unicode_segmentation::GraphemeCursor; + let off = self.clamp(off); + let mut c = GraphemeCursor::new(off, self.text.len(), true); + c.next_boundary(&self.text, 0).ok().flatten().unwrap_or(self.text.len()) + } + /// Start of the word at or before `off` (Ctrl+Left). + pub fn word_start(&self, off: usize) -> usize { + let off = self.clamp(off); + let Some(before) = self.text.get(..off) else { return 0 }; + let mut idx = off; + let mut seen_word = false; + for (i, c) in before.char_indices().rev() { + let w = c.is_alphanumeric() || c == '_' || c == '\''; + if w { + seen_word = true; + idx = i; + } else if seen_word { + break; + } else { + idx = i; + } + } + idx + } + /// End of the word after `off`, including trailing spaces (Ctrl+Right, like Word). + pub fn word_end(&self, off: usize) -> usize { + let off = self.clamp(off); + let Some(after) = self.text.get(off..) else { return self.text.len() }; + let mut it = after.char_indices().peekable(); + let mut end = self.text.len(); + let first_word = after.chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_'); + let mut in_space = false; + while let Some((i, c)) = it.next() { + let w = c.is_alphanumeric() || c == '_' || c == '\''; + if c == ' ' || c == NBSP { + in_space = true; + continue; + } + if in_space || (first_word && !w) || (!first_word && i > 0) { + end = off + i; + break; + } + } + end + } + /// The word around `off` (double-click): (start, end) without trailing space. + pub fn word_at(&self, off: usize) -> (usize, usize) { + let off = self.clamp(off); + let is_w = |c: char| c.is_alphanumeric() || c == '_' || c == '\''; + let mut a = off; + for (i, c) in self.text.get(..off).unwrap_or("").char_indices().rev() { + if !is_w(c) { + break; + } + a = i; + } + let mut b = off; + for (i, c) in self.text.get(off..).unwrap_or("").char_indices() { + if !is_w(c) { + if i == 0 && a == off { + b = off + c.len_utf8(); + } + break; + } + b = off + i + c.len_utf8(); + } + // Word also selects the trailing spaces. + let mut e = b; + for (i, c) in self.text.get(b..).unwrap_or("").char_indices() { + if c != ' ' { + break; + } + e = b + i + 1; + } + (a, e) + } + /// Sentence around `off` (Ctrl+click): ends after `.`, `!`, `?` and following spaces. + pub fn sentence_at(&self, off: usize) -> (usize, usize) { + let off = self.clamp(off); + let t = &self.text; + let mut a = 0; + let mut prev_end = false; + for (i, c) in t.char_indices() { + if i >= off { + break; + } + if prev_end && c != ' ' { + a = i; + } + if matches!(c, '.' | '!' | '?') { + prev_end = true; + } else if c != ' ' { + prev_end = false; + } + } + let mut b = t.len(); + let mut ended = false; + for (i, c) in t.get(off..).unwrap_or("").char_indices() { + if ended && c != ' ' { + b = off + i; + break; + } + if matches!(c, '.' | '!' | '?') { + ended = true; + } + } + (a, b) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bold() -> CharProps { + CharProps { bold: Some(true), ..Default::default() } + } + + #[test] + fn insert_and_runs() { + let mut p = Paragraph::with_text("Hello world", CharProps::default()); + p.insert_text(5, ",", &CharProps::default()).unwrap(); + assert_eq!(p.text, "Hello, world"); + assert_eq!(p.runs.len(), 1); + p.insert_text(0, "Oh ", &bold()).unwrap(); + assert_eq!(p.runs.len(), 2); + assert_eq!(p.runs[0].len, 3); + assert!(p.insert_text(100, "x", &bold()).is_err()); + } + + #[test] + fn insert_rejects_mid_char() { + let mut p = Paragraph::with_text("é", CharProps::default()); + assert!(p.insert_text(1, "x", &bold()).is_err()); + } + + #[test] + fn format_splits_runs() { + let mut p = Paragraph::with_text("abcdef", CharProps::default()); + p.format(2, 4, &|c| c.bold = Some(true)).unwrap(); + assert_eq!(p.runs.iter().map(|r| r.len).collect::>(), vec![2, 2, 2]); + assert_eq!(p.props_of_char(2).bold, Some(true)); + p.format(0, 6, &|c| c.bold = Some(true)).unwrap(); + assert_eq!(p.runs.len(), 1); + } + + #[test] + fn delete_merges() { + let mut p = Paragraph::with_text("abcdef", CharProps::default()); + p.format(2, 4, &|c| c.bold = Some(true)).unwrap(); + p.delete(2, 4).unwrap(); + assert_eq!(p.text, "abef"); + assert_eq!(p.runs.len(), 1); + p.delete(0, 4).unwrap(); + assert!(p.runs.is_empty()); + } + + #[test] + fn objects_track_text() { + let mut p = Paragraph::with_text("ab", CharProps::default()); + p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default()).unwrap(); + p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap(); + assert_eq!(p.objects.len(), 2); + assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. }))); + let off = p.object_offsets()[1]; + assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. }))); + assert_eq!(p.plain_text(), "a1b"); + p.delete(0, 3).unwrap(); + assert_eq!(p.objects.len(), 1); + let tail = p.split_off(p.object_offsets()[0]).unwrap(); + assert!(p.objects.is_empty()); + assert_eq!(tail.objects.len(), 1); + } + + #[test] + fn split_and_append() { + let mut p = Paragraph::with_text("Hello world", bold()); + let t = p.split_off(5).unwrap(); + assert_eq!(p.text, "Hello"); + assert_eq!(t.text, " world"); + assert_eq!(t.runs[0].props.bold, Some(true)); + p.append(t); + assert_eq!(p.text, "Hello world"); + assert_eq!(p.runs.len(), 1); + let t = p.split_off(p.len()).unwrap(); + assert!(t.is_empty()); + assert_eq!(t.mark.bold, Some(true)); + } + + #[test] + fn words() { + let p = Paragraph::with_text("The quick brown fox.", CharProps::default()); + assert_eq!(p.word_end(0), 4); + assert_eq!(p.word_end(4), 11); + assert_eq!(p.word_start(10), 4); + assert_eq!(p.word_start(11), 4); + assert_eq!(p.word_at(5), (4, 11)); + assert_eq!(p.sentence_at(3), (0, 21)); + assert_eq!(p.next_boundary(0), 1); + assert_eq!(p.prev_boundary(0), 0); + } + + proptest::proptest! { + #[test] + fn edits_keep_runs_consistent(ops in proptest::collection::vec((0u8..4, 0usize..40, 0usize..40, "[a-zé ]{0,5}"), 0..40)) { + let mut p = Paragraph::with_text("seed text", CharProps::default()); + for (k, a, b, s) in ops { + let a = p.clamp(a); + let b = p.clamp(b); + let _ = match k { + 0 => p.insert_text(a, &s, &bold()).map(|_| ()), + 1 => p.delete(a, b), + 2 => p.format(a, b, &|c| c.italic = Some(true)), + _ => p.split_off(a).map(|t| p.append(t)), + }; + let total: usize = p.runs.iter().map(|r| r.len).sum(); + proptest::prop_assert_eq!(total, p.text.len()); + proptest::prop_assert!(p.runs.iter().all(|r| r.len > 0)); + proptest::prop_assert_eq!(p.text.matches(OBJ).count(), p.objects.len()); + } + } + } +} diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs new file mode 100644 index 0000000..e26357f --- /dev/null +++ b/crates/doc/src/props.rs @@ -0,0 +1,713 @@ +//! Character, paragraph, table and cell properties. +//! +//! Every field is an `Option`: `None` means "inherit" (from the style chain, then document +//! defaults). [`CharProps::overlay`] and friends apply a patch on top of a base. + +use serde::{Deserialize, Serialize}; + +/// An sRGB colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + pub const BLACK: Rgb = Rgb(0, 0, 0); + pub const WHITE: Rgb = Rgb(255, 255, 255); + /// `RRGGBB` (no `#`). + pub fn hex(self) -> String { + format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2) + } + /// Parse `RRGGBB` or `#RRGGBB`. + pub fn parse(s: &str) -> Option { + let s = s.trim().trim_start_matches('#'); + if s.len() != 6 || !s.is_ascii() { + return None; + } + let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + Some(Rgb(p(0)?, p(2)?, p(4)?)) + } + pub fn luma(self) -> f32 { + 0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32 + } +} + +/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum TextColor { + #[default] + Auto, + Rgb(Rgb), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Underline { + #[default] + None, + Single, + Words, + Double, + Thick, + Dotted, + Dash, + DotDash, + DotDotDash, + Wave, + DoubleWave, +} + +impl Underline { + pub const ALL: [Underline; 11] = [ + Underline::None, + Underline::Single, + Underline::Words, + Underline::Double, + Underline::Thick, + Underline::Dotted, + Underline::Dash, + Underline::DotDash, + Underline::DotDotDash, + Underline::Wave, + Underline::DoubleWave, + ]; + /// The OOXML `w:u/@w:val` name. + pub fn ooxml(self) -> &'static str { + match self { + Underline::None => "none", + Underline::Single => "single", + Underline::Words => "words", + Underline::Double => "double", + Underline::Thick => "thick", + Underline::Dotted => "dotted", + Underline::Dash => "dash", + Underline::DotDash => "dotDash", + Underline::DotDotDash => "dotDotDash", + Underline::Wave => "wave", + Underline::DoubleWave => "wavyDouble", + } + } + pub fn from_ooxml(s: &str) -> Underline { + Underline::ALL.iter().copied().find(|u| u.ooxml() == s).unwrap_or(match s { + "thickDash" | "dashLong" | "dashedHeavy" | "dashLongHeavy" => Underline::Dash, + "dottedHeavy" => Underline::Dotted, + "wavyHeavy" => Underline::Wave, + "dashDotHeavy" => Underline::DotDash, + "dashDotDotHeavy" => Underline::DotDotDash, + _ => Underline::Single, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum VertAlign { + #[default] + Baseline, + Superscript, + Subscript, +} + +/// Word's 16 highlighter colours (plus none). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Highlight { + #[default] + None, + Yellow, + BrightGreen, + Turquoise, + Pink, + Blue, + Red, + DarkBlue, + Teal, + Green, + Violet, + DarkRed, + DarkYellow, + Gray50, + Gray25, + Black, + White, +} + +impl Highlight { + pub const ALL: [Highlight; 17] = [ + Highlight::None, + Highlight::Yellow, + Highlight::BrightGreen, + Highlight::Turquoise, + Highlight::Pink, + Highlight::Blue, + Highlight::Red, + Highlight::DarkBlue, + Highlight::Teal, + Highlight::Green, + Highlight::Violet, + Highlight::DarkRed, + Highlight::DarkYellow, + Highlight::Gray50, + Highlight::Gray25, + Highlight::Black, + Highlight::White, + ]; + pub fn rgb(self) -> Option { + Some(match self { + Highlight::None => return None, + Highlight::Yellow => Rgb(255, 255, 0), + Highlight::BrightGreen => Rgb(0, 255, 0), + Highlight::Turquoise => Rgb(0, 255, 255), + Highlight::Pink => Rgb(255, 0, 255), + Highlight::Blue => Rgb(0, 0, 255), + Highlight::Red => Rgb(255, 0, 0), + Highlight::DarkBlue => Rgb(0, 0, 128), + Highlight::Teal => Rgb(0, 128, 128), + Highlight::Green => Rgb(0, 128, 0), + Highlight::Violet => Rgb(128, 0, 128), + Highlight::DarkRed => Rgb(128, 0, 0), + Highlight::DarkYellow => Rgb(128, 128, 0), + Highlight::Gray50 => Rgb(128, 128, 128), + Highlight::Gray25 => Rgb(192, 192, 192), + Highlight::Black => Rgb(0, 0, 0), + Highlight::White => Rgb(255, 255, 255), + }) + } + /// OOXML `w:highlight/@w:val`. + pub fn ooxml(self) -> &'static str { + match self { + Highlight::None => "none", + Highlight::Yellow => "yellow", + Highlight::BrightGreen => "green", + Highlight::Turquoise => "cyan", + Highlight::Pink => "magenta", + Highlight::Blue => "blue", + Highlight::Red => "red", + Highlight::DarkBlue => "darkBlue", + Highlight::Teal => "darkCyan", + Highlight::Green => "darkGreen", + Highlight::Violet => "darkMagenta", + Highlight::DarkRed => "darkRed", + Highlight::DarkYellow => "darkYellow", + Highlight::Gray50 => "darkGray", + Highlight::Gray25 => "lightGray", + Highlight::Black => "black", + Highlight::White => "white", + } + } + pub fn from_ooxml(s: &str) -> Highlight { + Highlight::ALL.iter().copied().find(|h| h.ooxml() == s).unwrap_or(Highlight::None) + } + pub fn name(self) -> &'static str { + match self { + Highlight::None => "No Color", + Highlight::Yellow => "Yellow", + Highlight::BrightGreen => "Bright Green", + Highlight::Turquoise => "Turquoise", + Highlight::Pink => "Pink", + Highlight::Blue => "Blue", + Highlight::Red => "Red", + Highlight::DarkBlue => "Dark Blue", + Highlight::Teal => "Teal", + Highlight::Green => "Green", + Highlight::Violet => "Violet", + Highlight::DarkRed => "Dark Red", + Highlight::DarkYellow => "Dark Yellow", + Highlight::Gray50 => "Gray-50%", + Highlight::Gray25 => "Gray-25%", + Highlight::Black => "Black", + Highlight::White => "White", + } + } +} + +/// Character formatting (direct or in a style). `None` = inherit. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CharProps { + /// Character style id. + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font: Option, + /// Points. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub italic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub underline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub underline_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strike: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub double_strike: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub highlight: Option, + /// Character shading (background fill). + #[serde(skip_serializing_if = "Option::is_none")] + pub shading: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vert_align: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub small_caps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + /// Character spacing (expanded/condensed), points. + #[serde(skip_serializing_if = "Option::is_none")] + pub spacing: Option, + /// Horizontal scale, percent (100 = normal). + #[serde(skip_serializing_if = "Option::is_none")] + pub scale: Option, + /// Raised (+) / lowered (−) position, points. + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, + /// Kerning for fonts at or above this size (points); 0 = off. + #[serde(skip_serializing_if = "Option::is_none")] + pub kern: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub outline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub emboss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub engrave: Option, + /// BCP 47 language tag (proofing). + #[serde(skip_serializing_if = "Option::is_none")] + pub lang: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_proof: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rtl: Option, + /// Hyperlink target: a URL, or `#bookmark` for an internal link. + #[serde(skip_serializing_if = "Option::is_none")] + pub link: Option, + /// Tracked insertion: index into `Document::revisions`. + #[serde(skip_serializing_if = "Option::is_none")] + pub ins: Option, + /// Tracked deletion: index into `Document::revisions`. + #[serde(skip_serializing_if = "Option::is_none")] + pub del: Option, +} + +macro_rules! overlay_fields { + ($dst:ident, $src:ident; $($f:ident),*) => { + $( if $src.$f.is_some() { $dst.$f = $src.$f.clone(); } )* + }; +} + +impl CharProps { + /// Apply every `Some` field of `patch` on top of `self`. + pub fn overlay(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, + link, ins, del); + } + pub fn overlaid(mut self, patch: &CharProps) -> CharProps { + self.overlay(patch); + self + } + pub fn is_empty(&self) -> bool { + *self == CharProps::default() + } + /// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style + /// is cleared too, as Word's Clear Formatting does). + pub fn cleared(&self) -> CharProps { + CharProps { link: self.link.clone(), ins: self.ins, del: self.del, ..Default::default() } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Align { + #[default] + Left, + Center, + Right, + Justify, + Distribute, +} + +/// Line spacing rule. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "rule", content = "value")] +pub enum LineSpacing { + /// Multiple of single spacing (1.0 = single, 1.15, 1.5, 2.0 = double). + Multiple(f32), + /// At least this many points. + AtLeast(f32), + /// Exactly this many points. + Exactly(f32), +} + +impl Default for LineSpacing { + fn default() -> Self { + LineSpacing::Multiple(1.0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TabAlign { + #[default] + Left, + Center, + Right, + Decimal, + Bar, + /// Clears an inherited stop at this position. + Clear, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TabLeader { + #[default] + None, + Dot, + Hyphen, + Underscore, + MiddleDot, +} + +impl TabLeader { + pub fn char(self) -> Option { + match self { + TabLeader::None => None, + TabLeader::Dot => Some('.'), + TabLeader::Hyphen => Some('-'), + TabLeader::Underscore => Some('_'), + TabLeader::MiddleDot => Some('·'), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct TabStop { + /// Points from the paragraph's left indent origin (the text column's left edge). + pub pos: f32, + #[serde(default)] + pub align: TabAlign, + #[serde(default)] + pub leader: TabLeader, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum BorderStyle { + #[default] + None, + Single, + Double, + Dotted, + Dashed, + Thick, + Triple, + DotDash, + Wave, +} + +impl BorderStyle { + pub fn ooxml(self) -> &'static str { + match self { + BorderStyle::None => "nil", + BorderStyle::Single => "single", + BorderStyle::Double => "double", + BorderStyle::Dotted => "dotted", + BorderStyle::Dashed => "dashed", + BorderStyle::Thick => "thick", + BorderStyle::Triple => "triple", + BorderStyle::DotDash => "dotDash", + BorderStyle::Wave => "wave", + } + } + pub fn from_ooxml(s: &str) -> BorderStyle { + match s { + "nil" | "none" => BorderStyle::None, + "double" => BorderStyle::Double, + "dotted" => BorderStyle::Dotted, + "dashed" | "dashSmallGap" => BorderStyle::Dashed, + "thick" => BorderStyle::Thick, + "triple" => BorderStyle::Triple, + "dotDash" | "dotDotDash" => BorderStyle::DotDash, + "wave" | "doubleWave" => BorderStyle::Wave, + _ => BorderStyle::Single, + } + } +} + +/// One border line. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Border { + pub style: BorderStyle, + /// Line width, points. + pub width: f32, + pub color: Option, + /// Distance from the text, points. + pub space: f32, +} + +impl Border { + pub fn single(width: f32) -> Border { + Border { style: BorderStyle::Single, width, color: None, space: 0.0 } + } + pub fn is_visible(&self) -> bool { + self.style != BorderStyle::None && self.width > 0.0 + } +} + +/// Borders on the four sides (plus inner borders for tables and runs of bordered paragraphs). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Borders { + pub top: Option, + pub left: Option, + pub bottom: Option, + pub right: Option, + /// Between paragraphs / inside horizontal (tables). + pub between: Option, + /// Inside vertical (tables). + pub inside_v: Option, +} + +impl Borders { + pub fn all(b: Border) -> Borders { + Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: Some(b), inside_v: Some(b) } + } + pub fn box_(b: Border) -> Borders { + Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: None, inside_v: None } + } + pub fn any_visible(&self) -> bool { + [self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible) + } +} + +/// A reference to a numbering definition and level. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct NumRef { + /// `Numbering::nums` id; 0 removes numbering inherited from a style. + pub num: u32, + pub level: u8, +} + +/// Paragraph formatting. `None` = inherit. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ParaProps { + /// Paragraph style id. + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub align: Option, + /// Left indent, points. + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_left: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_right: Option, + /// First-line indent, points; negative = hanging. + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_first: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub line_spacing: Option, + /// Ignore space before/after between paragraphs of the same style. + #[serde(skip_serializing_if = "Option::is_none")] + pub contextual_spacing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keep_next: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keep_lines: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_break_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub widow_control: Option, + /// 0..=8 = Level 1..9; 9 = body text. + #[serde(skip_serializing_if = "Option::is_none")] + pub outline_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub numbering: Option, + /// Tab stops (replaces the inherited list; `TabAlign::Clear` stops remove inherited ones). + #[serde(skip_serializing_if = "Option::is_none")] + pub tabs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub shading: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub borders: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_hyphens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_line_numbers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bidi: Option, + /// Drop cap: lines to drop (0 = none) — applies to the first character(s). + #[serde(skip_serializing_if = "Option::is_none")] + pub drop_cap: Option, +} + +impl ParaProps { + pub fn overlay(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing, + contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, + suppress_hyphens, suppress_line_numbers, bidi, drop_cap); + } + pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { + self.overlay(patch); + self + } + pub fn is_empty(&self) -> bool { + *self == ParaProps::default() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum VAlign { + #[default] + Top, + Center, + Bottom, +} + +/// Table-wide properties. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TableProps { + pub style: Option, + /// Preferred width: points (`Some`) or automatic (`None`). + pub width: Option, + /// Preferred width as a percentage of the text column (overrides `width`). + pub width_pct: Option, + pub align: Option, + /// Indent from the left margin, points. + pub indent: Option, + pub borders: Option, + /// Default cell margins (top, left, bottom, right), points. + pub cell_margins: Option<[f32; 4]>, + /// Fixed column widths (no auto-fit). + pub fixed: bool, + /// Table style options ("look"): header row, total row, banded rows, first column, last + /// column, banded columns. + pub look: TableLook, + pub shading: Option, + /// Alternative text. + pub caption: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TableLook { + pub header_row: bool, + pub total_row: bool, + pub banded_rows: bool, + pub first_column: bool, + pub last_column: bool, + pub banded_columns: bool, +} + +impl Default for TableLook { + fn default() -> Self { + TableLook { header_row: true, total_row: false, banded_rows: true, first_column: true, last_column: false, banded_columns: false } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum HeightRule { + #[default] + Auto, + AtLeast, + Exact, +} + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RowProps { + /// Points (meaning depends on `height_rule`). + pub height: Option, + pub height_rule: HeightRule, + /// Repeat as a header row at the top of each page. + pub header: bool, + /// Don't let the row break across pages. + pub cant_split: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum VMerge { + #[default] + None, + /// First cell of a vertically merged range. + Restart, + /// Continues the merge from the cell above. + Continue, +} + +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CellProps { + /// Preferred width, points. + pub width: Option, + /// Number of grid columns spanned (1 = no horizontal merge). + pub span: u32, + pub vmerge: VMerge, + pub shading: Option, + pub borders: Option, + pub valign: VAlign, + /// Cell margins override (top, left, bottom, right), points. + pub margins: Option<[f32; 4]>, + /// Text direction: false = horizontal, true = rotated (top-to-bottom). + pub vertical_text: bool, + pub no_wrap: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_hex() { + assert_eq!(Rgb(255, 0, 16).hex(), "FF0010"); + assert_eq!(Rgb::parse("#FF0010"), Some(Rgb(255, 0, 16))); + assert_eq!(Rgb::parse("zzzzzz"), None); + assert_eq!(Rgb::parse("é1234"), None); + } + + #[test] + fn overlay_keeps_unset() { + let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() }; + let patch = CharProps { size: Some(14.0), italic: Some(true), ..Default::default() }; + let r = base.overlaid(&patch); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.size, Some(14.0)); + assert_eq!(r.italic, Some(true)); + } + + #[test] + fn ooxml_names_round_trip() { + for u in Underline::ALL { + assert_eq!(Underline::from_ooxml(u.ooxml()), u); + } + for h in Highlight::ALL { + assert_eq!(Highlight::from_ooxml(h.ooxml()), h); + } + } + + #[test] + fn props_serde_skip_none() { + let p = CharProps { bold: Some(true), ..Default::default() }; + let s = serde_json::to_string(&p).unwrap(); + assert_eq!(s, r#"{"bold":true}"#); + let back: CharProps = serde_json::from_str(&s).unwrap(); + assert_eq!(back, p); + } +} diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs new file mode 100644 index 0000000..3ea8623 --- /dev/null +++ b/crates/doc/src/resolve.rs @@ -0,0 +1,255 @@ +//! Style resolution: document defaults → paragraph style chain → character style chain → direct +//! formatting, into concrete values for layout. + +use serde::{Deserialize, Serialize}; + +use crate::props::{Align, Borders, CharProps, Highlight, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabStop, TextColor, Underline, VertAlign}; +use crate::styles::{StyleKind, StyleSheet}; + +/// Concrete character formatting. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedChar { + pub font: String, + pub size: f32, + pub bold: bool, + pub italic: bool, + pub underline: Underline, + pub underline_color: Option, + pub strike: bool, + pub double_strike: bool, + pub color: TextColor, + pub highlight: Option, + pub shading: Option, + pub vert_align: VertAlign, + pub caps: bool, + pub small_caps: bool, + pub hidden: bool, + pub spacing: f32, + pub scale: f32, + pub position: f32, + pub outline: bool, + pub shadow: bool, + pub emboss: bool, + pub engrave: bool, + pub link: Option, + pub ins: Option, + pub del: Option, + pub no_proof: bool, + pub lang: Option, +} + +impl ResolvedChar { + fn from(c: &CharProps) -> ResolvedChar { + ResolvedChar { + font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), + size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0), + bold: c.bold.unwrap_or(false), + italic: c.italic.unwrap_or(false), + underline: c.underline.unwrap_or_default(), + underline_color: c.underline_color, + strike: c.strike.unwrap_or(false), + double_strike: c.double_strike.unwrap_or(false), + color: c.color.unwrap_or_default(), + highlight: c.highlight.and_then(Highlight::rgb), + shading: c.shading, + vert_align: c.vert_align.unwrap_or_default(), + caps: c.caps.unwrap_or(false), + small_caps: c.small_caps.unwrap_or(false), + hidden: c.hidden.unwrap_or(false), + spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0), + scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0), + position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0), + outline: c.outline.unwrap_or(false), + shadow: c.shadow.unwrap_or(false), + emboss: c.emboss.unwrap_or(false), + engrave: c.engrave.unwrap_or(false), + link: c.link.clone(), + ins: c.ins, + del: c.del, + no_proof: c.no_proof.unwrap_or(false), + lang: c.lang.clone(), + } + } + /// The size glyphs are drawn at (super/subscript shrink to ~2/3). + pub fn draw_size(&self) -> f32 { + if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 } + } + /// Baseline shift, points (positive = up). + pub fn baseline_shift(&self) -> f32 { + self.position + + match self.vert_align { + VertAlign::Baseline => 0.0, + VertAlign::Superscript => self.size * 0.33, + VertAlign::Subscript => -self.size * 0.14, + } + } +} + +/// Concrete paragraph formatting. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedPara { + pub style: String, + pub align: Align, + pub indent_left: f32, + pub indent_right: f32, + pub indent_first: f32, + pub space_before: f32, + pub space_after: f32, + pub line_spacing: LineSpacing, + pub contextual_spacing: bool, + pub keep_next: bool, + pub keep_lines: bool, + pub page_break_before: bool, + pub widow_control: bool, + pub outline_level: Option, + pub numbering: Option, + pub tabs: Vec, + pub shading: Option, + pub borders: Option, + pub bidi: bool, + pub drop_cap: u8, + pub suppress_hyphens: bool, +} + +const MAX_INDENT: f32 = 1584.0; // 22" + +impl ResolvedPara { + fn from(p: &ParaProps, style: &str) -> ResolvedPara { + let clampi = |v: Option| v.unwrap_or(0.0).clamp(-MAX_INDENT, MAX_INDENT); + let mut tabs: Vec = p.tabs.clone().unwrap_or_default().into_iter().filter(|t| t.align != TabAlign::Clear && t.pos.is_finite()).collect(); + tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + ResolvedPara { + style: style.to_string(), + align: p.align.unwrap_or_default(), + indent_left: clampi(p.indent_left), + indent_right: clampi(p.indent_right), + indent_first: clampi(p.indent_first), + space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0), + space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0), + line_spacing: match p.line_spacing.unwrap_or_default() { + LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }), + LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }), + LineSpacing::Exactly(v) => LineSpacing::Exactly(if v.is_finite() { v.clamp(0.7, 1584.0) } else { 12.0 }), + }, + contextual_spacing: p.contextual_spacing.unwrap_or(false), + keep_next: p.keep_next.unwrap_or(false), + keep_lines: p.keep_lines.unwrap_or(false), + page_break_before: p.page_break_before.unwrap_or(false), + widow_control: p.widow_control.unwrap_or(true), + outline_level: p.outline_level.filter(|l| *l < 9), + numbering: p.numbering.filter(|n| n.num != 0), + tabs, + shading: p.shading, + borders: p.borders.filter(Borders::any_visible), + bidi: p.bidi.unwrap_or(false), + drop_cap: p.drop_cap.unwrap_or(0).min(10), + suppress_hyphens: p.suppress_hyphens.unwrap_or(false), + } + } +} + +impl StyleSheet { + /// Paragraph properties after defaults and the style chain (without direct formatting). + pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) { + let mut pp = self.default_para.clone(); + let mut cp = self.default_chr.clone(); + for s in self.chain(style) { + if s.kind == StyleKind::Paragraph { + let mut sp = s.para.clone(); + // Tabs accumulate along the chain (with Clear removing inherited stops). + if let (Some(base), Some(add)) = (pp.tabs.clone(), sp.tabs.clone()) { + let mut merged: Vec = base.into_iter().filter(|t| !add.iter().any(|a| (a.pos - t.pos).abs() < 0.5)).collect(); + merged.extend(add); + sp.tabs = Some(merged); + } + pp.overlay(&sp); + cp.overlay(&s.chr); + } + } + pp.style = None; + (pp, cp) + } + + /// Resolve a paragraph's properties. + pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara { + let style = p.style.clone().unwrap_or_else(|| "Normal".into()); + let (mut base, _) = self.para_style_props(&style); + let mut direct = p.clone(); + if let (Some(b), Some(d)) = (base.tabs.clone(), direct.tabs.clone()) { + let mut merged: Vec = b.into_iter().filter(|t| !d.iter().any(|a| (a.pos - t.pos).abs() < 0.5)).collect(); + merged.extend(d); + direct.tabs = Some(merged); + } + base.overlay(&direct); + ResolvedPara::from(&base, &style) + } + + /// Resolve character formatting for a run in a paragraph of style `para_style`. + pub fn resolve_char(&self, para_style: Option<&str>, c: &CharProps) -> ResolvedChar { + let (_, mut cp) = self.para_style_props(para_style.unwrap_or("Normal")); + if let Some(cs) = c.style.as_deref() { + for s in self.chain(cs) { + if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph { + cp.overlay(&s.chr); + } + } + } + let mut direct = c.clone(); + direct.style = None; + cp.overlay(&direct); + ResolvedChar::from(&cp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heading_resolves() { + let s = StyleSheet::builtin(); + let p = s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() }); + assert!(p.keep_next); + assert_eq!(p.outline_level, Some(0)); + assert_eq!(p.space_after, 4.0); + let c = s.resolve_char(Some("Heading1"), &CharProps::default()); + assert_eq!(c.size, 20.0); + assert_eq!(c.font, crate::styles::HEADING_FONT); + let c2 = s.resolve_char(Some("Heading1"), &CharProps { size: Some(30.0), ..Default::default() }); + assert_eq!(c2.size, 30.0); + } + + #[test] + fn char_style_then_direct() { + let s = StyleSheet::builtin(); + let c = s.resolve_char(None, &CharProps { style: Some("Strong".into()), italic: Some(true), ..Default::default() }); + assert!(c.bold && c.italic); + assert_eq!(c.size, 12.0); + let unknown = s.resolve_char(Some("NoSuchStyle"), &CharProps::default()); + assert_eq!(unknown.size, 12.0); + } + + #[test] + fn hostile_values_clamped() { + let s = StyleSheet::builtin(); + let p = s.resolve_para(&ParaProps { indent_left: Some(f32::INFINITY), line_spacing: Some(LineSpacing::Multiple(f32::NAN)), ..Default::default() }); + assert_eq!(p.indent_left, MAX_INDENT); + assert_eq!(p.line_spacing, LineSpacing::Multiple(1.0)); + let c = s.resolve_char(None, &CharProps { size: Some(-4.0), ..Default::default() }); + assert_eq!(c.size, 1.0); + } + + #[test] + fn header_tabs_inherit() { + let s = StyleSheet::builtin(); + let p = s.resolve_para(&ParaProps { + style: Some("Header".into()), + tabs: Some(vec![TabStop { pos: 100.0, align: TabAlign::Left, leader: Default::default() }]), + ..Default::default() + }); + assert_eq!(p.tabs.len(), 3); + assert_eq!(p.tabs[0].pos, 100.0); + } +} diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs new file mode 100644 index 0000000..c0f5061 --- /dev/null +++ b/crates/doc/src/section.rs @@ -0,0 +1,407 @@ +//! Section properties: page setup, columns, headers/footers, page numbering. + +use serde::{Deserialize, Serialize}; + +use crate::props::{Borders, VAlign}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum SectionStart { + #[default] + NextPage, + Continuous, + EvenPage, + OddPage, + NextColumn, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum NumFormat { + #[default] + Decimal, + UpperRoman, + LowerRoman, + UpperLetter, + LowerLetter, + Ordinal, + CardinalText, + OrdinalText, + DecimalZero, + Bullet, + None, +} + +impl NumFormat { + pub fn ooxml(self) -> &'static str { + match self { + NumFormat::Decimal => "decimal", + NumFormat::UpperRoman => "upperRoman", + NumFormat::LowerRoman => "lowerRoman", + NumFormat::UpperLetter => "upperLetter", + NumFormat::LowerLetter => "lowerLetter", + NumFormat::Ordinal => "ordinal", + NumFormat::CardinalText => "cardinalText", + NumFormat::OrdinalText => "ordinalText", + NumFormat::DecimalZero => "decimalZero", + NumFormat::Bullet => "bullet", + NumFormat::None => "none", + } + } + pub fn from_ooxml(s: &str) -> NumFormat { + [ + NumFormat::Decimal, + NumFormat::UpperRoman, + NumFormat::LowerRoman, + NumFormat::UpperLetter, + NumFormat::LowerLetter, + NumFormat::Ordinal, + NumFormat::CardinalText, + NumFormat::OrdinalText, + NumFormat::DecimalZero, + NumFormat::Bullet, + NumFormat::None, + ] + .into_iter() + .find(|f| f.ooxml() == s) + .unwrap_or(NumFormat::Decimal) + } + /// Format `n` (1-based). + pub fn format(self, n: u32) -> String { + match self { + NumFormat::Decimal => n.to_string(), + NumFormat::DecimalZero => format!("{n:02}"), + NumFormat::UpperRoman => roman(n).to_uppercase(), + NumFormat::LowerRoman => roman(n), + NumFormat::UpperLetter => letters(n).to_uppercase(), + NumFormat::LowerLetter => letters(n), + NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)), + NumFormat::CardinalText => cardinal_text(n), + NumFormat::OrdinalText => ordinal_text(n), + NumFormat::Bullet | NumFormat::None => String::new(), + } + } +} + +fn ordinal_suffix(n: u32) -> &'static str { + match (n % 10, n % 100) { + (_, 11..=13) => "th", + (1, _) => "st", + (2, _) => "nd", + (3, _) => "rd", + _ => "th", + } +} + +/// Lowercase roman numerals (0 → empty; values above 3999 use repeated `m`). +pub fn roman(mut n: u32) -> String { + const T: [(u32, &str); 13] = + [(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i")]; + let mut s = String::new(); + n = n.min(50_000); + for (v, r) in T { + while n >= v { + s.push_str(r); + n -= v; + } + } + s +} + +/// Word-style letters: a..z, aa..zz, aaa.. (repeated letter, not base-26). +pub fn letters(n: u32) -> String { + if n == 0 { + return String::new(); + } + let n = n.min(780); + let k = (n - 1) / 26 + 1; + let c = (b'a' + ((n - 1) % 26) as u8) as char; + std::iter::repeat_n(c, k as usize).collect() +} + +const ONES: [&str; 20] = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", +]; +const TENS: [&str; 10] = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]; + +/// English cardinal words, capitalised first letter (`Twenty-One`). +pub fn cardinal_text(n: u32) -> String { + fn words(n: u32) -> String { + match n { + 0..=19 => ONES.get(n as usize).copied().unwrap_or("").to_string(), + 20..=99 => { + let t = TENS.get((n / 10) as usize).copied().unwrap_or(""); + if n % 10 == 0 { t.to_string() } else { format!("{t}-{}", ONES.get((n % 10) as usize).copied().unwrap_or("")) } + } + 100..=999 => { + let h = format!("{} hundred", words(n / 100)); + if n % 100 == 0 { h } else { format!("{h} {}", words(n % 100)) } + } + _ => { + let th = format!("{} thousand", words(n / 1000)); + if n % 1000 == 0 { th } else { format!("{th} {}", words(n % 1000)) } + } + } + } + capitalize(&words(n.min(999_999))) +} + +/// English ordinal words (`Twenty-First`). +pub fn ordinal_text(n: u32) -> String { + let c = cardinal_text(n).to_lowercase(); + let (head, last) = match c.rfind(['-', ' ']) { + Some(i) => (c.get(..=i).unwrap_or(""), c.get(i + 1..).unwrap_or("")), + None => ("", c.as_str()), + }; + let last = match last { + "one" => "first".to_string(), + "two" => "second".to_string(), + "three" => "third".to_string(), + "five" => "fifth".to_string(), + "eight" => "eighth".to_string(), + "nine" => "ninth".to_string(), + "twelve" => "twelfth".to_string(), + l if l.ends_with('y') => format!("{}ieth", l.trim_end_matches('y')), + l => format!("{l}th"), + }; + capitalize(&format!("{head}{last}")) +} + +fn capitalize(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut up = true; + for c in s.chars() { + if up { + out.extend(c.to_uppercase()); + } else { + out.push(c); + } + up = c == '-' || c == ' '; + } + out +} + +/// Header/footer story ids (in `Document::parts`) for a section. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct HeaderSet { + pub default: Option, + pub first: Option, + pub even: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Columns { + pub count: u32, + /// Space between columns, points. + pub space: f32, + /// Line between columns. + pub separator: bool, + /// Unequal widths (width, space after) per column; empty = equal. + pub widths: Vec<(f32, f32)>, +} + +impl Default for Columns { + fn default() -> Self { + Columns { count: 1, space: 36.0, separator: false, widths: Vec::new() } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LineNumbering { + pub count_by: u32, + pub start: u32, + /// Distance from text, points (0 = auto). + pub distance: f32, + /// Restart each page / section / continuous. + pub restart: LineNumberRestart, +} + +impl Default for LineNumbering { + fn default() -> Self { + LineNumbering { count_by: 1, start: 1, distance: 0.0, restart: LineNumberRestart::Page } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LineNumberRestart { + #[default] + Page, + Section, + Continuous, +} + +/// Page setup and section-level settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SectionProps { + pub start: SectionStart, + /// Page width/height, points (already swapped for landscape). + pub page_w: f32, + pub page_h: f32, + pub landscape: bool, + pub margin_top: f32, + pub margin_bottom: f32, + pub margin_left: f32, + pub margin_right: f32, + /// Distance from the page edge to the header / footer. + pub header: f32, + pub footer: f32, + pub gutter: f32, + /// Mirror margins (inside/outside) — set from the document settings. + pub columns: Columns, + pub headers: HeaderSet, + pub footers: HeaderSet, + /// Different first page header/footer. + pub title_page: bool, + pub page_num_start: Option, + pub page_num_format: NumFormat, + pub line_numbers: Option, + pub valign: VAlign, + pub page_borders: Option, + /// Text direction / bidi section. + pub rtl: bool, +} + +impl Default for SectionProps { + /// Word's default: US Letter, 1" margins, 0.5" header/footer distance. + fn default() -> Self { + SectionProps { + start: SectionStart::NextPage, + page_w: 612.0, + page_h: 792.0, + landscape: false, + margin_top: 72.0, + margin_bottom: 72.0, + margin_left: 72.0, + margin_right: 72.0, + header: 36.0, + footer: 36.0, + gutter: 0.0, + columns: Columns::default(), + headers: HeaderSet::default(), + footers: HeaderSet::default(), + title_page: false, + page_num_start: None, + page_num_format: NumFormat::Decimal, + line_numbers: None, + valign: VAlign::Top, + page_borders: None, + rtl: false, + } + } +} + +impl SectionProps { + /// Width of the text area, points. + pub fn text_width(&self) -> f32 { + (self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0) + } + pub fn text_height(&self) -> f32 { + (self.page_h - self.margin_top - self.margin_bottom).max(18.0) + } + /// Swap to landscape/portrait (keeps margins relative to the paper as Word does: rotates them). + pub fn set_landscape(&mut self, on: bool) { + if on == self.landscape { + return; + } + self.landscape = on; + std::mem::swap(&mut self.page_w, &mut self.page_h); + let (t, b, l, r) = (self.margin_top, self.margin_bottom, self.margin_left, self.margin_right); + if on { + self.margin_top = l; + self.margin_bottom = r; + self.margin_left = b; + self.margin_right = t; + } else { + self.margin_left = t; + self.margin_right = b; + self.margin_bottom = l; + self.margin_top = r; + } + } + /// Column (x offset from the text area's left, width) for each column. + pub fn column_boxes(&self) -> Vec<(f32, f32)> { + let tw = self.text_width(); + let n = self.columns.count.clamp(1, 45) as usize; + if !self.columns.widths.is_empty() && self.columns.widths.len() == n { + let mut x = 0.0; + return self + .columns + .widths + .iter() + .map(|(w, sp)| { + let b = (x, w.max(18.0)); + x += w + sp; + b + }) + .collect(); + } + let space = self.columns.space.max(0.0); + let w = ((tw - space * (n as f32 - 1.0)) / n as f32).max(18.0); + (0..n).map(|i| (i as f32 * (w + space), w)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn number_formats() { + assert_eq!(NumFormat::UpperRoman.format(1994), "MCMXCIV"); + assert_eq!(NumFormat::LowerLetter.format(27), "aa"); + assert_eq!(NumFormat::LowerLetter.format(0), ""); + assert_eq!(NumFormat::Ordinal.format(12), "12th"); + assert_eq!(NumFormat::Ordinal.format(22), "22nd"); + assert_eq!(NumFormat::CardinalText.format(21), "Twenty-One"); + assert_eq!(NumFormat::OrdinalText.format(21), "Twenty-First"); + assert_eq!(NumFormat::OrdinalText.format(20), "Twentieth"); + assert_eq!(NumFormat::OrdinalText.format(112), "One Hundred Twelfth"); + assert_eq!(NumFormat::DecimalZero.format(3), "03"); + assert!(!NumFormat::UpperRoman.format(u32::MAX).is_empty()); + assert_eq!(NumFormat::LowerLetter.format(u32::MAX).len(), 30); + } + + #[test] + fn landscape_swaps() { + let mut s = SectionProps { margin_left: 50.0, ..Default::default() }; + s.set_landscape(true); + assert_eq!((s.page_w, s.page_h), (792.0, 612.0)); + assert_eq!(s.margin_top, 50.0); + s.set_landscape(false); + assert_eq!(s.margin_left, 50.0); + assert_eq!((s.page_w, s.page_h), (612.0, 792.0)); + } + + #[test] + fn columns_split() { + let s = SectionProps { columns: Columns { count: 2, space: 36.0, ..Default::default() }, ..Default::default() }; + let b = s.column_boxes(); + assert_eq!(b.len(), 2); + assert_eq!(b[0], (0.0, 216.0)); + assert_eq!(b[1], (252.0, 216.0)); + } +} diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs new file mode 100644 index 0000000..92c3353 --- /dev/null +++ b/crates/doc/src/styles.rs @@ -0,0 +1,478 @@ +//! Styles: paragraph, character, table and linked styles with `based on` chains, plus the +//! built-in style set (our own values in the spirit of a modern word processor's defaults). + +use serde::{Deserialize, Serialize}; + +use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum StyleKind { + #[default] + Paragraph, + Character, + Table, + Numbering, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct Style { + /// Stable id (`Heading1`), referenced by `ParaProps::style` / `CharProps::style`. + pub id: String, + /// Display name (`Heading 1`). + pub name: String, + pub kind: StyleKind, + pub based_on: Option, + /// Style for the paragraph after this one (Enter at the end). + pub next: Option, + /// Linked character/paragraph style id. + pub linked: Option, + pub para: ParaProps, + pub chr: CharProps, + /// Gallery order (lower first). + pub priority: Option, + /// Shown in the Styles gallery on the Home tab. + pub quick: bool, + pub hidden: bool, + /// Built in (cannot be deleted). + pub builtin: bool, + /// Table style: conditional formatting for header row, banding etc. + pub table: Option, +} + +/// Table style conditional formats (shading + character formatting per region). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct TableStyleParts { + pub borders: Option, + pub header_fill: Option, + pub header_chr: CharProps, + pub band_fill: Option, + pub first_col_chr: CharProps, + pub total_chr: CharProps, + pub total_border_top: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct StyleSheet { + /// Document defaults (`w:docDefaults`). + pub default_chr: CharProps, + pub default_para: ParaProps, + pub styles: Vec