diff --git a/Cargo.lock b/Cargo.lock index 80043a3d8..e046d2799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "ahash" version = "0.8.12" @@ -230,7 +241,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -536,6 +547,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1" + [[package]] name = "bitflags" version = "1.3.2" @@ -557,6 +574,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -794,6 +820,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.3" @@ -812,6 +848,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cmpv2" version = "0.2.0" @@ -830,7 +872,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "x509-cert", @@ -906,6 +948,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a" + [[package]] name = "core-foundation" version = "0.9.4" @@ -983,6 +1031,12 @@ dependencies = [ "libm", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1009,6 +1063,11 @@ dependencies = [ "rustix 1.1.5", ] +[[package]] +name = "craft-cfb" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" + [[package]] name = "craft-cms" version = "0.1.0" @@ -1020,7 +1079,7 @@ dependencies = [ [[package]] name = "craft-codepage" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" [[package]] name = "craft-dialogs" @@ -1043,7 +1102,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13af [[package]] name = "craft-document-bundle" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-zip", "serde_json", @@ -1051,10 +1110,30 @@ dependencies = [ "zip 9.1.1", ] +[[package]] +name = "craft-docx" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" +dependencies = [ + "base64 0.23.1", + "chrono", + "craft-drawingml", + "craft-math", + "craft-offcrypto", + "craft-opc", + "craft-wordmodel", + "craft-xml", + "log", + "quick-xml 0.41.0", + "thiserror 2.0.21", + "unicode-bidi", + "zeroize", +] + [[package]] name = "craft-drawingml" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-xml", "kurbo", @@ -1065,12 +1144,12 @@ dependencies = [ [[package]] name = "craft-eps" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" [[package]] name = "craft-epub" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-xml", "craft-zip", @@ -1108,13 +1187,43 @@ dependencies = [ [[package]] name = "craft-idml" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-xml", "craft-zip", "thiserror 2.0.21", ] +[[package]] +name = "craft-image-codecs" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=85145d8dfd52966eacd045683a86a3737883aeea#85145d8dfd52966eacd045683a86a3737883aeea" +dependencies = [ + "craft-jpeg", + "exr", + "flate2", + "half", + "image", + "image-webp", + "jpeg-encoder", + "png", + "rayon", + "serde_json", + "thiserror 2.0.21", + "tiff", + "zune-core", + "zune-jpeg", + "zune-jpegxl", +] + +[[package]] +name = "craft-jpeg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=85145d8dfd52966eacd045683a86a3737883aeea#85145d8dfd52966eacd045683a86a3737883aeea" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-keymap" version = "0.1.0" @@ -1127,10 +1236,36 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-math" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" +dependencies = [ + "craft-xml", + "serde", +] + +[[package]] +name = "craft-offcrypto" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" +dependencies = [ + "aes", + "base64 0.23.1", + "craft-cfb", + "craft-xml", + "getrandom 0.3.4", + "hmac", + "sha1 0.11.0", + "sha2 0.11.1", + "thiserror 2.0.21", + "zeroize", +] + [[package]] name = "craft-opc" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-xml", "craft-zip", @@ -1177,10 +1312,21 @@ dependencies = [ "winsafe", ] +[[package]] +name = "craft-psd" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=85145d8dfd52966eacd045683a86a3737883aeea#85145d8dfd52966eacd045683a86a3737883aeea" +dependencies = [ + "encoding_rs", + "flate2", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "craft-rtf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-codepage", "encoding_rs", @@ -1189,12 +1335,12 @@ dependencies = [ [[package]] name = "craft-sheet" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" [[package]] name = "craft-svg" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-xml", "flate2", @@ -1241,7 +1387,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tar", "tempfile", "ureq", @@ -1270,10 +1416,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-wordmodel" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" +dependencies = [ + "base64 0.23.1", + "craft-math", + "log", + "serde", + "serde_json", + "thiserror 2.0.21", + "unicode-segmentation", +] + [[package]] name = "craft-xlsx" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "craft-drawingml", "craft-opc", @@ -1285,7 +1445,7 @@ dependencies = [ [[package]] name = "craft-xml" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "quick-xml 0.41.0", "thiserror 2.0.21", @@ -1294,7 +1454,7 @@ dependencies = [ [[package]] name = "craft-zip" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe243c90e0f95c56eb6f82424be02a3ed77f#ba24fe243c90e0f95c56eb6f82424be02a3ed77f" +source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" dependencies = [ "flate2", "thiserror 2.0.21", @@ -1372,6 +1532,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1390,7 +1568,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "pem-rfc7468", @@ -1623,6 +1801,8 @@ version = "0.20.1" dependencies = [ "craft-cms", "craft-eps", + "craft-image-codecs", + "craft-psd", "craft-svg", "designcraft-color", "designcraft-fonts", @@ -1632,8 +1812,6 @@ dependencies = [ "resvg", "skrifa 0.47.0", "tiff", - "zune-core", - "zune-jpeg", ] [[package]] @@ -1697,6 +1875,7 @@ dependencies = [ name = "designcraft-render" version = "0.20.1" dependencies = [ + "craft-image-codecs", "craft-pdf", "designcraft-color", "designcraft-compose", @@ -1716,10 +1895,12 @@ dependencies = [ name = "designcraft-textimport" version = "0.20.1" dependencies = [ + "craft-docx", + "craft-idml", "craft-rtf", + "craft-wordmodel", "craft-xlsx", "designcraft-doc", - "quick-xml 0.41.0", "zip 3.0.0", ] @@ -1807,8 +1988,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.3", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2149,7 +2342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2187,6 +2380,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "multiversion", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fancy-regex" version = "0.19.2" @@ -2553,9 +2762,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2934,6 +3145,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -2973,6 +3193,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.12.0" @@ -3277,6 +3506,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "qoi", "tiff", "zune-core", "zune-jpeg", @@ -3313,6 +3543,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -3430,6 +3669,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + [[package]] name = "js-sys" version = "0.3.106" @@ -3541,6 +3786,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.191" @@ -3741,6 +3992,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -3848,7 +4121,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4273,7 +4546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4736,6 +5009,15 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "qrcodegen" version = "1.8.0" @@ -4826,7 +5108,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5215,7 +5497,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5274,7 +5556,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5523,7 +5805,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5534,7 +5827,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5588,12 +5892,12 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" dependencies = [ "aws-lc-rs", "base64 0.22.1", - "const-oid", + "const-oid 0.9.6", "der", - "digest", + "digest 0.10.7", "pem", "rand_core 0.10.1", - "sha2", + "sha2 0.10.9", "signature", "sigstore-types", "spki", @@ -5662,7 +5966,7 @@ dependencies = [ "chrono", "cmpv2", "cms", - "const-oid", + "const-oid 0.9.6", "der", "hex", "rand", @@ -5701,7 +6005,7 @@ dependencies = [ "base64 0.22.1", "chrono", "cms", - "const-oid", + "const-oid 0.9.6", "hex", "pem", "rustls-pki-types", @@ -6091,6 +6395,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempfile" version = "3.27.0" @@ -6098,10 +6408,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7313,7 +7623,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7725,9 +8035,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", - "sha1", + "sha1 0.10.7", "signature", "spki", "tls_codec", @@ -8103,6 +8413,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" @@ -8112,6 +8431,15 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.1" diff --git a/Cargo.toml b/Cargo.toml index 121b553b1..955a05a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,14 +13,18 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } -craft-document-bundle = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } -craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243c90e0f95c56eb6f82424be02a3ed77f", version = "=0.1.0" } +craft-document-bundle = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "85145d8dfd52966eacd045683a86a3737883aeea", version = "=0.1.0" } +craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "85145d8dfd52966eacd045683a86a3737883aeea", version = "=0.1.0" } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index ccd9d82f9..6b58daccb 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -14,14 +14,14 @@ craft-svg = { workspace = true } designcraft-color = { workspace = true } designcraft-fonts = { workspace = true } image = { workspace = true } -tiff = { workspace = true } resvg = { workspace = true } skrifa = { workspace = true } log = { workspace = true } craft-cms = { workspace = true } -zune-core = { version = "0.5", default-features = false } -zune-jpeg = "0.5" +craft-image-codecs = { workspace = true } +craft-psd = { workspace = true } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } flate2 = "1" +tiff = { workspace = true } diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 63f0e6b09..b311f82f1 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -48,9 +48,7 @@ pub fn eps_png(bytes: &[u8]) -> Option> { return Some(proxy); } let rgba = crate::decode_rgba(&proxy)?; - let mut png = Vec::new(); - image::DynamicImage::ImageRgba8(rgba).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some(png) + encode_png(rgba) } /// The proxy for a placed EPS: (image bytes, its size in points). The TIFF preview when present, @@ -84,9 +82,13 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { img.put_pixel(0, y, image::Rgba([150, 150, 150, 255])); img.put_pixel(pw - 1, y, image::Rgba([150, 150, 150, 255])); } - let mut out = Vec::new(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some((out, (w, h))) + Some((encode_png(img)?, (w, h))) +} + +fn encode_png(img: image::RgbaImage) -> Option> { + let (width, height) = img.dimensions(); + let image = craft_image_codecs::Image::from_u8(width, height, craft_image_codecs::ChannelLayout::Rgba, img.into_raw()).ok()?; + craft_image_codecs::encode(&image, craft_image_codecs::Format::Png, &Default::default()).ok() } /// Pixels per point for rendered EPS (a QR code's modules stay crisp when scaled up on screen). @@ -124,9 +126,7 @@ fn render_rectangles(ps: &[u8], bb: [f64; 4]) -> Option> { } } } - let mut out = Vec::new(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) + encode_png(img) } #[cfg(test)] diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index d30a6b3d8..b9209e65e 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -10,7 +10,6 @@ pub use resvg::usvg; pub mod colour; mod eps; -mod jpeg; mod psd; mod tone; pub use craft_eps::is_eps; @@ -25,7 +24,7 @@ pub fn is_pdf(bytes: &[u8]) -> bool { } pub fn is_psd(bytes: &[u8]) -> bool { - bytes.starts_with(b"8BPS") + craft_psd::head::sniff(bytes) } /// An SVG document (optionally after an XML declaration, comments or a doctype). @@ -47,13 +46,13 @@ pub fn mime(bytes: &[u8]) -> &'static str { if is_svg(bytes) { return "image/svg+xml"; } - match image::guess_format(bytes) { - Ok(image::ImageFormat::Png) => "image/png", - Ok(image::ImageFormat::Jpeg) => "image/jpeg", - Ok(image::ImageFormat::Gif) => "image/gif", - Ok(image::ImageFormat::WebP) => "image/webp", - Ok(image::ImageFormat::Tiff) => "image/tiff", - Ok(image::ImageFormat::Bmp) => "image/bmp", + match craft_image_codecs::detect(bytes) { + Some(craft_image_codecs::Format::Png) => "image/png", + Some(craft_image_codecs::Format::Jpeg) => "image/jpeg", + Some(craft_image_codecs::Format::Gif) => "image/gif", + Some(craft_image_codecs::Format::WebP) => "image/webp", + Some(craft_image_codecs::Format::Tiff) => "image/tiff", + Some(craft_image_codecs::Format::Bmp) => "image/bmp", _ => "application/octet-stream", } } @@ -80,8 +79,13 @@ fn svg_options() -> &'static usvg::Options<'static> { let image_href_resolver = usvg::ImageHrefResolver { resolve_data: Box::new(move |mime, data, options| { let raster = matches!( - image::guess_format(&data), - Ok(image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::Gif | image::ImageFormat::WebP) + craft_image_codecs::detect(&data), + Some( + craft_image_codecs::Format::Png + | craft_image_codecs::Format::Jpeg + | craft_image_codecs::Format::Gif + | craft_image_codecs::Format::WebP + ) ); if mime == "image/svg+xml" || (mime == "text/plain" && !raster) { let xml = svg_text(&data, MAX_NESTED_SVG_BYTES.saturating_sub(NESTED_SVG_BYTES.get()).min(MAX_SVG_BYTES))?; @@ -331,21 +335,19 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { /// Pixel dimensions of a raster. pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { - if let Some(p) = palette_tiff(bytes) { - return Some((p.width, p.height)); + if is_psd(bytes) { + let header = craft_psd::header::Header::from_bytes(bytes).ok()?; + return Some((header.width, header.height)); } - if is_psd(bytes) && bytes.len() >= 26 { - // Header: signature, version, 6 reserved, channels, height, width (big-endian). - let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); - return Some((be(18), be(14))); - } - image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .ok()? - .into_dimensions() + craft_image_codecs::inspect_raster(bytes) .ok() - // `image` doesn't read five-sample CMYK with alpha. - .or_else(|| tiff_decoder(bytes)?.dimensions().ok()) + .flatten() + .map(|image| (image.width, image.height)) + .or_else(|| craft_image_codecs::probe_size(bytes).ok()) + .or_else(|| { + let image = craft_image_codecs::decode_with(bytes, &decode_options()).ok()?; + Some(image.dimensions()) + }) } /// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits. @@ -367,72 +369,43 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { if is_psd(bytes) { return psd::decode(bytes); } - // `image` rejects indexed-colour TIFFs (an old GIF logo saved from Photoshop). - if let Some(img) = decode_palette_tiff(bytes) { - return Some(img); - } // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document // keeps the EPS itself. if is_eps(bytes) { return decode_rgba(&eps_proxy(bytes)?.0); } - if let Some((raster, icc)) = jpeg::decode_cmyk(bytes) { - return to_display_rgba(Pixels::Cmyk(raster), icc.as_deref(), &DisplayColour::active()); + let decoded = craft_image_codecs::decode_with(bytes, &decode_options()).ok()?; + if decoded.layout().is_cmyk() { + let raster = cmyk_raster(&decoded)?; + return to_display_rgba(Pixels::Cmyk(raster), decoded.icc.as_deref(), &DisplayColour::active()); } - if let Some(raster) = decode_cmyk_tiff(bytes) { - return to_display_rgba(Pixels::Cmyk(raster), embedded_icc(bytes).as_deref(), &DisplayColour::active()); + let rgba = image::RgbaImage::from_raw(decoded.width(), decoded.height(), decoded.to_rgba8())?; + let Some(icc) = decoded.icc.as_deref() else { return Some(rgba) }; + let px = if decoded.layout().is_gray() { Pixels::Gray(rgba) } else { Pixels::Rgb(rgba) }; + to_display_rgba(px, Some(icc), &DisplayColour::active()) +} + +fn decode_options() -> craft_image_codecs::DecodeOptions { + craft_image_codecs::DecodeOptions { limits: craft_image_codecs::Limits { max_alloc: MAX_RGBA_BYTES, ..Default::default() }, ..Default::default() } +} + +fn cmyk_raster(image: &craft_image_codecs::Image) -> Option { + let converted = image.convert(craft_image_codecs::ChannelLayout::CmykA, craft_image_codecs::SampleType::U8); + let pixels = usize::try_from(converted.width()).ok()?.checked_mul(usize::try_from(converted.height()).ok()?)?; + let data = converted.as_u8()?; + if data.len() != pixels.checked_mul(5)? { + return None; } - let img = image::load_from_memory(bytes).ok()?; - let rgba = decode_to_rgba8(&img); - let Some(icc) = embedded_icc(bytes) else { return Some(rgba) }; - let px = if img.color().has_color() { Pixels::Rgb(rgba) } else { Pixels::Gray(rgba) }; - to_display_rgba(px, Some(&icc), &DisplayColour::active()) -} - -/// The ICC profile embedded in a PNG, JPEG, TIFF or WebP. -fn embedded_icc(bytes: &[u8]) -> Option> { - use image::ImageDecoder; - let mut d = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; - d.icc_profile().ok().flatten() -} - -/// Fresh raster decodes are sRGB in the pinned image decoder. Expanding an L8 value to -/// [gray, gray, gray, 255] is exact and avoids its generic floating-point conversion. -fn decode_to_rgba8(img: &image::DynamicImage) -> image::RgbaImage { - let prepared = match img { - image::DynamicImage::ImageLuma8(gray) => prepare_luma8_rgba(gray), - _ => None, - }; - rgba_or_original(img, prepared) -} - -// Keep preparation failure on the existing conversion/error path. This small boundary also -// lets tests force failure deterministically without exhausting memory or changing an allocator. -fn rgba_or_original(img: &image::DynamicImage, prepared: Option) -> image::RgbaImage { - prepared.unwrap_or_else(|| img.to_rgba8()) -} - -fn prepare_luma8_rgba(gray: &image::GrayImage) -> Option { - let (width, height) = gray.dimensions(); - let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; - let length = pixels.checked_mul(4)?; - let mut bytes = Vec::new(); - // Unlike the generic ConvertBuffer API's infallible allocation, preserve a route back to - // the original to_rgba8 behavior when the requested buffer cannot be reserved. - bytes.try_reserve_exact(length).ok()?; - bytes.resize(length, 0); - for (output, input) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(gray.pixels()) { - let value = input.0[0]; - output.copy_from_slice(&[value, value, value, 255]); + let mut cmyk = Vec::with_capacity(pixels.checked_mul(4)?); + let mut alpha = Vec::with_capacity(pixels); + for &[c, m, y, k, a] in data.as_chunks::<5>().0 { + cmyk.extend([c, m, y, k]); + alpha.push(a); } - let mut rgba = image::RgbaImage::from_raw(width, height, bytes)?; - rgba.set_color_space(gray.color_space()).ok()?; - Some(rgba) + let alpha = alpha.iter().any(|&a| a < 255).then_some(alpha); + Some(CmykRaster { width: converted.width(), height: converted.height(), cmyk, alpha }) } -#[cfg(test)] -mod luma8_tests; - /// A CMYK raster's ink values, 8 bits per ink (0 = no ink, 255 = solid), as PDF's DeviceCMYK /// reads them. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -445,109 +418,12 @@ pub struct CmykRaster { pub alpha: Option>, } -fn tiff_decoder(bytes: &[u8]) -> Option>> { - let tiff = [b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m)); - if !tiff { - return None; - } - tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).ok() -} - -/// An indexed-colour TIFF (PhotometricInterpretation 3): its size, bits per index and ColorMap -/// in 8 bits per channel. -struct PaletteTiff { - width: u32, - height: u32, - bits: u8, - colours: Vec<[u8; 3]>, -} - -/// The header of a palette TIFF with 1-, 2-, 4- or 8-bit indices and a full ColorMap, which -/// neither `image` nor `tiff` decodes. `None` for anything else. -fn palette_tiff(bytes: &[u8]) -> Option { - use tiff::tags::Tag; - let mut d = tiff_decoder(bytes)?; - if d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok()?? != 3 { - return None; - } - if d.find_tag_unsigned::(Tag::SamplesPerPixel).ok()?.unwrap_or(1) != 1 { - return None; - } - let bits = d.find_tag_unsigned::(Tag::BitsPerSample).ok()?.unwrap_or(1); - if !matches!(bits, 1 | 2 | 4 | 8) { - return None; - } - // 2^bits reds, then greens, then blues; 16-bit entries whose high byte is the 8-bit value. - let map = d.find_tag_unsigned_vec::(Tag::ColorMap).ok()??; - let n = 1usize << bits; - let (r, gb) = map.split_at_checked(n)?; - let (g, b) = gb.split_at_checked(n)?; - if b.len() != n { - return None; - } - let colours = r.iter().zip(g).zip(b).map(|((&r, &g), &b)| [r, g, b].map(|v| (v >> 8) as u8)).collect(); - let (width, height) = d.dimensions().ok()?; - Some(PaletteTiff { width, height, bits, colours }) -} - -/// A palette TIFF as RGBA, each index looked up in the ColorMap. -fn decode_palette_tiff(bytes: &[u8]) -> Option { - let p = palette_tiff(bytes)?; - // Read as gray, `tiff` hands back the indices themselves, whatever the compression or layout. - let gray = with_photometric(bytes, 1)?; - let tiff::decoder::DecodingResult::U8(px) = tiff_decoder(&gray)?.read_image().ok()? else { return None }; - let (w, h) = (usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?); - let bits = usize::from(p.bits); - // Indices below 8 bits are packed high bits first, each row starting on a byte. - let row = w.checked_mul(bits)?.div_ceil(8); - if row == 0 || px.len() < row.checked_mul(h)? { - return None; - } - let per_byte = 8 / bits; - let mask = ((1u16 << bits) - 1) as u8; - let mut out = Vec::new(); - out.try_reserve_exact(w.checked_mul(h)?.checked_mul(4)?).ok()?; - for line in px.chunks_exact(row).take(h) { - for x in 0..w { - let shift = 8 - bits * (x % per_byte + 1); - let i = (line.get(x / per_byte)? >> shift) & mask; - let [r, g, b] = *p.colours.get(usize::from(i))?; - out.extend([r, g, b, 255]); - } - } - image::RgbaImage::from_raw(p.width, p.height, out) -} - -/// A copy of a TIFF with its first image's PhotometricInterpretation (tag 262, one SHORT) set -/// to `value`. Classic and BigTIFF, either byte order. -fn with_photometric(bytes: &[u8], value: u16) -> Option> { - let le = bytes.starts_with(b"II"); - let int = |at: usize, len: usize| -> Option { - let b = bytes.get(at..at.checked_add(len)?)?; - let v = |acc: u64, &x: &u8| acc << 8 | u64::from(x); - usize::try_from(if le { b.iter().rev().fold(0, v) } else { b.iter().fold(0, v) }).ok() - }; - // Classic: 4-byte offsets and value counts, 2-byte entry count, 12-byte entries. BigTIFF: - // 8, 8, 8 and 20. - let (ifd, word, count_len, entry_len) = if int(2, 2)? == 43 { (int(8, 8)?, 8, 8, 20) } else { (int(4, 4)?, 4, 2, 12) }; - let count = int(ifd, count_len)?; - for k in 0..count { - let at = ifd.checked_add(count_len)?.checked_add(k.checked_mul(entry_len)?)?; - // Tag, type (3 = SHORT), value count, then the value itself. - if int(at, 2)? == 262 && int(at + 2, 2)? == 3 && int(at + 4, word)? == 1 { - let mut out = bytes.to_vec(); - let v = if le { value.to_le_bytes() } else { value.to_be_bytes() }; - let value_at = at + 4 + word; - out.get_mut(value_at..value_at + 2)?.copy_from_slice(&v); - return Some(out); - } - } - None -} - /// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks). pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { - tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_))) + craft_image_codecs::tiff_info(bytes) + .ok() + .and_then(|info| info.default_page().and_then(|i| info.pages.get(i).map(|page| (page.photometric, page.samples_per_pixel)))) + .is_some_and(|(photometric, samples)| photometric == Some(5) && matches!(samples, 4 | 5)) } /// The ink values of a CMYK TIFF, which [`decode_rgba`] converts to RGB. 8- or 16-bit CMYK, @@ -555,48 +431,11 @@ pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { /// format, another ink set (InkSet 2), planar storage or premultiplied alpha — and for files /// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files. pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { - use tiff::tags::Tag; if too_large_to_decode(bytes) { return None; } - let mut d = tiff_decoder(bytes)?; - let has_alpha = match d.colortype().ok()? { - tiff::ColorType::CMYK(8 | 16) => false, - tiff::ColorType::CMYKA(8 | 16) => true, - _ => return None, - }; - // InkSet (tag 332): 1 or absent = CMYK; 2 = other inks, named in InkNames. - if d.find_tag_unsigned::(Tag::Unknown(332)).ok()?.is_some_and(|s| s != 1) { - return None; - } - // ExtraSamples 2 = unassociated alpha. Premultiplied CMYK has no meaning as ink values. - if has_alpha && d.find_tag_unsigned_vec::(Tag::ExtraSamples).ok()?.and_then(|v| v.first().copied()) != Some(2) { - return None; - } - let (w, h) = d.dimensions().ok()?; - let pixels = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?; - let samples = if has_alpha { 5 } else { 4 }; - let px = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|&s| ((u32::from(s) * 255 + 32_767) / 65_535) as u8).collect(), - _ => return None, - }; - // A planar file reads back as its first plane only, and extra non-alpha samples make the - // pixels wider: either way the length is not `pixels * samples`. - if px.len() != pixels.checked_mul(samples)? { - return None; - } - if !has_alpha { - return Some(CmykRaster { width: w, height: h, cmyk: px, alpha: None }); - } - let mut cmyk = Vec::with_capacity(pixels.checked_mul(4)?); - let mut alpha = Vec::with_capacity(pixels); - for &[c, m, y, k, a] in px.as_chunks::<5>().0 { - cmyk.extend([c, m, y, k]); - alpha.push(a); - } - let alpha = alpha.iter().any(|&a| a < 255).then_some(alpha); - Some(CmykRaster { width: w, height: h, cmyk, alpha }) + let image = craft_image_codecs::decode_with(bytes, &decode_options()).ok()?; + image.layout().is_cmyk().then(|| cmyk_raster(&image)).flatten() } /// An SVG rasterised so its longer side is `max_side` pixels: premultiplied RGBA8. diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs index 90977d36e..3e0d8f101 100644 --- a/crates/images/src/psd.rs +++ b/crates/images/src/psd.rs @@ -1,356 +1,83 @@ -//! Photoshop documents (.psd, and .psb large documents): the merged composite in the image data -//! section, decoded by its colour mode per the public file format specification. Extra alpha and -//! spot channels don't change the placed image; when the layer count is negative, the first extra -//! channel is the composite's merged transparency. +//! Photoshop's merged composite, decoded by the shared PSD implementation and colour managed by +//! DesignCraft for its active display profile. -use designcraft_color::cms::{Lab, lab::lab_to_srgb}; +use craft_psd::header::ColorMode; use crate::CmykRaster; use crate::colour::{DisplayColour, Pixels, to_display_rgba}; -/// The largest decoded image, in RGBA bytes (the `image` crate's default allocation limit). -const MAX_RGBA_BYTES: usize = 512 << 20; - -/// Tagged blocks whose length is 8 bytes in a PSB. -const LONG_KEYS: [&[u8; 4]; 13] = - [b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD"]; - -/// Big-endian reads; each one fails at the end of the data. -struct Reader<'a> { - b: &'a [u8], -} - -impl<'a> Reader<'a> { - fn take(&mut self, n: usize) -> Option<&'a [u8]> { - let (head, rest) = self.b.split_at_checked(n)?; - self.b = rest; - Some(head) - } - fn u16(&mut self) -> Option { - Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?)) - } - fn u32(&mut self) -> Option { - Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?)) - } - /// A length-prefixed block: 4-byte length, or 8 bytes when `long`. - fn block(&mut self, long: bool) -> Option<&'a [u8]> { - let n = if long { u64::from_be_bytes(self.take(8)?.try_into().ok()?) } else { u64::from(self.u32()?) }; - self.take(usize::try_from(n).ok()?) - } -} - -#[derive(Clone, Copy, PartialEq)] -enum Mode { - Bitmap, - /// Grayscale, and Duotone (its data is the gray channel; the inks are in the colour mode data). - Gray, - Indexed, - Rgb, - Cmyk, - Lab, -} - -impl Mode { - fn parse(v: u16) -> Option { - Some(match v { - 0 => Mode::Bitmap, - 1 | 8 => Mode::Gray, - 2 => Mode::Indexed, - 3 => Mode::Rgb, - 4 => Mode::Cmyk, - 9 => Mode::Lab, - _ => return None, - }) - } - fn colour_channels(self) -> usize { - match self { - Mode::Bitmap | Mode::Gray | Mode::Indexed => 1, - Mode::Rgb | Mode::Lab => 3, - Mode::Cmyk => 4, - } - } - /// The stored channel values of white: a composite with transparency is matted against it. - fn white(self) -> Option<[f32; 4]> { - match self { - Mode::Gray | Mode::Rgb | Mode::Cmyk => Some([255.0; 4]), - Mode::Lab => Some([255.0, 128.0, 128.0, 0.0]), - Mode::Bitmap | Mode::Indexed => None, - } - } -} - -/// The merged composite as straight RGBA8, `None` when the file is damaged or unsupported (32-bit, -/// Multichannel, ZIP-compressed composite). -/// Whether the header's depth, channel count and size are all within what the decoder supports. -fn header_in_range(mode: Mode, depth: u16, channels: usize, (w, h): (u32, u32), max_side: u32) -> bool { - let depth_ok = match mode { - Mode::Bitmap => depth == 1, - Mode::Indexed => depth == 8, - _ => depth == 8 || depth == 16, - }; - (1..=56).contains(&channels) && channels >= mode.colour_channels() && (1..=max_side).contains(&w) && (1..=max_side).contains(&h) && depth_ok -} +/// The largest decoded raster in RGBA bytes. +const MAX_RGBA_BYTES: u64 = 512 << 20; pub(crate) fn decode(bytes: &[u8]) -> Option { - let mut r = Reader { b: bytes }; - if r.take(4)? != b"8BPS" { + let (file, _) = craft_psd::PsdFile::from_bytes_salvaging(bytes).ok()?; + let header = &file.header; + let pixels = usize::try_from(header.width).ok()?.checked_mul(usize::try_from(header.height).ok()?)?; + if u64::try_from(pixels).ok()?.checked_mul(4)? > MAX_RGBA_BYTES { return None; } - let psb = match r.u16()? { - 1 => false, - 2 => true, - _ => return None, - }; - r.take(6)?; - let channels = usize::from(r.u16()?); - let (h, w) = (r.u32()?, r.u32()?); - let depth = r.u16()?; - let mode = Mode::parse(r.u16()?)?; - let max_side = if psb { 300_000 } else { 30_000 }; - let colours = mode.colour_channels(); - if !header_in_range(mode, depth, channels, (w, h), max_side) { + if header.color_mode == ColorMode::Cmyk { + let raster = cmyk(&file, pixels)?; + return to_display_rgba(Pixels::Cmyk(raster), file.icc_profile(), &DisplayColour::active()); + } + let composite = file.composite_rgba8().ok()?; + let rgba = image::RgbaImage::from_raw(composite.width, composite.height, composite.data)?; + match (header.color_mode, file.icc_profile()) { + (ColorMode::Grayscale | ColorMode::Duotone, Some(icc)) => to_display_rgba(Pixels::Gray(rgba), Some(icc), &DisplayColour::active()), + (ColorMode::Rgb, Some(icc)) => to_display_rgba(Pixels::Rgb(rgba), Some(icc), &DisplayColour::active()), + _ => Some(rgba), + } +} + +fn cmyk(file: &craft_psd::PsdFile, pixels: usize) -> Option { + let header = &file.header; + if !matches!(header.depth, 8 | 16) || header.channels < 4 { return None; } - let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); - let pixels = wu.checked_mul(hu)?; - let (stride, len) = (wu.checked_mul(4)?, pixels.checked_mul(4)?); - if len > MAX_RGBA_BYTES { + let plane = header.row_bytes().checked_mul(usize::try_from(header.height).ok()?)?; + let decoded = file.decode_merged().ok()?; + let needed = plane.checked_mul(4)?; + if decoded.len() < needed { return None; } - - let colour_mode_data = r.block(false)?; - let icc = icc_profile(r.block(false)?); - let layers = r.block(psb)?; - let transparency = channels > colours && layer_count(layers, psb).is_some_and(|n| n < 0); - let row_bytes = wu.checked_mul(usize::from(depth))?.div_ceil(8); - let data = ImageData::parse(r, psb, channels, row_bytes, hu)?; - let palette = if mode == Mode::Indexed { Some(palette(colour_mode_data)?) } else { None }; - // Header sizes are untrusted: allocate only once the data to fill the image is present. - data.holds(colours + usize::from(transparency))?; - - let mut rgba = vec![0u8; len]; - for c in 0..colours { - let mut rows = rgba.chunks_exact_mut(stride); - data.channel(c, |src| { - if let Some(dst) = rows.next() { - fill(dst, c, 4, src, depth); + let alpha_plane = if file.merged_has_alpha() { Some(decoded.get(needed..needed.checked_add(plane)?)?) } else { None }; + let sample = |channel: usize, pixel: usize| -> Option { + let bytes_per_sample = usize::from(header.depth / 8); + let at = channel.checked_mul(plane)?.checked_add(pixel.checked_mul(bytes_per_sample)?)?; + let value = match header.depth { + 8 => *decoded.get(at)?, + 16 => { + let v = u16::from_be_bytes(decoded.get(at..at.checked_add(2)?)?.try_into().ok()?); + ((u32::from(v) * 255 + 32_767) / 65_535) as u8 } - })?; - } - let mut alpha = Vec::new(); - if transparency { - alpha = vec![0u8; pixels]; - let mut rows = alpha.chunks_exact_mut(wu); - data.channel(colours, |src| { - if let Some(dst) = rows.next() { - fill(dst, 0, 1, src, depth); - } - })?; - } - - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - let byte = |v: f32| v.round().clamp(0.0, 255.0) as u8; - let white = mode.white(); - for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let a = alpha.get(i).copied().unwrap_or(255); - let mut v = px.map(f32::from); - // The composite is matted against white where it's partly transparent: un-matte it. - if let Some(white) = white - && a > 0 - && a < 255 - { - for (v, w) in v.iter_mut().zip(white).take(colours) { - *v = (w + (*v - w) * 255.0 / f32::from(a)).round().clamp(0.0, 255.0); - } - } - let [v0, v1, v2, v3] = v; - *px = match mode { - Mode::Bitmap | Mode::Gray => [byte(v0), byte(v0), byte(v0), a], - Mode::Indexed => { - let Some([pr, pg, pb]) = &palette else { return None }; - let i = usize::from(px[0]); - [pr[i], pg[i], pb[i], a] - } - Mode::Rgb => [byte(v0), byte(v1), byte(v2), a], - // Stored inverted (255 is no ink): ink values, converted below. - Mode::Cmyk => [v0, v1, v2, v3].map(|v| 255 - byte(v)), - Mode::Lab => { - let [r, g, b] = lab_to_srgb(Lab::new(v0 * 100.0 / 255.0, v1 - 128.0, v2 - 128.0)); - [q(r), q(g), q(b), a] - } - }; - } - let icc = icc.filter(|_| matches!(mode, Mode::Gray | Mode::Rgb | Mode::Cmyk)); - if mode == Mode::Cmyk { - let alpha = transparency.then_some(alpha); - let raster = CmykRaster { width: w, height: h, cmyk: rgba, alpha }; - return to_display_rgba(Pixels::Cmyk(raster), icc, &DisplayColour::active()); - } - let img = image::RgbaImage::from_raw(w, h, rgba)?; - match icc { - Some(icc) if mode == Mode::Gray => to_display_rgba(Pixels::Gray(img), Some(icc), &DisplayColour::active()), - Some(icc) => to_display_rgba(Pixels::Rgb(img), Some(icc), &DisplayColour::active()), - None => Some(img), - } -} - -/// The ICC profile in the image resources section (resource 1039), if any. -fn icc_profile(resources: &[u8]) -> Option<&[u8]> { - let mut r = Reader { b: resources }; - while r.take(4)? == b"8BIM" { - let id = r.u16()?; - // Pascal name, padded to an even length including its length byte. - let name_len = usize::from(*r.b.first()?); - r.take((name_len + 2) & !1)?; - let data = r.block(false)?; - if data.len() % 2 == 1 { - r.take(1)?; - } - if id == 1039 { - return Some(data); - } - } - None -} - -/// The layer count from the layer and mask section. 16- and 32-bit documents keep their layer -/// info in an `Lr16` / `Lr32` tagged block after the global layer mask info. -fn layer_count(section: &[u8], psb: bool) -> Option { - let count = |info: &[u8]| Some(i16::from_be_bytes(info.get(..2)?.try_into().ok()?)); - let mut r = Reader { b: section }; - let info = r.block(psb)?; - if !info.is_empty() { - return count(info); - } - r.block(false)?; // global layer mask info - while !r.b.is_empty() { - let sig = r.take(4)?; - if sig != b"8BIM" && sig != b"8B64" { - return None; - } - let key = r.take(4)?; - let data = r.block(psb && LONG_KEYS.iter().any(|k| k.as_slice() == key))?; - if matches!(key, b"Lr16" | b"Lr32" | b"Layr") { - return count(data); - } - // Padding to 2 or 4 bytes (a signature never starts with 0). - while r.b.first() == Some(&0) { - r.take(1)?; - } - } - None -} - -/// An indexed-colour palette: 256 reds, then greens, then blues. -fn palette(data: &[u8]) -> Option<[[u8; 256]; 3]> { - let part = |i: usize| data.get(i * 256..(i + 1) * 256)?.try_into().ok(); - Some([part(0)?, part(1)?, part(2)?]) -} - -/// The composite's channels: raw planes, or PackBits-compressed rows. -struct ImageData<'a> { - /// Each row's compressed length, channel after channel (2 bytes each, 4 in a PSB); empty for raw. - counts: &'a [u8], - count_size: usize, - data: &'a [u8], - row_bytes: usize, - height: usize, -} - -impl<'a> ImageData<'a> { - fn parse(mut r: Reader<'a>, psb: bool, channels: usize, row_bytes: usize, height: usize) -> Option { - let count_size = if psb { 4 } else { 2 }; - let counts = match r.u16()? { - 0 => &[][..], - 1 => r.take(channels.checked_mul(height)?.checked_mul(count_size)?)?, - // ZIP appears only in layers, never in the composite. _ => return None, }; - Some(ImageData { counts, count_size, data: r.b, row_bytes, height }) + Some(255 - value) + }; + let mut inks = Vec::new(); + inks.try_reserve_exact(pixels.checked_mul(4)?).ok()?; + let mut alpha = Vec::new(); + if alpha_plane.is_some() { + alpha.try_reserve_exact(pixels).ok()?; } - - /// `Some` when the data can hold the first `channels` channels: whole raw planes, or row - /// counts that fit in the data and are each long enough to unpack to a full row (PackBits - /// writes at most 128 bytes per 2 input bytes). - fn holds(&self, channels: usize) -> Option<()> { - let rows = channels.checked_mul(self.height)?; - if self.counts.is_empty() { - let need = self.row_bytes.checked_mul(rows)?; - return (need <= self.data.len()).then_some(()); + for p in 0..pixels { + for channel in 0..4 { + inks.push(sample(channel, p)?); } - let min_row = self.row_bytes.div_ceil(128).checked_mul(2)?; - let mut total = 0usize; - for c in self.counts.chunks_exact(self.count_size).take(rows) { - let n = c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)); - if n < min_row { - return None; - } - total = total.checked_add(n)?; + if let Some(plane) = alpha_plane { + let bytes_per_sample = usize::from(header.depth / 8); + let at = p.checked_mul(bytes_per_sample)?; + let a = match header.depth { + 8 => *plane.get(at)?, + 16 => { + let v = u16::from_be_bytes(plane.get(at..at.checked_add(2)?)?.try_into().ok()?); + ((u32::from(v) * 255 + 32_767) / 65_535) as u8 + } + _ => return None, + }; + alpha.push(a); } - (total <= self.data.len()).then_some(()) - } - - /// Channel `ch`'s rows, uncompressed, to `row` from top to bottom. - fn channel(&self, ch: usize, mut row: impl FnMut(&[u8])) -> Option<()> { - if self.row_bytes == 0 { - return None; - } - if self.counts.is_empty() { - let plane = self.row_bytes.checked_mul(self.height)?; - let start = ch.checked_mul(plane)?; - self.data.get(start..start.checked_add(plane)?)?.chunks_exact(self.row_bytes).for_each(row); - return Some(()); - } - let mut counts = self.counts.chunks_exact(self.count_size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b))); - // This channel starts after every earlier channel's rows. - let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, usize::checked_add)?; - for _ in 0..self.height { - let n = counts.next()?; - let end = at.checked_add(n)?; - row(&unpack_bits(self.data.get(at..end)?, self.row_bytes)?); - at = end; - } - Some(()) } + let alpha = alpha.iter().any(|&a| a < 255).then_some(alpha); + Some(CmykRaster { width: header.width, height: header.height, cmyk: inks, alpha }) } - -/// One PackBits row; `None` unless it unpacks to exactly `len` bytes. -fn unpack_bits(mut src: &[u8], len: usize) -> Option> { - let mut out = Vec::with_capacity(len); - while let Some((&head, rest)) = src.split_first() { - src = rest; - match head as i8 { - -128 => {} - n @ 0.. => { - let (literal, rest) = src.split_at_checked(n as usize + 1)?; - out.extend_from_slice(literal); - src = rest; - } - n => { - let (&v, rest) = src.split_first()?; - out.resize(out.len() + (1 - n as isize) as usize, v); - src = rest; - } - } - if out.len() > len { - return None; - } - } - (out.len() == len).then_some(out) -} - -/// A row of `depth`-bit samples as 8-bit values, into every `step`-th byte of `dst` from `slot`. -/// Bitmap mode stores black as 1. -fn fill(dst: &mut [u8], slot: usize, step: usize, src: &[u8], depth: u16) { - let dst = dst.iter_mut().skip(slot).step_by(step); - match depth { - 1 => dst.zip(src.iter().flat_map(|&b| (0..8).rev().map(move |i| if (b >> i) & 1 == 1 { 0 } else { 255 }))).for_each(|(d, s)| *d = s), - 16 => dst - .zip(src.as_chunks::<2>().0.iter().map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)))) - .for_each(|(d, s)| *d = ((s * 255 + 32_767) / 65_535) as u8), - _ => dst.zip(src).for_each(|(d, &s)| *d = s), - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs index 3d0b945ce..a4b1515fe 100644 --- a/crates/images/src/tone.rs +++ b/crates/images/src/tone.rs @@ -13,49 +13,9 @@ pub enum Tone { /// The [`Tone`] of an image file, `None` when a swatch doesn't colour it. pub fn tone(bytes: &[u8]) -> Option { - use image::{ExtendedColorType as E, ImageDecoder, ImageFormat as F}; - if crate::is_psd(bytes) { - // Header: channels at 12, colour mode at 24 (big-endian). A second channel is alpha or spot. - let be = |i: usize| Some(u16::from_be_bytes([*bytes.get(i)?, *bytes.get(i + 1)?])); - return match (be(12)?, be(24)?) { - (1, 0) => Some(Tone::Bitmap), - (1, 1) => Some(Tone::Gray), - _ => None, - }; - } - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; - match reader.format()? { - F::Png => { - // `image` expands 1-bit greys to 8 bits: the depth is IHDR's (signature, length, type, - // width, height, then the bit depth). - let gray = matches!(reader.into_decoder().ok()?.color_type(), image::ColorType::L8 | image::ColorType::L16); - let one_bit = *bytes.get(24)? == 1; - gray.then_some(if one_bit { Tone::Bitmap } else { Tone::Gray }) - } - F::Tiff | F::Jpeg | F::Bmp => match reader.into_decoder().ok()?.original_color_type() { - E::L1 => Some(Tone::Bitmap), - E::L2 | E::L4 | E::L8 | E::L16 => Some(Tone::Gray), - _ => None, - }, - // Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap. - F::Gif => { - if crate::too_large_to_decode(bytes) { - return None; - } - let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8(); - let mut opaque = true; - let mut two_level = true; - for p in img.pixels() { - let [r, g, b, a] = p.0; - match a { - 0 => opaque = false, - 255 if r == g && g == b => two_level &= r == 0 || r == 255, - _ => return None, - } - } - if two_level { Some(Tone::Bitmap) } else { opaque.then_some(Tone::Gray) } - } - _ => None, + match craft_image_codecs::classify_tone(bytes)? { + craft_image_codecs::ToneClass::Bitmap => Some(Tone::Bitmap), + craft_image_codecs::ToneClass::Gray => Some(Tone::Gray), } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 8cd446af1..4f93f62ee 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -22,6 +22,7 @@ designcraft-images = { workspace = true } log = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } +craft-image-codecs = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] vello_cpu = { workspace = true, features = ["multithreading"] } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 25207b248..e6e5d04e8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -76,15 +76,17 @@ impl Rendered { let mut px = self.to_straight(); // `pixels` is public: a mismatched buffer is padded or cut to the stated size. px.resize(self.width as usize * self.height as usize * 4, 0); - let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { + let Ok(img) = craft_image_codecs::Image::from_u8(self.width, self.height, craft_image_codecs::ChannelLayout::Rgba, px) else { log::error!("PNG encode: {}×{} image is too large", self.width, self.height); - return Vec::new(); + return buf; }; - if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) { - log::error!("PNG encode: {e}"); - return Vec::new(); + match craft_image_codecs::encode(&img, craft_image_codecs::Format::Png, &Default::default()) { + Ok(bytes) => bytes, + Err(e) => { + log::error!("PNG encode: {e}"); + buf + } } - buf } pub fn to_jpeg(&self, quality: u8) -> Vec { let rgba = self.to_straight(); @@ -99,9 +101,18 @@ impl Rendered { }) .collect(); let mut buf = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); - let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8); - buf + let Ok(img) = craft_image_codecs::Image::from_u8(self.width, self.height, craft_image_codecs::ChannelLayout::Rgb, rgb) else { + log::error!("JPEG encode: {}×{} image is too large", self.width, self.height); + return buf; + }; + let options = craft_image_codecs::EncodeOptions { jpeg_quality: quality.clamp(1, 100), ..Default::default() }; + match craft_image_codecs::encode(&img, craft_image_codecs::Format::Jpeg, &options) { + Ok(bytes) => bytes, + Err(e) => { + log::error!("JPEG encode: {e}"); + buf + } + } } /// Straight-alpha RGBA at (x, y). pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { diff --git a/crates/textimport/Cargo.toml b/crates/textimport/Cargo.toml index 34302939b..460458ddf 100644 --- a/crates/textimport/Cargo.toml +++ b/crates/textimport/Cargo.toml @@ -12,5 +12,9 @@ workspace = true designcraft-doc = { workspace = true } craft-rtf = { workspace = true } craft-xlsx = { workspace = true } -quick-xml = { workspace = true } +craft-docx = { workspace = true } +craft-wordmodel = { workspace = true } +craft-idml = { workspace = true } + +[dev-dependencies] zip = { workspace = true } diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index fb76d366a..ba869a599 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -1,680 +1,331 @@ -//! Word (.docx, Office Open XML) → story. +//! DOCX to DesignCraft story adapter. Package, XML, styles and numbering are read by craft-docx; +//! this module maps the neutral Word model into the app's story and style model. -use std::collections::HashMap; -use std::io::Read; +use std::collections::{HashMap, HashSet}; +use craft_wordmodel::Block; +use craft_wordmodel::para::{InlineObject, NoteKind, Paragraph}; +use craft_wordmodel::props::{Align as WordAlign, CharProps as WordCharProps, ParaProps as WordParaProps, Underline, VertAlign}; +use craft_wordmodel::section::NumFormat; +use craft_wordmodel::styles::{StyleKind, StyleSheet}; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; -use designcraft_doc::{CharAttrs, CharFormat, ListCounter, ListType, ParaAttrs, ParaFormat, ParaProps, Story, StoryId, Table}; -use quick_xml::events::Event; +use designcraft_doc::{CharAttrs, CharFormat, ListType, NumberStyle, ParaAttrs, ParaFormat, Story, StoryId, Table}; -use crate::docx_numbering::{ListPara, Numbering}; use crate::{ImportError, Imported, ImportedStyle}; -/// A minimal element tree (local names, attributes by local name). -#[derive(Debug, Default, Clone)] -pub(crate) struct El { - pub(crate) name: String, - attrs: Vec<(String, String)>, - kids: Vec, +const MAX_NAME_CHARS: usize = 256; +const MAX_NOTE_BYTES: usize = 8 << 20; +const MAX_WARNINGS: usize = 100; + +fn capped_name(name: &str) -> String { + name.chars().take(MAX_NAME_CHARS).collect() } -#[derive(Debug, Clone)] -enum Node { - El(El), - Text(String), +fn style_names(styles: &StyleSheet) -> HashMap { + styles.styles.iter().map(|s| (s.id.clone(), capped_name(&s.name))).collect() } -impl El { - pub(crate) fn attr(&self, k: &str) -> Option<&str> { - self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) - } - pub(crate) fn els(&self) -> impl Iterator { - self.kids.iter().filter_map(|n| if let Node::El(e) = n { Some(e) } else { None }) - } - pub(crate) fn child(&self, name: &str) -> Option<&El> { - self.els().find(|e| e.name == name) - } - pub(crate) fn text(&self) -> String { - self.kids - .iter() - .map(|n| match n { - Node::Text(t) => t.clone(), - Node::El(e) => e.text(), - }) - .collect() - } - /// `` → the value; a bare `` counts as "on". - pub(crate) fn val(&self, name: &str) -> Option { - self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string()) - } - /// A toggle property (`ST_OnOff`: a boolean, or the words `on` and `off`). None when unset. - pub(crate) fn on(&self, name: &str) -> Option { - self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none" | "off")) - } -} - -fn local(n: &[u8]) -> String { - let s = String::from_utf8_lossy(n); - s.rsplit(':').next().unwrap_or(&s).to_string() -} - -/// How deep elements may nest. Word's own XML stays well under a hundred levels; the tree is read -/// recursively afterwards (`El::text`, the block walk) and dropped recursively, so a document -/// nested without bound must be refused here, not run into the stack (#712). -pub(crate) const MAX_DEPTH: usize = 256; - -pub(crate) fn parse(xml: &str) -> Result { - let mut r = quick_xml::Reader::from_str(xml); - let mut stack: Vec = vec![El::default()]; - loop { - match r.read_event().map_err(|e| ImportError::Corrupt(e.to_string()))? { - Event::Start(e) => { - if stack.len() > MAX_DEPTH { - return Err(ImportError::Corrupt(format!("XML elements nested deeper than {MAX_DEPTH} levels"))); - } - let el = El { - name: local(e.name().as_ref()), - attrs: e - .attributes() - .flatten() - .map(|a| { - (local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default()) - }) - .collect(), - kids: vec![], - }; - stack.push(el); - } - Event::Empty(e) => { - let el = El { - name: local(e.name().as_ref()), - attrs: e - .attributes() - .flatten() - .map(|a| { - (local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default()) - }) - .collect(), - kids: vec![], - }; - if let Some(p) = stack.last_mut() { - p.kids.push(Node::El(el)); - } - } - Event::End(_) => { - let el = stack.pop().unwrap_or_default(); - match stack.last_mut() { - Some(p) => p.kids.push(Node::El(el)), - None => return Ok(el), - } - } - Event::Text(t) => { - let s = t.decode().map(|s| s.to_string()).unwrap_or_default(); - let s = quick_xml::escape::unescape(&s).map(|s| s.to_string()).unwrap_or(s); - if let Some(p) = stack.last_mut() { - p.kids.push(Node::Text(s)); - } - } - Event::CData(t) => { - let s = t.decode().map(|s| s.to_string()).unwrap_or_default(); - if let Some(p) = stack.last_mut() { - p.kids.push(Node::Text(s)); - } - } - Event::GeneralRef(e) => { - let name = String::from_utf8_lossy(&e).to_string(); - let s = match name.as_str() { - "amp" => "&".to_string(), - "lt" => "<".into(), - "gt" => ">".into(), - "quot" => "\"".into(), - "apos" => "'".into(), - n if n.starts_with("#x") && n[2..].bytes().all(|b| b.is_ascii_hexdigit()) => { - u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32).map(String::from).unwrap_or_default() - } - n if n.starts_with('#') && !n.starts_with("#x") && n[1..].bytes().all(|b| b.is_ascii_digit()) => { - n[1..].parse::().ok().and_then(char::from_u32).map(String::from).unwrap_or_default() - } - _ => String::new(), - }; - if let Some(p) = stack.last_mut() { - p.kids.push(Node::Text(s)); - } - } - Event::Eof => break, - _ => {} - } - } - let mut root = stack.pop().unwrap_or_default(); - Ok(root.kids.drain(..).find_map(|n| if let Node::El(e) = n { Some(e) } else { None }).unwrap_or_default()) -} - -/// Largest decompressed zip part read (a small file can inflate to gigabytes). -pub(crate) const PART_LIMIT: u64 = 64 * 1024 * 1024; - -/// A zip part as text. `Ok(None)` when the part is missing or isn't text; an error when it -/// decompresses to more than [`PART_LIMIT`]. -pub(crate) fn part(zip: &mut zip::ZipArchive>, name: &str) -> Result, ImportError> { - part_capped(zip, name, PART_LIMIT) -} - -pub(crate) fn part_capped(zip: &mut zip::ZipArchive>, name: &str, limit: u64) -> Result, ImportError> { - let Ok(f) = zip.by_name(name) else { return Ok(None) }; - let mut s = String::new(); - // Read one byte past the limit to tell "exactly at the limit" from "over it". - if f.take(limit.saturating_add(1)).read_to_string(&mut s).is_err() { - return Ok(None); - } - if s.len() as u64 > limit { - return Err(ImportError::Corrupt(format!("{name} is larger than {} MiB uncompressed", limit / (1024 * 1024)))); - } - Ok(Some(s)) -} - -/// Character attributes from ``. -fn run_attrs(rpr: Option<&El>) -> CharAttrs { - let mut a = CharAttrs::default(); - let Some(r) = rpr else { return a }; - let bold = r.on("b"); - let italic = r.on("i"); - a.font_style = match (bold, italic) { +fn char_attrs(p: &WordCharProps) -> CharAttrs { + let mut out = CharAttrs::default(); + out.font_family = p.font.as_ref().map(|s| capped_name(s)); + out.font_style = match (p.bold, p.italic) { (Some(true), Some(true)) => Some("Bold Italic".into()), (Some(true), _) => Some("Bold".into()), (_, Some(true)) => Some("Italic".into()), - (Some(false), Some(false)) | (Some(false), None) | (None, Some(false)) => Some("Regular".into()), + (Some(false), Some(false)) => Some("Regular".into()), _ => None, }; - if let Some(u) = r.val("u") { - a.underline = Some(!matches!(u.as_str(), "none" | "0" | "false" | "off")); - } - if let Some(s) = r.on("strike") { - a.strikethrough = Some(s); - } - // Half-points; a size the engine cannot mean is dropped (#1593). - if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).and_then(designcraft_doc::attrs::imported_type_size) { - a.size = Some(pt); - } - if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { - a.font_family = Some(f.to_string()); - } - if r.on("caps") == Some(true) { - a.capitalization = Some(designcraft_doc::Capitalization::AllCaps); - } - if let Some(v) = r.val("vertAlign") { - a.position = Some(match v.as_str() { - "superscript" => designcraft_doc::Position::Superscript, - "subscript" => designcraft_doc::Position::Subscript, - _ => designcraft_doc::Position::Normal, + out.size = p.size.map(f64::from).and_then(designcraft_doc::attrs::imported_type_size); + out.underline = p.underline.map(|u| u != Underline::None); + out.strikethrough = p.strike.or(p.double_strike); + out.capitalization = p.caps.filter(|&on| on).map(|_| designcraft_doc::Capitalization::AllCaps); + out.position = p.vert_align.map(|v| match v { + VertAlign::Superscript => designcraft_doc::Position::Superscript, + VertAlign::Subscript => designcraft_doc::Position::Subscript, + VertAlign::Baseline => designcraft_doc::Position::Normal, + }); + out +} + +fn para_attrs(p: &WordParaProps, doc: &craft_wordmodel::Document) -> ParaAttrs { + let mut out = ParaAttrs::default(); + out.align = p.align.map(|a| match a { + WordAlign::Center => designcraft_doc::Align::Center, + WordAlign::Right => designcraft_doc::Align::Right, + WordAlign::Justify | WordAlign::Distribute => designcraft_doc::Align::LeftJustified, + WordAlign::Left => designcraft_doc::Align::Left, + }); + out.left_indent = p.indent_left.map(f64::from).and_then(designcraft_doc::attrs::imported_measure); + out.right_indent = p.indent_right.map(f64::from).and_then(designcraft_doc::attrs::imported_measure); + out.first_line_indent = p.indent_first.map(f64::from).and_then(designcraft_doc::attrs::imported_measure); + out.space_before = p.space_before.map(f64::from).and_then(designcraft_doc::attrs::imported_measure); + out.space_after = p.space_after.map(f64::from).and_then(designcraft_doc::attrs::imported_measure); + if let Some(numbering) = p.numbering.filter(|n| n.num != 0) { + let definition = doc.numbering.nums.iter().find(|n| n.id == numbering.num).and_then(|n| { + n.level_overrides.iter().find(|(level, _)| *level == numbering.level).map(|(_, def)| def).or_else(|| { + let abs = doc.numbering.abstracts.iter().find(|a| a.id == n.abstract_id)?; + abs.levels.get(usize::from(numbering.level)) + }) }); - } - a -} - -/// Paragraph attributes from `` (spacing in twips = 1/20 pt, indents likewise). -fn para_attrs(ppr: Option<&El>) -> ParaAttrs { - let mut a = ParaAttrs::default(); - let Some(p) = ppr else { return a }; - // Twips, clamped: a file can hold 1e300, which became a 5e298 pt indent (#1593). - let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure); - if let Some(sp) = p.child("spacing") { - a.space_before = tw(sp.attr("before")); - a.space_after = tw(sp.attr("after")); - } - if let Some(ind) = p.child("ind") { - a.left_indent = tw(ind.attr("left").or(ind.attr("start"))); - a.right_indent = tw(ind.attr("right").or(ind.attr("end"))); - a.first_line_indent = tw(ind.attr("firstLine")).or(tw(ind.attr("hanging")).map(|h| -h)); - } - if let Some(j) = p.val("jc") { - a.align = Some(match j.as_str() { - "center" => designcraft_doc::Align::Center, - "right" | "end" => designcraft_doc::Align::Right, - "both" | "distribute" => designcraft_doc::Align::LeftJustified, - _ => designcraft_doc::Align::Left, - }); - } - a -} - -struct Ctx { - para_names: HashMap, - char_names: HashMap, - footnotes: HashMap, - /// Footnote ids already imported, and how much note text has been built. A reference clones - /// its note and walks it again, so one 1 MB footnote referenced 100k times meant ~100 GB - /// (#1584). Word gives each note its own id and references it once; a second reference to the - /// same id gets the mark without the text. - notes_done: std::collections::HashSet, - note_bytes: usize, - warnings: Vec, - /// What has already been said, so saying it again is a lookup rather than a scan of - /// everything said so far. A warning that names a footnote id is one per id, and the - /// document chooses how many ids it has (noted in review). - warned: std::collections::HashSet, - next_table: u64, - numbering: Numbering, - /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. - style_lists: HashMap, Option)>, - /// The number the model's own list counter is at (it restarts after a paragraph that isn't - /// in a list), to tell where a numbered paragraph needs an explicit Start At. - /// The model's unnamed-list numbering as composition will count it. - model_counter: ListCounter, - /// The style of paragraphs without `w:pStyle` (Word's default paragraph style, usually Normal). - default_para: String, -} - -/// A note's tables may hold tables of their own. The document model stops nesting at 64, so -/// counting no deeper than that reaches every cell a note can actually have. -const MAX_NOTE_DEPTH: usize = 64; - -/// The text `s` holds, including its tables' cell text. A table's cells each keep their text in -/// a story of their own rather than in `s.text`, which holds only the anchor, so counting -/// `s.text` scored a table of any size as a couple of bytes (noted in review). -fn note_bytes(s: &Story, depth: usize) -> usize { - let mut n = s.text.len(); - if depth == 0 { - return n; - } - for t in s.tables.values() { - for c in &t.cells { - n = n.saturating_add(note_bytes(&c.text, depth - 1)); - } - } - n -} - -/// Warnings one import reports. A document that earns more than this has earned the point. -const MAX_WARNINGS: usize = 100; - -impl Ctx { - /// Report `message` once. Beyond [`MAX_WARNINGS`] distinct messages nothing further is kept, - /// not even in the set, so neither the report nor the memory behind it grows with the - /// document -- a footnote warning names an id, and a file can hold as many ids as it likes. - fn warn(&mut self, message: String) { - if self.warnings.len() > MAX_WARNINGS { - return; - } - if !self.warned.insert(message.clone()) { - return; - } - if self.warnings.len() == MAX_WARNINGS { - self.warnings.push(format!("The Word file has more than {MAX_WARNINGS} kinds of problem: the rest are not listed.")); - } else { - self.warnings.push(message); - } - } - - fn para_style(&self, ppr: Option<&El>) -> String { - ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone()) - } - - /// Append the paragraphs and tables of `body` to `st`. - fn blocks(&mut self, body: &El, st: &mut Story, allow_notes: bool) { - let mut first = st.is_empty(); - for e in body.els() { - match e.name.as_str() { - "p" => { - if !first { - let len = st.len(); - st.insert(len, "\n"); - } - first = false; - let ppr = e.child("pPr"); - let style = self.para_style(ppr); - let mut attrs = para_attrs(ppr); - let list = self.list(ppr); - if let Some(l) = &list { - // The level's indents, unless the paragraph sets its own. - let direct = attrs; - attrs = l.attrs.clone(); - attrs.merge(&direct); - } - let at = st.len(); - st.format_paras(at..at, |f| *f = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() }); - self.inline(e, st, allow_notes); - let (start_at, expression) = self.start_at(list.as_ref(), st.len() == at); - if let Some(n) = start_at { - st.format_paras(at..at, |f| f.para.start_at = Some(Some(n))); - } - if let Some(e) = expression { - st.format_paras(at..at, |f| f.para.number_expression = Some(e.clone())); - } + if let Some(level) = definition { + if level.format == NumFormat::Bullet { + out.list_type = Some(ListType::Bullets); + out.bullet_char = Some(level.text.chars().next().unwrap_or('•').to_string()); + } else if level.format != NumFormat::None { + out.list_type = Some(ListType::Numbers); + out.number_style = Some(match level.format { + NumFormat::UpperRoman => NumberStyle::UpperRoman, + NumFormat::LowerRoman => NumberStyle::LowerRoman, + NumFormat::UpperLetter => NumberStyle::UpperLetters, + NumFormat::LowerLetter => NumberStyle::LowerLetters, + NumFormat::DecimalZero => NumberStyle::ArabicLeadingZero, + _ => NumberStyle::Arabic, + }); + let mut expression = level.text.clone(); + for i in (1..=9).rev() { + expression = expression.replace(&format!("%{i}"), &format!("^{i}")); } - "tbl" => { - let mut rows: Vec> = Vec::new(); - for r in e.els().filter(|r| r.name == "tr") { - let mut row = Vec::new(); - for c in r.els().filter(|c| c.name == "tc") { - let mut paras = Vec::new(); - self.cell_paras(c, &mut paras); - row.push(paras.join("\n")); - } - rows.push(row); - } - if rows.is_empty() { - continue; - } - self.next_table += 1; - let t = Table::from_strings(self.next_table, &rows, 300.0, &ParaFormat::default(), &CharFormat::default()); - let len = st.len(); - st.insert_table(len, t); - first = st.text.ends_with('\n'); + out.number_expression = Some(expression); + if level.start > 1 { + out.start_at = Some(Some(level.start)); } - "sdt" => { - if let Some(c) = e.child("sdtContent") { - self.blocks(c, st, allow_notes); - } - } - _ => {} } - } - } - - /// The text of each paragraph in a table cell, read by the same run rules as body text. - /// Cells are plain text, so a page or column break in a cell ends its paragraph. - fn cell_paras(&mut self, el: &El, out: &mut Vec) { - for e in el.els() { - match e.name.as_str() { - "p" => { - let mut st = Story::new(StoryId(0)); - self.inline(e, &mut st, false); - let is_break = |c: char| matches!(c, designcraft_doc::PAGE_BREAK | designcraft_doc::COLUMN_BREAK); - out.push(st.text.chars().map(|c| if is_break(c) { '\n' } else { c }).collect()); - } - "sdt" => { - if let Some(c) = e.child("sdtContent") { - self.cell_paras(c, out); - } - } - _ => {} - } - } - } - - /// The list a paragraph is in (its own `w:numPr`, else its style's), counting it. - fn list(&mut self, ppr: Option<&El>) -> Option { - let direct = ppr.and_then(|p| p.child("numPr")); - let style = ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.style_lists.get(&id)); - let num_id = direct.and_then(|n| n.val("numId")).or_else(|| style.and_then(|s| s.0.clone()))?; - let ilvl = direct.and_then(|n| n.val("ilvl")).or_else(|| style.and_then(|s| s.1.clone())); - // A level that isn't a small number is no list. - let ilvl = ilvl.map_or(Some(0), |l| l.trim().parse::().ok())?; - self.numbering.next(num_id.trim(), ilvl) - } - - /// Start At for a paragraph, so that the model numbers it as Word does: only where the - /// model's counter (per level; restarted after a paragraph that isn't in a list) would give a - /// different number. And the number expression with parent levels as text, where the model's - /// numbers for those levels differ from Word's. - fn start_at(&mut self, list: Option<&ListPara>, empty: bool) -> (Option, Option) { - let Some(l) = list else { - self.model_counter = ListCounter::default(); - return (None, None); - }; - // Bullets and empty paragraphs leave the counter alone (they show no number). - if l.attrs.list_type != Some(ListType::Numbers) || empty { - return (None, None); - } - let want = l.number.max(1); - let mut pp = ParaProps::default(); - pp.apply(&l.attrs); - let mut model = self.model_counter.clone(); - let start = (model.advance(&pp) != want).then_some(want); - if start.is_some() { - model = self.model_counter.clone(); - pp.start_at = start; - model.advance(&pp); - } - let level_text = |k: u32| ParaProps { number_expression: format!("^{k}"), ..ParaProps::default() }.number_label_in(&model); - let expression = l.parents.iter().any(|(k, text)| level_text(*k) != *text).then(|| l.fixed_expression.clone()).flatten(); - self.model_counter = model; - (start, expression) - } - - /// Runs (and hyperlinks, fields' results) of a paragraph. - fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) { - for e in p.els() { - match e.name.as_str() { - "r" => self.run(e, st, allow_notes), - // Tracked changes import as accepted: insertions and move destinations are kept, - // deletions (`del`) and move sources (`moveFrom`) are dropped. - "hyperlink" | "smartTag" | "ins" | "moveTo" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes), - // A content control (date picker, drop-down, plain text) keeps its runs in `sdtContent`. - "sdt" => { - if let Some(c) = e.child("sdtContent") { - self.inline(c, st, allow_notes); - } - } - _ => {} - } - } - } - - fn run(&mut self, r: &El, st: &mut Story, allow_notes: bool) { - let rpr = r.child("rPr"); - let style = rpr.and_then(|p| p.val("rStyle")).and_then(|id| self.char_names.get(&id).cloned()).unwrap_or_else(|| NO_CHAR_STYLE.into()); - let fmt = CharFormat { style, over: run_attrs(rpr) }; - for e in r.els() { - let text = match e.name.as_str() { - "t" => e.text(), - "tab" => "\t".into(), - "br" => match e.attr("type") { - Some("page") => designcraft_doc::PAGE_BREAK.to_string(), - Some("column") => designcraft_doc::COLUMN_BREAK.to_string(), - _ => designcraft_doc::FORCED_LINE_BREAK.to_string(), - }, - "noBreakHyphen" => "\u{2011}".into(), - "softHyphen" => "\u{AD}".into(), - "footnoteReference" if allow_notes => { - const MAX_NOTE_BYTES: usize = 8 << 20; - let id = e.attr("id").unwrap_or_default().to_string(); - // The note's text is built once per id, and only while the budget lasts. A - // repeat reference, or one past the budget, still gets its mark and its - // number -- the reference is part of the text -- with an empty note (#1584). - // - // The id is recorded only once its text has actually been kept, further down: - // recording it here meant a first reference arriving after the budget was - // spent counted as "done" with an empty note, and the next reference to it - // then claimed the text was kept at the first one (noted in review). - let again = !id.is_empty() && self.notes_done.contains(&id); - let spent = self.note_bytes >= MAX_NOTE_BYTES; - if again || spent { - let len = st.len(); - if st.insert_note(len, "", ParaFormat::default()).is_some() { - let message = if again { - format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference.") - } else { - format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20)) - }; - self.warn(message); - } - continue; - } - if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { - let mut ns = Story::new(StoryId(0)); - let counter = std::mem::take(&mut self.model_counter); - self.blocks(¬e, &mut ns, false); - self.model_counter = counter; - self.note_bytes = self.note_bytes.saturating_add(note_bytes(&ns, MAX_NOTE_DEPTH)); - if !id.is_empty() { - self.notes_done.insert(id.clone()); - } - // Word puts the reference mark in the note text: we generate the number. - let len = st.len(); - match st.insert_note(len, "", ParaFormat::default()) { - Some(id) => { - if let Some(n) = st.note_mut(id) { - n.text = ns; - } - } - None => self.warn("A footnote in the Word file could not be imported.".into()), - } - } - continue; - } - "drawing" | "pict" | "object" => { - self.warn("Images and drawings in the Word file were not imported (place them separately).".into()); - continue; - } - _ => continue, - }; - if !text.is_empty() { - let len = st.len(); - st.insert_with(len, &text, fmt.clone()); - } - } - } -} - -/// Largest `word/numbering.xml` read. -const NUMBERING_LIMIT: u64 = 16 * 1024 * 1024; -/// Deepest `w:basedOn` chain followed (a cycle stops here too). -const MAX_STYLE_DEPTH: usize = 32; - -/// Each paragraph style's list: the first `w:numId` and `w:ilvl` on its `w:basedOn` chain. -fn style_lists(styles: &El) -> HashMap, Option)> { - let by_id: HashMap<&str, &El> = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?, s))).collect(); - let mut out = HashMap::new(); - for (id, s) in &by_id { - let (mut num_id, mut ilvl) = (None, None); - let mut cur = Some(*s); - for _ in 0..MAX_STYLE_DEPTH { - let Some(c) = cur else { break }; - if let Some(n) = c.child("pPr").and_then(|p| p.child("numPr")) { - num_id = num_id.or_else(|| n.val("numId")); - ilvl = ilvl.or_else(|| n.val("ilvl")); - } - if num_id.is_some() && ilvl.is_some() { - break; - } - cur = c.val("basedOn").and_then(|b| by_id.get(b.as_str()).copied()); - } - if num_id.is_some() { - out.insert(id.to_string(), (num_id, ilvl)); + out.left_indent = Some(f64::from(level.indent)); + out.first_line_indent = Some(-f64::from(level.hanging)); } } out } +fn char_format(p: &WordCharProps, names: &HashMap) -> CharFormat { + let style = p.style.as_ref().and_then(|id| names.get(id)).cloned().unwrap_or_else(|| NO_CHAR_STYLE.into()); + CharFormat { style, over: char_attrs(p) } +} + +fn style_records(styles: &StyleSheet, doc: &craft_wordmodel::Document) -> (Vec, Vec, HashMap) { + let names = style_names(styles); + let mut paras = Vec::new(); + let mut chars = Vec::new(); + for style in &styles.styles { + let name = names.get(&style.id).cloned().unwrap_or_else(|| capped_name(&style.name)); + let based_on = style.based_on.as_ref().and_then(|id| names.get(id)).cloned(); + let mut para = para_attrs(&style.para, doc); + let mut char_values = char_attrs(&style.chr); + if based_on.is_none() { + let mut defaults = para_attrs(&styles.default_para, doc); + defaults.merge(¶); + para = defaults; + let mut defaults = char_attrs(&styles.default_chr); + defaults.merge(&char_values); + char_values = defaults; + } + let record = ImportedStyle { name: name.clone(), based_on, para, chars: char_values }; + match style.kind { + StyleKind::Paragraph => paras.push(record), + StyleKind::Character if name != NO_CHAR_STYLE => chars.push(record), + _ => {} + } + } + (paras, chars, names) +} + +fn plain_blocks(blocks: &[std::sync::Arc]) -> String { + let mut out = String::new(); + let mut first = true; + for block in blocks { + match block.as_ref() { + Block::Para(p) => { + if !first { + out.push('\n'); + } + first = false; + out.push_str(&p.plain_text()); + } + Block::Table(t) => { + for row in &t.rows { + if !first { + out.push('\n'); + } + first = false; + out.push_str(&row.cells.iter().map(|c| plain_blocks(&c.blocks)).collect::>().join("\t")); + } + } + } + } + out +} + +fn word_text(c: char) -> char { + use craft_wordmodel::para::{COLUMN_BREAK, LINE_BREAK, PAGE_BREAK}; + match c { + LINE_BREAK => designcraft_doc::FORCED_LINE_BREAK, + PAGE_BREAK => designcraft_doc::PAGE_BREAK, + COLUMN_BREAK => designcraft_doc::COLUMN_BREAK, + c => c, + } +} + +fn append_paragraph( + doc: &craft_wordmodel::Document, + p: &Paragraph, + st: &mut Story, + names: &HashMap, + warned_objects: &mut bool, + notes_done: &mut HashSet, + note_bytes: &mut usize, + warnings: &mut Vec, +) { + let para_at = st.len(); + let style = p.props.style.as_ref().and_then(|id| names.get(id)).cloned().unwrap_or_else(|| BASIC_PARAGRAPH.into()); + st.format_paras(para_at..para_at, |fmt| *fmt = ParaFormat { style: style.clone(), para: para_attrs(&p.props, doc), ..Default::default() }); + + let mut object_i = 0usize; + let mut last_format: Option = None; + let mut chunk = String::new(); + let flush = |st: &mut Story, fmt: &mut Option, text: &mut String| { + if !text.is_empty() { + st.insert_with(st.len(), text, fmt.clone().unwrap_or_default()); + text.clear(); + } + }; + for (at, c) in p.text.char_indices() { + let run = p + .runs + .iter() + .scan(0usize, |offset, r| { + let range = *offset..offset.saturating_add(r.len); + *offset = range.end; + Some((range, r)) + }) + .find(|(range, _)| range.contains(&at)) + .map(|(_, r)| r); + let fmt = run.map(|r| char_format(&r.props, names)).unwrap_or_default(); + if last_format.as_ref().is_some_and(|prev| prev != &fmt) { + flush(st, &mut last_format, &mut chunk); + } + last_format = Some(fmt); + if c == craft_wordmodel::para::OBJ { + flush(st, &mut last_format, &mut chunk); + if let Some(object) = p.objects.get(object_i) { + match object { + InlineObject::NoteRef { kind: NoteKind::Footnote, id, .. } => { + let mut text = doc.parts.get(id).map(|part| plain_blocks(&part.blocks)).unwrap_or_default(); + if notes_done.contains(id) { + text.clear(); + add_warning( + warnings, + format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference."), + ); + } else if note_bytes.saturating_add(text.len()) > MAX_NOTE_BYTES { + text.clear(); + add_warning( + warnings, + format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20)), + ); + } else { + notes_done.insert(*id); + *note_bytes = note_bytes.saturating_add(text.len()); + } + let _ = st.insert_note(st.len(), &text, ParaFormat::default()); + } + InlineObject::NoteRef { .. } => { + *warned_objects = true; + let _ = st.insert_note(st.len(), "", ParaFormat::default()); + } + other => { + let text = other.plain_text(); + if text.is_empty() { + *warned_objects = true; + } else { + st.insert_with(st.len(), text, last_format.clone().unwrap_or_default()); + } + } + } + } else { + *warned_objects = true; + } + object_i = object_i.saturating_add(1); + } else { + chunk.push(word_text(c)); + } + } + flush(st, &mut last_format, &mut chunk); +} + +fn append_blocks( + doc: &craft_wordmodel::Document, + blocks: &[std::sync::Arc], + st: &mut Story, + names: &HashMap, + warned_objects: &mut bool, + notes_done: &mut HashSet, + note_bytes: &mut usize, + warnings: &mut Vec, +) { + let mut first = st.is_empty(); + for block in blocks { + match block.as_ref() { + Block::Para(p) => { + if !first && !st.text.ends_with('\n') { + st.insert(st.len(), "\n"); + } + first = false; + append_paragraph(doc, p, st, names, warned_objects, notes_done, note_bytes, warnings); + } + Block::Table(table) => { + let rows: Vec> = table.rows.iter().map(|r| r.cells.iter().map(|c| plain_blocks(&c.blocks)).collect()).collect(); + if !rows.is_empty() { + let id = st.tables.keys().next_back().copied().unwrap_or(0).saturating_add(1); + let t = Table::from_strings(id, &rows, 300.0, &ParaFormat::default(), &CharFormat::default()); + st.insert_table(st.len(), t); + first = st.text.ends_with('\n'); + } + } + } + } +} + pub fn import(bytes: &[u8]) -> Result { - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; - let doc = part(&mut zip, "word/document.xml")?.ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; - let doc = parse(&doc)?; - // Styles. - let mut ctx = Ctx { - para_names: HashMap::new(), - char_names: HashMap::new(), - footnotes: HashMap::new(), - notes_done: std::collections::HashSet::new(), - note_bytes: 0, - warnings: vec![], - warned: std::collections::HashSet::new(), - next_table: 0, - numbering: Numbering::default(), - style_lists: HashMap::new(), - model_counter: ListCounter::default(), - default_para: BASIC_PARAGRAPH.into(), + let doc = craft_docx::read(bytes).map_err(|e| ImportError::Corrupt(e.to_string()))?; + let (mut para_styles, mut char_styles, names) = style_records(&doc.styles, &doc); + let mut story = Story::new(StoryId(0)); + let mut warned_objects = false; + let mut notes_done = HashSet::new(); + let mut note_bytes = 0usize; + let mut warnings = Vec::new(); + append_blocks(&doc, &doc.body, &mut story, &names, &mut warned_objects, &mut notes_done, &mut note_bytes, &mut warnings); + story.end_paragraphs_at_breaks(); + if warned_objects { + add_warning(&mut warnings, "Images, drawings and endnotes in the Word file were not imported (place them separately).".into()); + } + let used_p: HashSet = story.paras.iter().map(|p| p.style.clone()).collect(); + let used_c: HashSet = story.runs().map(|(_, f)| f.style.clone()).collect(); + let keep = |list: &mut Vec, used: &HashSet| { + let mut want = used.clone(); + let mut todo: Vec = used.iter().cloned().collect(); + while let Some(name) = todo.pop() { + for base in list.iter().filter(|s| s.name == name).filter_map(|s| s.based_on.as_ref()) { + if want.insert(base.clone()) { + todo.push(base.clone()); + } + } + } + list.retain(|s| want.contains(&s.name)); }; - // Lists that can't be read leave plain paragraphs, not a failed import. - match part_capped(&mut zip, "word/numbering.xml", NUMBERING_LIMIT).and_then(|s| s.map(|s| parse(&s)).transpose()) { - Ok(Some(n)) => ctx.numbering = Numbering::parse(&n), - Ok(None) => {} - Err(_) => ctx.warnings.push("The Word file's list definitions could not be read: lists were imported as plain paragraphs.".into()), + keep(&mut para_styles, &used_p); + keep(&mut char_styles, &used_c); + Ok(Imported { story, para_styles, char_styles, warnings }) +} + +fn add_warning(warnings: &mut Vec, message: String) { + if warnings.contains(&message) || warnings.len() > MAX_WARNINGS { + return; } - let mut para_styles = Vec::new(); - let mut char_styles = Vec::new(); - if let Some(styles) = part(&mut zip, "word/styles.xml")?.map(|s| parse(&s)).transpose()? { - let ids: HashMap = styles - .els() - .filter(|s| s.name == "style") - .filter_map(|s| Some((s.attr("styleId")?.to_string(), crate::capped_name(s.val("name")?)))) - .collect(); - ctx.style_lists = style_lists(&styles); - // Word's document defaults sit beneath every paragraph style that isn't based on another. - let defaults = styles.child("docDefaults"); - let default_para = para_attrs(defaults.and_then(|d| d.child("pPrDefault")).and_then(|d| d.child("pPr"))); - let default_chars = run_attrs(defaults.and_then(|d| d.child("rPrDefault")).and_then(|d| d.child("rPr"))); - let is_para = |s: &&El| s.name == "style" && s.attr("type").unwrap_or("paragraph") == "paragraph"; - let default_id = styles - .els() - .filter(is_para) - .find(|s| matches!(s.attr("default"), Some("1" | "true" | "on"))) - .or_else(|| styles.els().filter(is_para).find(|s| s.attr("styleId") == Some("Normal"))) - .and_then(|s| s.attr("styleId")); - if let Some(name) = default_id.and_then(|id| ids.get(id)) { - ctx.default_para = name.clone(); - } - for s in styles.els().filter(|s| s.name == "style") { - let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue }; - let kind = s.attr("type").unwrap_or("paragraph"); - // Word's default paragraph font is "no character style": our [None]. - let name = match (kind, name.as_str()) { - ("character", "Default Paragraph Font") => NO_CHAR_STYLE.to_string(), - _ => crate::capped_name(name.clone()), - }; - let based_on = s.val("basedOn").and_then(|b| ids.get(&b).cloned()); - let mut imp = ImportedStyle { name: name.clone(), based_on, para: para_attrs(s.child("pPr")), chars: run_attrs(s.child("rPr")) }; - match kind { - "paragraph" => { - ctx.para_names.insert(id.to_string(), name.clone()); - if imp.based_on.is_none() { - let (mut para, mut chars) = (default_para.clone(), default_chars.clone()); - para.merge(&imp.para); - chars.merge(&imp.chars); - (imp.para, imp.chars) = (para, chars); - } - para_styles.push(imp); - } - "character" => { - ctx.char_names.insert(id.to_string(), name.clone()); - if name != NO_CHAR_STYLE { - char_styles.push(imp); - } - } - _ => {} - } - } + if warnings.len() == MAX_WARNINGS { + warnings.push(format!("The Word file has more than {MAX_WARNINGS} kinds of problem: the rest are not listed.")); + } else { + warnings.push(message); } - // Footnotes (ids -1 / 0 are the separators). - if let Some(f) = part(&mut zip, "word/footnotes.xml")?.map(|s| parse(&s)).transpose()? { - for n in f.els().filter(|n| n.name == "footnote") { - if let Some(id) = n.attr("id").filter(|id| id.parse::().is_ok_and(|v| v > 0)) { - ctx.footnotes.insert(id.to_string(), n.clone()); - } - } - } - let body = doc.child("body").ok_or_else(|| ImportError::Corrupt("no document body".into()))?; - let mut st = Story::new(StoryId(0)); - ctx.blocks(body, &mut st, true); - // A page or column break ends its paragraph. - st.end_paragraphs_at_breaks(); - // Only styles that are used, plus what they are based on. - let stories = || std::iter::once(&st).chain(st.notes.iter().map(|n| &n.text)); - let used_p: std::collections::HashSet = stories().flat_map(|s| s.paras.iter().map(|p| p.style.clone())).collect(); - let used_c: std::collections::HashSet = stories().flat_map(|s| s.runs().map(|(_, f)| f.style.clone())).collect(); - let keep = |list: Vec, used: &std::collections::HashSet| { - let mut want: std::collections::HashSet = used.clone(); - { - // What each name is based on (every style of that name), then a worklist: each name - // is looked at once, however long the chain. - let mut bases: HashMap<&str, Vec<&str>> = HashMap::new(); - for s in &list { - if let Some(b) = &s.based_on { - bases.entry(s.name.as_str()).or_default().push(b.as_str()); - } - } - let mut todo: Vec = want.iter().cloned().collect(); - while let Some(name) = todo.pop() { - for b in bases.get(name.as_str()).into_iter().flatten() { - if want.insert((*b).to_string()) { - todo.push((*b).to_string()); - } - } - } - } - list.into_iter().filter(|s| want.contains(&s.name)).collect::>() - }; - Ok(Imported { story: st, para_styles: keep(para_styles, &used_p), char_styles: keep(char_styles, &used_c), warnings: ctx.warnings }) } #[cfg(test)] diff --git a/crates/textimport/src/docx/tests.rs b/crates/textimport/src/docx/tests.rs index fdaa79a11..ad5f08da1 100644 --- a/crates/textimport/src/docx/tests.rs +++ b/crates/textimport/src/docx/tests.rs @@ -1,23 +1,6 @@ use super::*; use std::io::Write; -#[test] -fn cdata_text() { - let el = parse("").unwrap(); - assert_eq!(el.child("t").unwrap().text(), "x < y"); -} - -/// Issue #712: nesting past the budget is an error from the parser, not a stack overflow later. -#[test] -fn nesting_past_the_budget_is_refused() { - let nested = |n: usize| format!("{}{}", "".repeat(n), "".repeat(n)); - let ok = parse(&nested(MAX_DEPTH)).expect("the budget itself parses"); - assert_eq!(ok.name, "p"); - let err = parse(&nested(MAX_DEPTH + 1)).expect_err("one level more is refused"); - assert!(err.to_string().contains("nested deeper"), "{err}"); - assert!(parse(&nested(100_000)).is_err()); -} - #[test] fn table_adds_no_empty_paragraph() { let document = format!( @@ -55,17 +38,6 @@ fn docx_parts(parts: &[(&str, &str)]) -> Vec { const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; -#[test] -fn oversized_part_is_an_error_not_a_huge_read() { - // A highly compressible part: 64 KiB of spaces inflates past a 4 KiB cap. - let big = docx(&" ".repeat(64 * 1024), "", ""); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(big.as_slice())).unwrap(); - assert!(matches!(part_capped(&mut zip, "word/document.xml", 4096), Err(ImportError::Corrupt(_)))); - // At or under the cap reads fine; a missing part is `None`. - assert_eq!(part_capped(&mut zip, "word/document.xml", 64 * 1024).unwrap().map(|s| s.len()), Some(64 * 1024)); - assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None); -} - /// A DOCX can hold any number where a measure belongs: `1e300` twips became a 5e298 pt /// indent, and `w:sz` of `1e300` a 5e299 pt type size. Measures are clamped to the widest /// page and a size the engine cannot mean is dropped (#1593). @@ -139,7 +111,7 @@ fn a_footnote_table_counts_toward_the_note_budget() { assert_eq!(i.story.notes.len(), 18, "all 18 references are kept"); // What was retained is the budget plus at most the one note that was already being built // when it ran out -- not all 10.8 MB. - let held: usize = i.story.notes.iter().map(|n| note_bytes(&n.text, MAX_NOTE_DEPTH)).sum(); + let held: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); assert!(held <= (8 << 20) + big.len() + 1024, "the cell text is inside the budget: {held} bytes"); assert!(held > big.len(), "and the notes that fit were imported in full: {held} bytes"); assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is reported: {:?}", i.warnings); @@ -564,20 +536,6 @@ fn hostile_numbering_falls_back_to_plain_paragraphs() { assert_eq!(i.warnings.len(), 1); } -#[test] -fn an_element_with_many_attributes_parses_in_linear_time() { - // RUSTSEC-2026-0194: before quick-xml 0.41 the duplicate-attribute check compared each name with every - // earlier one on the element, so a crafted .docx/.xlsx with one huge tag hung the import (O(N²)). - let attrs: String = (0..100_000).map(|i| format!(" w:a{i}=\"{i}\"")).collect(); - let xml = format!(""); - let started = std::time::Instant::now(); - let doc = parse(&xml).unwrap(); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "parse took {:?}", started.elapsed()); - let p = doc.child("body").and_then(|b| b.child("p")).unwrap(); - assert_eq!(p.attr("a0"), Some("0")); - assert_eq!(p.attr("a99999"), Some("99999")); -} - #[test] fn table_cells_read_text_like_body_paragraphs() { // Regression (#721): cells took every text node, so deleted text, move sources and field @@ -638,11 +596,3 @@ fn off_valued_toggles_stay_off() { let on = st.format_after(st.text.find("bold").unwrap()).clone().over; assert_eq!(on.font_style.as_deref(), Some("Bold"), "`on` is still on"); } - -#[test] -fn signed_numeric_character_references_are_dropped() { - let el = parse("&#+65;&#x+42;&#-65;&#x-42;&#;&#x;").unwrap(); - assert_eq!(el.text(), ""); - let ok = parse("AB").unwrap(); - assert_eq!(ok.text(), "AB"); -} diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs deleted file mode 100644 index a00ba8ea7..000000000 --- a/crates/textimport/src/docx_numbering.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Word lists: `word/numbering.xml` and the `w:numPr` of paragraphs and paragraph styles. -//! -//! A `w:num` (numId) points at a `w:abstractNum`, whose `w:lvl` elements (levels 0–8) give -//! each level's format, label text, start number, indents and the text after the label. -//! -//! Numbering follows Word's main rules, simplified: -//! - Numbers count per level. A paragraph at level L restarts the levels below it (unless a -//! level's `w:lvlRestart` says otherwise). -//! - `num` instances of one abstractNum share their counters, so a list carries on across -//! them and across paragraphs that aren't in a list. A `num` with a `w:startOverride` counts -//! on its own, starting at the override. -//! - Levels map to the model's list levels (`ilvl` + 1). Parent levels in a label (`%1.%2.`) -//! become the model's level references (`^1.^2.`); where the model's level numbers would differ -//! from Word's (a legal-style label, a level the model hasn't numbered), the importer writes -//! them as text instead ([`ListPara::fixed_expression`]). - -use std::collections::HashMap; - -use designcraft_doc::{ListType, NumberStyle, ParaAttrs}; - -use crate::docx::El; - -/// Most `w:abstractNum` or `w:num` elements read; the rest are ignored. -pub(crate) const MAX_DEFS: usize = 10_000; -/// Word's list levels are 0–8. -const LEVELS: usize = 9; -/// Longest label text (`w:lvlText`) kept, in characters. -const MAX_LVL_TEXT: usize = 64; - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -enum Format { - #[default] - Bullet, - /// No number: the label is the level text alone. - Nothing, - Number(NumberStyle), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -enum Suffix { - #[default] - Tab, - Space, - Nothing, -} - -#[derive(Clone, Debug, Default)] -struct Level { - format: Format, - text: String, - start: u32, - suffix: Suffix, - left: Option, - /// First-line indent in points (negative for a hanging indent). - first_line: Option, - /// `w:lvlRestart`: 0 = never restart, n = restart after level n (1-based) or a higher one. - restart: Option, - /// `w:isLgl`: parent levels in the label are written as decimal numbers. - legal: bool, - /// The font of the label (`w:rPr/w:rFonts`), which decides what a Symbol/Wingdings bullet is. - font: Option, - picture: bool, -} - -#[derive(Debug, Default)] -struct Num { - abstract_id: String, - /// `w:lvlOverride` per level: a start override and/or a replacement level. - start_override: [Option; LEVELS], - level_override: [Option; LEVELS], -} - -impl Num { - fn has_start_override(&self) -> bool { - self.start_override.iter().any(Option::is_some) - } -} - -/// A list paragraph: the paragraph attributes that make it one, and its number in Word. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct ListPara { - pub(crate) attrs: ParaAttrs, - /// The paragraph's number at its level (also counted for bullets). - pub(crate) number: u32, - /// Parent levels the number expression refers to (`^k`, 1-based) and the text Word shows - /// for each. - pub(crate) parents: Vec<(u32, String)>, - /// The number expression with Word's parent level numbers written as text. - pub(crate) fixed_expression: Option, -} - -/// The lists of a Word document and the numbering state while its paragraphs are read in order. -#[derive(Debug, Default)] -pub(crate) struct Numbering { - abstracts: HashMap; LEVELS]>, - nums: HashMap, - /// Current number per level, per counter key (an abstractNum, or a `num` with overrides). - counters: HashMap; LEVELS]>, -} - -fn level_index(e: &El) -> Option { - e.attr("ilvl").and_then(|v| v.trim().parse::().ok()).filter(|l| *l < LEVELS) -} - -fn parse_u32(v: Option) -> Option { - let v = v?; - let v = v.trim(); - // Negative starts count from 0; numbers too large for a u32 are clamped. - match v.parse::() { - Ok(n) => Some(u32::try_from(n.max(0)).unwrap_or(u32::MAX)), - Err(_) => None, - } -} - -fn number_style(fmt: &str) -> Format { - match fmt { - "bullet" => Format::Bullet, - "none" => Format::Nothing, - "decimalZero" => Format::Number(NumberStyle::ArabicLeadingZero), - "upperRoman" => Format::Number(NumberStyle::UpperRoman), - "lowerRoman" => Format::Number(NumberStyle::LowerRoman), - "upperLetter" => Format::Number(NumberStyle::UpperLetters), - "lowerLetter" => Format::Number(NumberStyle::LowerLetters), - "chicago" => Format::Number(NumberStyle::Symbols), - // decimal and every format the model has no style for (ordinal, cardinalText, - // decimalEnclosedCircle, the East Asian counting systems, …). - _ => Format::Number(NumberStyle::Arabic), - } -} - -fn parse_level(e: &El) -> Level { - // Twips, clamped as elsewhere: a level's indent is a measure, not any number (#1593). - let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure); - let ind = e.child("pPr").and_then(|p| p.child("ind")); - Level { - format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)), - text: e.child("lvlText").and_then(|t| t.attr("val")).unwrap_or_default().chars().take(MAX_LVL_TEXT).collect(), - start: parse_u32(e.val("start")).unwrap_or(1), - suffix: match e.val("suff").as_deref() { - Some("space") => Suffix::Space, - Some("nothing") => Suffix::Nothing, - _ => Suffix::Tab, - }, - left: ind.and_then(|i| tw(i.attr("left").or(i.attr("start")))), - first_line: ind.and_then(|i| tw(i.attr("firstLine")).or(tw(i.attr("hanging")).map(|h| -h))), - restart: parse_u32(e.val("lvlRestart")), - legal: e.on("isLgl") == Some(true), - // Only the font's kind matters (Symbol, Wingdings), and every bullet paragraph looks at it: - // a font name longer than any real one is cut. - font: e - .child("rPr") - .and_then(|r| r.child("rFonts")) - .and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) - .map(|f| f.chars().take(MAX_FONT_NAME_CHARS).collect()), - picture: e.child("lvlPicBulletId").is_some(), - } -} - -/// Longest font name kept from a numbering level. -const MAX_FONT_NAME_CHARS: usize = 128; - -/// The bullet Word shows for `text` in `font`. Symbol and Wingdings bullets are stored as -/// private-use characters (U+F0xx) or as the font's own code points; the common ones become -/// their Unicode look-alikes, unknown ones a plain bullet. -fn bullet_char(text: &str, font: Option<&str>) -> String { - let font = font.unwrap_or_default().to_ascii_lowercase(); - let symbol = font.contains("symbol"); - let wingdings = font.contains("wingdings"); - text.chars() - .map(|c| { - let code = u32::from(c); - let low = if (0xF000..=0xF0FF).contains(&code) { code - 0xF000 } else { code }; - if wingdings { - return match low { - 0x6C => '●', - 0x6E => '■', - 0x71 => '❑', - 0x76 => '❖', - 0xA7 => '▪', - 0xD8 => '➢', - 0xFC => '✓', - _ => '•', - }; - } - if symbol { - return match low { - 0x2D => '–', - 0x6F => '◦', - _ => '•', - }; - } - match c { - // Word's second-level bullet is a Courier New "o". - 'o' => '◦', - '\u{E000}'..='\u{F8FF}' => '•', - c => c, - } - }) - .filter(|c| *c != '\n' && *c != '\r') - .collect() -} - -impl Numbering { - /// Read `numbering.xml`'s root (`w:numbering`). - pub(crate) fn parse(root: &El) -> Self { - let mut n = Numbering::default(); - for e in root.els() { - match e.name.as_str() { - "abstractNum" if n.abstracts.len() < MAX_DEFS => { - let Some(id) = e.attr("abstractNumId") else { continue }; - let mut levels: [Option; LEVELS] = Default::default(); - for l in e.els().filter(|l| l.name == "lvl") { - if let Some(slot) = level_index(l).and_then(|i| levels.get_mut(i)) { - *slot = Some(parse_level(l)); - } - } - n.abstracts.insert(id.to_string(), levels); - } - "num" if n.nums.len() < MAX_DEFS => { - let (Some(id), Some(abstract_id)) = (e.attr("numId"), e.val("abstractNumId")) else { continue }; - let mut num = Num { abstract_id, ..Default::default() }; - for o in e.els().filter(|o| o.name == "lvlOverride") { - let Some(i) = level_index(o) else { continue }; - if let Some(slot) = num.start_override.get_mut(i) { - *slot = parse_u32(o.val("startOverride")); - } - if let (Some(slot), Some(l)) = (num.level_override.get_mut(i), o.child("lvl")) { - *slot = Some(parse_level(l)); - } - } - n.nums.insert(id.to_string(), num); - } - _ => {} - } - } - n - } - - /// The list formatting of the next paragraph with `w:numId` `num_id` at level `ilvl`, counting - /// it. `None` (a plain paragraph) for numId 0, an unknown list or level, or a level past 8. - pub(crate) fn next(&mut self, num_id: &str, ilvl: u32) -> Option { - let lv = usize::try_from(ilvl).ok().filter(|l| *l < LEVELS)?; - let num = self.nums.get(num_id)?; - let abs = self.abstracts.get(&num.abstract_id)?; - let level = |k: usize| num.level_override.get(k).and_then(Option::as_ref).or_else(|| abs.get(k).and_then(Option::as_ref)); - let def = level(lv)?; - let own = num.has_start_override(); - let key = if own { format!("n:{num_id}") } else { format!("a:{}", num.abstract_id) }; - let start = |k: usize| { - let o = if own { num.start_override.get(k).copied().flatten() } else { None }; - o.or_else(|| level(k).map(|l| l.start)).unwrap_or(1) - }; - let counters = self.counters.entry(key).or_default(); - let n = counters.get(lv).copied().flatten().map_or_else(|| start(lv), |v| v.saturating_add(1)); - if let Some(c) = counters.get_mut(lv) { - *c = Some(n); - } - for (m, c) in counters.iter_mut().enumerate().skip(lv + 1) { - let restart = match level(m).and_then(|l| l.restart) { - None => true, - Some(0) => false, - Some(r) => ilvl < r, - }; - if restart { - *c = None; - } - } - // Parent levels' current numbers, as text, for the label. - let parent = |k: usize| -> String { - let v = counters.get(k).copied().flatten().unwrap_or_else(|| start(k)); - match level(k).map(|l| l.format) { - Some(Format::Number(s)) if !def.legal => s.format(v), - Some(Format::Number(_)) => v.to_string(), - _ => String::new(), - } - }; - // `refs`: a parent number level becomes the model's `^k`; otherwise Word's text. - let mut parents = Vec::new(); - let mut label = |own_level: &str, escape: bool, refs: bool| -> String { - let mut out = String::new(); - let mut it = def.text.chars().peekable(); - while let Some(c) = it.next() { - if c == '%' - && let Some(k) = it.peek().and_then(|d| d.to_digit(10)).filter(|d| (1..=9).contains(d)) - { - it.next(); - let k = k as usize - 1; - let number = matches!(level(k).map(|l| l.format), Some(Format::Number(_))); - if k == lv { - out.push_str(own_level); - } else if refs && number && !def.legal { - out.push('^'); - out.push_str(&(k + 1).to_string()); - parents.push((k as u32 + 1, parent(k))); - } else { - let p = parent(k); - out.push_str(&if escape { p.replace('^', "^^") } else { p }); - } - } else if escape && c == '^' { - out.push_str("^^"); - } else if c != '\n' && c != '\r' { - out.push(c); - } - } - out - }; - // The model's defaults: `^t` after a number, a literal tab after a bullet. - let (number_sep, bullet_sep) = match def.suffix { - Suffix::Tab => ("^t", "\t"), - Suffix::Space => (" ", " "), - Suffix::Nothing => ("", ""), - }; - let mut a = ParaAttrs::default(); - let mut fixed_expression = None; - match def.format { - Format::Number(style) => { - a.list_type = Some(ListType::Numbers); - a.number_style = Some(style); - a.number_expression = Some(format!("{}{number_sep}", label("^#", true, true))); - fixed_expression = Some(format!("{}{number_sep}", label("^#", true, false))); - } - Format::Bullet => { - a.list_type = Some(ListType::Bullets); - a.bullet_char = Some(if def.picture { "•".into() } else { bullet_char(&def.text, def.font.as_deref()) }); - a.list_separator = Some(bullet_sep.into()); - } - Format::Nothing => { - a.list_type = Some(ListType::Bullets); - a.bullet_char = Some(label("", false, false)); - a.list_separator = Some(bullet_sep.into()); - } - } - a.left_indent = def.left; - a.first_line_indent = def.first_line; - a.list_level = Some(ilvl + 1); - match def.restart { - None => {} - Some(0) => a.restart_numbers = Some(false), - Some(r) => a.restart_after_level = Some(r), - } - Some(ListPara { attrs: a, number: n, parents, fixed_expression }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn symbol_and_wingdings_bullets_become_unicode() { - assert_eq!(bullet_char("\u{F0B7}", Some("Symbol")), "•"); - assert_eq!(bullet_char("\u{F0A7}", Some("Wingdings")), "▪"); - assert_eq!(bullet_char("o", Some("Courier New")), "◦"); - assert_eq!(bullet_char("–", None), "–"); - assert_eq!(bullet_char("\u{F076}", None), "•"); - } -} diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 26b1b1fc3..0838861e2 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -9,7 +9,6 @@ #![forbid(unsafe_code)] mod docx; -mod docx_numbering; pub mod export; mod rtf; pub mod tagged; @@ -29,44 +28,6 @@ pub fn xlsx_records(bytes: &[u8], sheet: Option<&str>) -> Result use designcraft_doc::{CharAttrs, ParaAttrs, Story}; -/// Characters kept of a style name read from a file: each paragraph that uses a style carries a -/// copy of its name, so a long one would be multiplied by the paragraph count. -pub(crate) const MAX_NAME_CHARS: usize = 256; - -pub(crate) fn capped_name(mut name: String) -> String { - if let Some((at, _)) = name.char_indices().nth(MAX_NAME_CHARS) { - name.truncate(at); - } - name -} - -/// Joins UTF-16 code units that arrive one at a time (RTF `\uN`, Tagged Text `<0xNNNN>`) into -/// characters. A character past U+FFFF comes as two units, a surrogate pair: the first half -/// waits for the second. -#[derive(Default)] -pub(crate) struct Utf16Units { - high: Option, -} - -impl Utf16Units { - /// The character that `unit` is or completes. Half a pair without its partner is no character. - pub(crate) fn push(&mut self, unit: u32) -> Option { - match (self.high.take(), unit) { - (Some(high), 0xDC00..=0xDFFF) => char::from_u32(0x10000 + ((high - 0xD800) << 10) + (unit - 0xDC00)), - (_, 0xD800..=0xDBFF) => { - self.high = Some(unit); - None - } - _ => char::from_u32(unit), - } - } - - /// Other text came in between: a waiting first half has no partner. - pub(crate) fn reset(&mut self) { - self.high = None; - } -} - /// A paragraph or character style found in the source. #[derive(Clone, Debug, Default, PartialEq)] pub struct ImportedStyle { diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index c3668614d..d8a56aff0 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -1,456 +1,72 @@ -//! Tagged Text: plain text with `` markup for paragraph and character styles and local -//! formatting — the text interchange format of layout applications. Written as ASCII (other -//! characters as `<0xXXXX>`); read from ASCII, UTF-8 or UTF-16 files. Paragraph styles, character -//! styles, font family, font style and size are kept; other tags are skipped. +//! DesignCraft model adapter for the shared Tagged Text codec. -use std::fmt::Write as _; - -use designcraft_doc::{CharFormat, Document, Story, StoryId, story::NO_CHAR_STYLE}; +use designcraft_doc::{CharFormat, Document, Story, StoryId, attrs::CharAttrs, story::NO_CHAR_STYLE}; use crate::{ImportError, Imported, ImportedStyle}; -fn escape(out: &mut String, c: char) { - match c { - '<' => out.push_str("\\<"), - '>' => out.push_str("\\>"), - '\\' => out.push_str("\\\\"), - c if c.is_ascii() && !c.is_ascii_control() => out.push(c), - '\t' => out.push('\t'), - c => { - let mut buf = [0u16; 2]; - for u in c.encode_utf16(&mut buf) { - let _ = write!(out, "<0x{u:04X}>"); - } - } - } -} - -/// A tag value with '<', '>' and '\\' escaped by a backslash, so it keeps the tag intact. -fn escape_value(v: &str) -> String { - let mut out = String::with_capacity(v.len()); - for c in v.chars() { - if matches!(c, '<' | '>' | '\\') { - out.push('\\'); - } - out.push(c); - } - out -} - -/// A tag value (cut to [`crate::MAX_NAME_CHARS`]: style names are copied into every paragraph) with the escapes `escape_value` writes (`\\`, `\<`, `\>`) decoded. Any other -/// backslash, including a trailing one, stays literal, as older files and InDesign's `\:` expect. -fn unescape_value(v: &str) -> String { - let mut out = String::with_capacity(v.len()); - let mut it = v.chars().peekable(); - while let Some(c) = it.next() { - if c == '\\' - && let Some(&n) = it.peek() - && matches!(n, '\\' | '<' | '>') - { - out.push(n); - it.next(); - } else { - out.push(c); - } - } - crate::capped_name(out) -} - -/// The first format version whose `cFont` and `cTypeface` values are escaped like style names. -/// Older files hold them raw, so a backslash in them is literal. -const ESCAPED_FONTS_VERSION: u32 = 8; - -/// A `cFont` or `cTypeface` value: decoded in files that escape it, literal in older ones. -fn font_value(v: &str, escaped: bool) -> String { - if escaped { unescape_value(v) } else { crate::capped_name(v.to_string()) } -} - -/// `story` as Tagged Text. pub fn tagged_text(doc: &Document, story: &Story) -> String { - let mut out = String::from("\r\n\r\n"); - let ranges = story.para_ranges(); - for (i, r) in ranges.iter().enumerate() { - if i > 0 { - out.push_str("\r\n"); - } - let pf = story.paras.get(i).cloned().unwrap_or_default(); - let _ = write!(out, "", escape_value(&pf.style)); - for (rr, f) in story.runs() { - let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); - if a >= b { + let mut paragraphs = Vec::new(); + for (i, range) in story.para_ranges().iter().enumerate() { + let mut paragraph = + craft_idml::tagged::Paragraph { style: story.paras.get(i).map_or_else(String::new, |p| p.style.clone()), runs: Vec::new() }; + for (run_range, format) in story.runs() { + let start = run_range.start.max(range.start); + let end = run_range.end.min(range.end); + if start >= end + || doc.conditions_hide(format.over.conditions.as_deref().unwrap_or(&[])) + || format.over.change == Some(designcraft_doc::ChangeMark::Deleted) + { continue; } - if doc.conditions_hide(f.over.conditions.as_deref().unwrap_or(&[])) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { - continue; - } - let mut open = Vec::new(); - if f.style != NO_CHAR_STYLE { - let _ = write!(out, "", escape_value(&f.style)); - open.push("CharStyle"); - } - if let Some(v) = &f.over.font_family { - let _ = write!(out, "", escape_value(v)); - open.push("cFont"); - } - if let Some(v) = &f.over.font_style { - let _ = write!(out, "", escape_value(v)); - open.push("cTypeface"); - } - if let Some(v) = f.over.size { - let _ = write!(out, ""); - open.push("cSize"); - } - for c in story.text[a..b].chars() { - escape(&mut out, c); - } - for t in open.into_iter().rev() { - let _ = write!(out, "<{t}:>"); - } + paragraph.runs.push(craft_idml::tagged::Run { + text: story.text[start..end].to_string(), + format: craft_idml::tagged::CharFormat { + style: (format.style != NO_CHAR_STYLE).then(|| format.style.clone()), + font: format.over.font_family.clone(), + typeface: format.over.font_style.clone(), + size: format.over.size, + }, + }); } + paragraphs.push(paragraph); } - out + craft_idml::tagged::write(¶graphs) } -/// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark). pub(crate) fn decode(bytes: &[u8]) -> String { - if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { - let le = bytes[0] == 0xFF; - let units: Vec = bytes[2..] - .as_chunks::<2>() - .0 - .iter() - .map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }) - .collect(); - return String::from_utf16_lossy(&units); - } - String::from_utf8_lossy(bytes).trim_start_matches('\u{FEFF}').to_string() + craft_idml::tagged::decode(bytes) } -/// Is this Tagged Text (by its header)? pub fn is_tagged(bytes: &[u8]) -> bool { - let head = decode(&bytes[..bytes.len().min(64)]); - let h = head.trim_start(); - h.starts_with(" Result { - let text = decode(bytes); - let chars: Vec = text.chars().collect(); - // Paragraphs: (style, runs of (text, format)). - let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())]; - let mut started = false; - let mut fmt = CharFormat::default(); - let mut para_styles: Vec = Vec::new(); - let mut char_styles: Vec = Vec::new(); - // The names in each list, for membership (the lists keep the order the file names them). - let (mut para_seen, mut char_seen) = (std::collections::HashSet::new(), std::collections::HashSet::new()); - let mut warnings = Vec::new(); - let push = |paras: &mut Vec<(String, Vec<(String, CharFormat)>)>, c: char, fmt: &CharFormat| { - let Some((_, runs)) = paras.last_mut() else { return }; - match runs.last_mut() { - Some((t, f)) if f == fmt => t.push(c), - _ => runs.push((c.to_string(), fmt.clone())), + let parsed = craft_idml::tagged::parse(bytes); + let mut story = Story::new(StoryId(0)); + for (index, paragraph) in parsed.paragraphs.iter().enumerate() { + if index > 0 { + let at = story.len(); + story.insert(at, "\n"); } - }; - let mut escaped_fonts = false; - let mut units = crate::Utf16Units::default(); - let mut i = 0; - let mut skipped = 0; - while i < chars.len() { - let c = chars[i]; - match c { - '\\' if i + 1 < chars.len() => { - units.reset(); - push(&mut paras, chars[i + 1], &fmt); - i += 2; - } - '<' => { - // A tag: up to the matching '>' (nested in definitions). - let mut depth = 0; - let mut j = i; - while j < chars.len() { - match chars[j] { - '\\' => j += 1, - '<' => depth += 1, - '>' => { - depth -= 1; - if depth == 0 { - break; - } - } - _ => {} - } - j += 1; - } - let tag: String = chars[i + 1..j.min(chars.len())].iter().collect(); - i = j + 1; - let (name, value) = tag.split_once(':').map_or((tag.as_str(), ""), |(a, b)| (a, b)); - match name { - n if n.starts_with("0x") => { - // `from_str_radix` accepts a leading '+', so check the digits first. - if let Some(digits) = n.get(2..) - && digits.bytes().all(|b| b.is_ascii_hexdigit()) - && let Ok(u) = u32::from_str_radix(digits, 16) - && let Some(ch) = units.push(u) - { - push(&mut paras, ch, &fmt); - } - } - "ParaStyle" => { - started = true; - let v = unescape_value(value); - if !v.is_empty() && para_seen.insert(v.clone()) { - para_styles.push(v.clone()); - } - if let Some(p) = paras.last_mut() { - p.0 = v; - } - } - "CharStyle" => { - let v = unescape_value(value); - fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() }; - if !v.is_empty() && char_seen.insert(v.clone()) { - char_styles.push(v); - } - } - "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| font_value(value, escaped_fonts)), - "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| font_value(value, escaped_fonts)), - // A size the engine cannot mean (0, negative, 1e300) is dropped, so the text - // keeps its style's size (#1593). - "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size), - "DefineParaStyle" | "DefineCharStyle" => { - let nm = crate::capped_name(value.split('=').next().unwrap_or("").to_string()); - let (list, seen) = - if name == "DefineParaStyle" { (&mut para_styles, &mut para_seen) } else { (&mut char_styles, &mut char_seen) }; - if !nm.is_empty() && seen.insert(nm.clone()) { - list.push(nm); - } - } - "Version" => escaped_fonts = value.trim().parse::().is_ok_and(|v| v >= ESCAPED_FONTS_VERSION), - n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") => {} - _ => skipped += 1, - } - } - '\r' | '\n' => { - // A paragraph break (CRLF counts once). - if c == '\r' && chars.get(i + 1) == Some(&'\n') { - i += 1; - } - let style = paras.last().map(|p| p.0.clone()).unwrap_or_default(); - // Header lines before any text don't start paragraphs. - if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) { - i += 1; - continue; - } - paras.push((style, Vec::new())); - i += 1; - } - c => { - units.reset(); - push(&mut paras, c, &fmt); - i += 1; - } + for run in ¶graph.runs { + let format = CharFormat { + style: run.format.style.as_deref().filter(|s| !s.is_empty()).unwrap_or(NO_CHAR_STYLE).to_string(), + over: CharAttrs { + font_family: run.format.font.clone(), + font_style: run.format.typeface.clone(), + size: run.format.size.and_then(designcraft_doc::attrs::imported_type_size), + ..Default::default() + }, + }; + let at = story.len(); + story.insert_with(at, &run.text, format); + } + if let Some(p) = story.paras.last_mut() { + p.style = paragraph.style.clone(); } } - if skipped > 0 { - warnings.push(format!("{skipped} formatting tag(s) not kept")); - } - // Build the story. - let mut st = Story::new(StoryId(0)); - for (k, (style, runs)) in paras.iter().enumerate() { - if k > 0 { - let n = st.len(); - st.insert(n, "\n"); - } - for (t, f) in runs { - let n = st.len(); - st.insert_with(n, t, f.clone()); - } - if !style.is_empty() - && let Some(p) = st.paras.last_mut() - { - p.style = style.clone(); - } - } - let mk = |n: &String| ImportedStyle { name: n.clone(), ..Default::default() }; - Ok(Imported { story: st, para_styles: para_styles.iter().map(mk).collect(), char_styles: char_styles.iter().map(mk).collect(), warnings }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Characters past U+FFFF are written as two tags, a UTF-16 surrogate pair. Each half was - /// converted alone and dropped. - #[test] - fn surrogate_pairs_become_one_character() { - let i = import("\r\n\r\nA<0xD83D><0xDE00>B <0xD842><0xDFB7><0x7530>".as_bytes()).unwrap(); - assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}"); - // What the exporter writes comes back. - let doc = Document::new(&designcraft_doc::build::NewDocument::default()); - let back = import(tagged_text(&doc, &i.story).as_bytes()).unwrap(); - assert_eq!(back.story.text, i.story.text); - // Half a pair is not a character: it is still left out. - assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); - } - - /// A style name is read back exactly as the exporter wrote it, spaces included. - #[test] - fn style_names_keep_leading_and_trailing_spaces() { - let i = import("\r\n\r\nHello".as_bytes()).unwrap(); - assert_eq!(i.story.paras[0].style, "Body "); - assert_eq!(i.story.char_format_at(2).style, " Em "); - assert_eq!(i.para_styles[0].name, "Body "); - assert_eq!(i.char_styles[0].name, " Em "); - } - - /// A style defined with spaces in its name keeps them, so a paragraph that uses the name matches. - #[test] - fn defined_style_names_keep_leading_and_trailing_spaces() { - let src = - "\r\n>>Hello"; - let i = import(src.as_bytes()).unwrap(); - assert_eq!(i.para_styles.len(), 1); - assert_eq!(i.para_styles[0].name, "Body "); - assert_eq!(i.char_styles.len(), 1); - assert_eq!(i.char_styles[0].name, " Em "); - } - - /// `from_str_radix` accepts a leading `+`, so `<0x+41>` inserted an 'A'. A signed or empty - /// code is not a code point tag and is skipped. - #[test] - fn a_signed_hex_code_tag_is_skipped() { - for t in ["<0x+41>", "<0x-41>", "<0x>", "<0xZZ>"] { - let src = format!("\r\nA{t}B"); - assert_eq!(import(src.as_bytes()).unwrap().story.text, "AB", "{t}"); - } - } - - /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a - /// negative, and tagged text took all three and set them on the text (#1593). They are now - /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. - #[test] - fn a_char_size_the_engine_cannot_mean_is_dropped() { - for v in ["1e300", "-5", "0", "1297", "0.09"] { - let src = format!("\r\nbig"); - let i = import(src.as_bytes()).unwrap(); - let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; - assert_eq!(size, None, "cSize {v} is not a size the engine can mean"); - } - // A size inside the range still arrives. - let src = "\r\nbig"; - let i = import(src.as_bytes()).unwrap(); - let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; - assert_eq!(size, Some(18.0)); - } - - /// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story. - #[test] - fn non_finite_char_size_is_ignored() { - for v in ["NaN", "inf", "infinity", "-inf"] { - let src = format!("\r\nbig"); - let i = import(src.as_bytes()).unwrap(); - let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; - assert!(size.is_none_or(f64::is_finite), "{v}: size {size:?}"); - } - } - - #[test] - fn tagged_text_keeps_a_leading_empty_paragraph() { - let doc = Document::new(&designcraft_doc::build::NewDocument::default()); - let mut story = Story::new(StoryId(0)); - story.insert(0, "\nText"); - story.paras[0].style = "Head".into(); - story.paras[1].style = "Body".into(); - let back = import(tagged_text(&doc, &story).as_bytes()).unwrap(); - assert_eq!(back.story.text, story.text); - assert_eq!(back.story.paras, story.paras); - } - - /// Style names with ':', '<', '>' or '\' were written with those characters dropped, so - /// re-import gave the paragraphs a different style. - #[test] - fn style_names_with_tag_characters_round_trip() { - let doc = Document::new(&designcraft_doc::build::NewDocument::default()); - for name in ["Body: Indent", "Ac", "x\\y", "end\\"] { - let mut story = Story::new(StoryId(0)); - story.insert_with(0, "Text", CharFormat { style: name.into(), ..Default::default() }); - story.paras[0].style = name.into(); - let out = tagged_text(&doc, &story); - let back = import(out.as_bytes()).unwrap(); - assert_eq!(back.story.text, "Text", "{out}"); - assert_eq!(back.story.paras[0].style, name, "{out}"); - assert_eq!(back.story.char_format_at(1).style, name, "{out}"); - assert_eq!(back.para_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]); - assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]); - } - } - - #[test] - fn font_values_with_tag_characters_round_trip() { - let doc = Document::new(&designcraft_doc::build::NewDocument::default()); - for name in ["Weird>Font", "Ac", "x\\y", "end\\"] { - let mut story = Story::new(StoryId(0)); - let mut f = CharFormat::default(); - f.over.font_family = Some(name.into()); - f.over.font_style = Some(name.into()); - story.insert_with(0, "hello", f); - let out = tagged_text(&doc, &story); - let back = import(out.as_bytes()).unwrap(); - assert_eq!(back.story.text, "hello", "{out}"); - assert_eq!(back.story.char_format_at(1).over.font_family.as_deref(), Some(name), "{out}"); - assert_eq!(back.story.char_format_at(1).over.font_style.as_deref(), Some(name), "{out}"); - } - } - - /// Version 7 files hold font values raw, so their backslashes stay literal. - #[test] - fn old_format_font_values_stay_literal() { - let i = import("\r\n\r\nxy".as_bytes()).unwrap(); - assert_eq!(i.story.char_format_at(1).over.font_family.as_deref(), Some("a\\b")); - assert_eq!(i.story.char_format_at(2).over.font_style.as_deref(), Some("c\\d")); - } - - /// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal. - #[test] - fn unknown_backslashes_in_style_names_stay_literal() { - assert_eq!(unescape_value(r"x\y"), r"x\y"); - assert_eq!(unescape_value(r"a\:b"), r"a\:b"); - assert_eq!(unescape_value("end\\"), "end\\"); - assert_eq!(unescape_value(r"\\\"), r"\"); - } - - #[test] - fn tagged_text_round_trips() { - let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; - assert!(is_tagged(src.as_bytes())); - let i = import(src.as_bytes()).unwrap(); - assert_eq!(i.story.text, "Café <1>\nA word and big text"); - assert_eq!(i.story.paras[0].style, "Head"); - assert_eq!(i.story.paras[1].style, "Body"); - assert_eq!(i.story.char_format_at(i.story.text.find("word").unwrap() + 1).style, "Em"); - assert_eq!(i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size, Some(18.0)); - assert_eq!(i.para_styles.iter().map(|s| s.name.as_str()).collect::>(), ["Head", "Body"]); - // Export and back. - let doc = Document::new(&designcraft_doc::build::NewDocument::default()); - let out = tagged_text(&doc, &i.story); - assert!(out.starts_with("") && out.contains("Caf<0x00E9>") && out.contains("word"), "{out}"); - let back = import(out.as_bytes()).unwrap(); - assert_eq!(back.story.text, i.story.text); - assert_eq!(back.story.paras[1].style, "Body"); - // UTF-16 with a byte-order mark. - let mut u16 = vec![0xFF, 0xFE]; - for u in "\r\nHi".encode_utf16() { - u16.extend(u.to_le_bytes()); - } - assert_eq!(import(&u16).unwrap().story.text, "Hi"); - } - - #[test] - fn a_long_style_name_is_cut() { - let name = "N".repeat(100_000); - assert_eq!(unescape_value(&name).len(), crate::MAX_NAME_CHARS); - assert_eq!(unescape_value("Body"), "Body"); - } + let styles = |names: Vec| names.into_iter().map(|name| ImportedStyle { name, ..Default::default() }).collect(); + let warnings = (parsed.skipped_tags > 0).then(|| format!("{} formatting tag(s) not kept", parsed.skipped_tags)).into_iter().collect(); + Ok(Imported { story, para_styles: styles(parsed.para_styles), char_styles: styles(parsed.char_styles), warnings }) }