mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Delegate raster codecs and text interchange to craft-libs
This commit is contained in:
15 files changed
+893
-2198
No files matched your search
Generated
+368
-40
@@ -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"
|
||||
|
||||
+12
-8
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -48,9 +48,7 @@ pub fn eps_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
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<u8>, (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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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)]
|
||||
|
||||
+59
-220
@@ -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<image::RgbaImage> {
|
||||
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<CmykRaster> {
|
||||
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<Vec<u8>> {
|
||||
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>) -> image::RgbaImage {
|
||||
prepared.unwrap_or_else(|| img.to_rgba8())
|
||||
}
|
||||
|
||||
fn prepare_luma8_rgba(gray: &image::GrayImage) -> Option<image::RgbaImage> {
|
||||
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<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn tiff_decoder(bytes: &[u8]) -> Option<tiff::decoder::Decoder<std::io::Cursor<&[u8]>>> {
|
||||
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<PaletteTiff> {
|
||||
use tiff::tags::Tag;
|
||||
let mut d = tiff_decoder(bytes)?;
|
||||
if d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok()?? != 3 {
|
||||
return None;
|
||||
}
|
||||
if d.find_tag_unsigned::<u16>(Tag::SamplesPerPixel).ok()?.unwrap_or(1) != 1 {
|
||||
return None;
|
||||
}
|
||||
let bits = d.find_tag_unsigned::<u8>(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::<u16>(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<image::RgbaImage> {
|
||||
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<Vec<u8>> {
|
||||
let le = bytes.starts_with(b"II");
|
||||
let int = |at: usize, len: usize| -> Option<usize> {
|
||||
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<CmykRaster> {
|
||||
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::<u16>(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::<u16>(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.
|
||||
|
||||
+62
-335
@@ -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<u16> {
|
||||
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?))
|
||||
}
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
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<Mode> {
|
||||
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<image::RgbaImage> {
|
||||
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<CmykRaster> {
|
||||
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<u8> {
|
||||
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<i16> {
|
||||
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<Self> {
|
||||
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<Vec<u8>> {
|
||||
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;
|
||||
@@ -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<Tone> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<u8> {
|
||||
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] {
|
||||
|
||||
@@ -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 }
|
||||
+300
-649
@@ -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<Node>,
|
||||
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<String, String> {
|
||||
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<Item = &El> {
|
||||
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()
|
||||
}
|
||||
/// `<w:x w:val="…"/>` → the value; a bare `<w:b/>` counts as "on".
|
||||
pub(crate) fn val(&self, name: &str) -> Option<String> {
|
||||
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<bool> {
|
||||
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<El, ImportError> {
|
||||
let mut r = quick_xml::Reader::from_str(xml);
|
||||
let mut stack: Vec<El> = 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::<u32>().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<std::io::Cursor<&[u8]>>, name: &str) -> Result<Option<String>, ImportError> {
|
||||
part_capped(zip, name, PART_LIMIT)
|
||||
}
|
||||
|
||||
pub(crate) fn part_capped(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64) -> Result<Option<String>, 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 `<w:rPr>`.
|
||||
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::<f64>().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 `<w:pPr>` (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::<f64>().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<String, String>,
|
||||
char_names: HashMap<String, String>,
|
||||
footnotes: HashMap<String, El>,
|
||||
/// 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<String>,
|
||||
note_bytes: usize,
|
||||
warnings: Vec<String>,
|
||||
/// 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<String>,
|
||||
next_table: u64,
|
||||
numbering: Numbering,
|
||||
/// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id.
|
||||
style_lists: HashMap<String, (Option<String>, Option<String>)>,
|
||||
/// 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<String>> = 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<String>) {
|
||||
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<ListPara> {
|
||||
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::<u32>().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<u32>, Option<String>) {
|
||||
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<String, (Option<String>, Option<String>)> {
|
||||
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<String, String>) -> 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<ImportedStyle>, Vec<ImportedStyle>, HashMap<String, String>) {
|
||||
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<Block>]) -> 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::<Vec<_>>().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<String, String>,
|
||||
warned_objects: &mut bool,
|
||||
notes_done: &mut HashSet<u32>,
|
||||
note_bytes: &mut usize,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
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<CharFormat> = None;
|
||||
let mut chunk = String::new();
|
||||
let flush = |st: &mut Story, fmt: &mut Option<CharFormat>, 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<Block>],
|
||||
st: &mut Story,
|
||||
names: &HashMap<String, String>,
|
||||
warned_objects: &mut bool,
|
||||
notes_done: &mut HashSet<u32>,
|
||||
note_bytes: &mut usize,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
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<Vec<String>> = 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<Imported, ImportError> {
|
||||
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<String> = story.paras.iter().map(|p| p.style.clone()).collect();
|
||||
let used_c: HashSet<String> = story.runs().map(|(_, f)| f.style.clone()).collect();
|
||||
let keep = |list: &mut Vec<ImportedStyle>, used: &HashSet<String>| {
|
||||
let mut want = used.clone();
|
||||
let mut todo: Vec<String> = 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<String>, 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<String, String> = 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::<i64>().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<String> = stories().flat_map(|s| s.paras.iter().map(|p| p.style.clone())).collect();
|
||||
let used_c: std::collections::HashSet<String> = stories().flat_map(|s| s.runs().map(|(_, f)| f.style.clone())).collect();
|
||||
let keep = |list: Vec<ImportedStyle>, used: &std::collections::HashSet<String>| {
|
||||
let mut want: std::collections::HashSet<String> = 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<String> = 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::<Vec<_>>()
|
||||
};
|
||||
Ok(Imported { story: st, para_styles: keep(para_styles, &used_p), char_styles: keep(char_styles, &used_c), warnings: ctx.warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn cdata_text() {
|
||||
let el = parse("<a><t><![CDATA[x < y]]></t></a>").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!("{}{}", "<w:p>".repeat(n), "</w:p>".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<u8> {
|
||||
|
||||
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!("<w:document><w:body><w:p{attrs}/></w:body></w:document>");
|
||||
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("<a>&#+65;&#x+42;&#-65;&#x-42;&#;&#x;</a>").unwrap();
|
||||
assert_eq!(el.text(), "");
|
||||
let ok = parse("<a>AB</a>").unwrap();
|
||||
assert_eq!(ok.text(), "AB");
|
||||
}
|
||||
@@ -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<f64>,
|
||||
/// First-line indent in points (negative for a hanging indent).
|
||||
first_line: Option<f64>,
|
||||
/// `w:lvlRestart`: 0 = never restart, n = restart after level n (1-based) or a higher one.
|
||||
restart: Option<u32>,
|
||||
/// `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<String>,
|
||||
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<u32>; LEVELS],
|
||||
level_override: [Option<Level>; 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, [Option<Level>; LEVELS]>,
|
||||
nums: HashMap<String, Num>,
|
||||
/// Current number per level, per counter key (an abstractNum, or a `num` with overrides).
|
||||
counters: HashMap<String, [Option<u32>; LEVELS]>,
|
||||
}
|
||||
|
||||
fn level_index(e: &El) -> Option<usize> {
|
||||
e.attr("ilvl").and_then(|v| v.trim().parse::<usize>().ok()).filter(|l| *l < LEVELS)
|
||||
}
|
||||
|
||||
fn parse_u32(v: Option<String>) -> Option<u32> {
|
||||
let v = v?;
|
||||
let v = v.trim();
|
||||
// Negative starts count from 0; numbers too large for a u32 are clamped.
|
||||
match v.parse::<i64>() {
|
||||
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::<f64>().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<Level>; 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<ListPara> {
|
||||
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), "•");
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<Vec<String>
|
||||
|
||||
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<u32>,
|
||||
}
|
||||
|
||||
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<char> {
|
||||
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 {
|
||||
|
||||
+50
-434
@@ -1,456 +1,72 @@
|
||||
//! Tagged Text: plain text with `<Tag:value>` 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("<ASCII-WIN>\r\n<Version:8>\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, "<ParaStyle:{}>", 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, "<CharStyle:{}>", escape_value(&f.style));
|
||||
open.push("CharStyle");
|
||||
}
|
||||
if let Some(v) = &f.over.font_family {
|
||||
let _ = write!(out, "<cFont:{}>", escape_value(v));
|
||||
open.push("cFont");
|
||||
}
|
||||
if let Some(v) = &f.over.font_style {
|
||||
let _ = write!(out, "<cTypeface:{}>", escape_value(v));
|
||||
open.push("cTypeface");
|
||||
}
|
||||
if let Some(v) = f.over.size {
|
||||
let _ = write!(out, "<cSize:{v:.6}>");
|
||||
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<u16> = 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("<ASCII-") || h.starts_with("<UNICODE-") || h.starts_with("<ANSI-")
|
||||
craft_idml::tagged::is_tagged(bytes)
|
||||
}
|
||||
|
||||
/// Read Tagged Text.
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
let text = decode(bytes);
|
||||
let chars: Vec<char> = 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<String> = Vec::new();
|
||||
let mut char_styles: Vec<String> = 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::<u32>().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("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body>A<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("<ASCII-WIN>\r\n<ParaStyle:Body>A<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("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body ><CharStyle: Em >Hello<CharStyle:>".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 =
|
||||
"<ASCII-WIN>\r\n<DefineParaStyle:Body =<cSize:12>><DefineCharStyle: Em =<cSize:12>><ParaStyle:Body ><CharStyle: Em >Hello<CharStyle:>";
|
||||
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!("<ASCII-WIN>\r\n<ParaStyle:Body>A{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!("<ASCII-WIN>\r\n<ParaStyle:Body><cSize:{v}>big<cSize:>");
|
||||
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 = "<ASCII-WIN>\r\n<ParaStyle:Body><cSize:18>big<cSize:>";
|
||||
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!("<ASCII-WIN>\r\n<ParaStyle:Body><cSize:{v}>big<cSize:>");
|
||||
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", "A<b>c", "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::<Vec<_>>(), [name]);
|
||||
assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [name]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_values_with_tag_characters_round_trip() {
|
||||
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
for name in ["Weird>Font", "A<b>c", "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("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body><cFont:a\\b>x<cFont:><cTypeface:c\\d>y".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"\<a\>\\"), r"<a>\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_text_round_trips() {
|
||||
let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> 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::<Vec<_>>(), ["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("<ASCII-WIN>") && out.contains("Caf<0x00E9>") && out.contains("<CharStyle:Em>word<CharStyle:>"), "{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 "<UNICODE-WIN>\r\n<ParaStyle:X>Hi".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<String>| 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 })
|
||||
}
|
||||
Reference in new issue
Block a user