Mayo commited on
feat: export to psd
Browse files- Cargo.lock +65 -145
- Cargo.toml +3 -1
- README.md +20 -15
- docs/README.ja.md +5 -0
- docs/README.zh-CN.md +5 -0
- koharu-pipeline/src/ops/edit.rs +1 -0
- koharu-pipeline/src/ops/vision.rs +2 -1
- koharu-psd/Cargo.toml +21 -0
- koharu-psd/examples/from_image.rs +201 -0
- koharu-psd/examples/full_fixture.rs +211 -0
- koharu-psd/src/descriptor.rs +181 -0
- koharu-psd/src/engine_data.rs +852 -0
- koharu-psd/src/error.rs +25 -0
- koharu-psd/src/export.rs +827 -0
- koharu-psd/src/lib.rs +9 -0
- koharu-psd/src/packbits.rs +150 -0
- koharu-psd/src/writer.rs +143 -0
- koharu-psd/tests/export.rs +214 -0
- koharu-renderer/Cargo.toml +1 -1
- koharu-renderer/benches/rendering.rs +8 -2
- koharu-renderer/src/facade.rs +64 -26
- koharu-renderer/src/font.rs +54 -127
- koharu-renderer/src/layout.rs +25 -3
- koharu-renderer/src/shape.rs +14 -6
- koharu-renderer/src/text/script.rs +34 -1
- koharu-renderer/tests/rendering.rs +15 -5
- koharu-rpc/Cargo.toml +1 -0
- koharu-rpc/src/api.rs +62 -8
- koharu-rpc/src/mcp/mod.rs +5 -1
- koharu-types/src/lib.rs +1 -0
- koharu-types/src/protocol.rs +10 -0
- ui/components/MenuBar.tsx +8 -0
- ui/components/panels/RenderControlsPanel.tsx +68 -23
- ui/lib/api.ts +18 -2
- ui/lib/errors.ts +1 -0
- ui/lib/generated/protocol/DocumentChangedEvent.ts +1 -1
- ui/lib/generated/protocol/DocumentDetail.ts +1 -1
- ui/lib/generated/protocol/DocumentSummary.ts +1 -1
- ui/lib/generated/protocol/DownloadState.ts +2 -2
- ui/lib/generated/protocol/FontFaceInfo.ts +3 -0
- ui/lib/generated/protocol/TextBlockDetail.ts +1 -0
- ui/lib/protocol.ts +2 -0
- ui/lib/query/hooks.ts +1 -1
- ui/lib/query/mutations.ts +6 -0
Cargo.lock
CHANGED
|
@@ -1936,53 +1936,44 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
|
| 1936 |
|
| 1937 |
[[package]]
|
| 1938 |
name = "font-types"
|
| 1939 |
-
version = "0.
|
| 1940 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1941 |
-
checksum = "
|
| 1942 |
dependencies = [
|
| 1943 |
"bytemuck",
|
| 1944 |
]
|
| 1945 |
|
| 1946 |
[[package]]
|
| 1947 |
-
name = "
|
| 1948 |
-
version = "0.
|
| 1949 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1950 |
-
checksum = "
|
| 1951 |
dependencies = [
|
| 1952 |
-
"
|
| 1953 |
]
|
| 1954 |
|
| 1955 |
[[package]]
|
| 1956 |
-
name = "
|
| 1957 |
-
version = "0.
|
| 1958 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1959 |
-
checksum = "
|
| 1960 |
dependencies = [
|
| 1961 |
-
"
|
| 1962 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1963 |
]
|
| 1964 |
|
| 1965 |
[[package]]
|
| 1966 |
-
name = "
|
| 1967 |
-
version = "0.
|
| 1968 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1969 |
-
checksum = "
|
| 1970 |
dependencies = [
|
| 1971 |
-
"
|
| 1972 |
-
"
|
| 1973 |
-
"icu_locale_core",
|
| 1974 |
-
"linebender_resource_handle",
|
| 1975 |
-
"memmap2",
|
| 1976 |
-
"objc2",
|
| 1977 |
-
"objc2-core-foundation",
|
| 1978 |
-
"objc2-core-text",
|
| 1979 |
-
"objc2-foundation",
|
| 1980 |
-
"read-fonts 0.35.0",
|
| 1981 |
-
"roxmltree",
|
| 1982 |
-
"smallvec",
|
| 1983 |
-
"windows 0.58.0",
|
| 1984 |
-
"windows-core 0.58.0",
|
| 1985 |
-
"yeslogic-fontconfig-sys",
|
| 1986 |
]
|
| 1987 |
|
| 1988 |
[[package]]
|
|
@@ -2865,7 +2856,7 @@ dependencies = [
|
|
| 2865 |
"bitflags 2.11.0",
|
| 2866 |
"bytemuck",
|
| 2867 |
"core_maths",
|
| 2868 |
-
"read-fonts
|
| 2869 |
"smallvec",
|
| 2870 |
]
|
| 2871 |
|
|
@@ -3962,14 +3953,25 @@ dependencies = [
|
|
| 3962 |
"uuid",
|
| 3963 |
]
|
| 3964 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3965 |
[[package]]
|
| 3966 |
name = "koharu-renderer"
|
| 3967 |
version = "0.38.1"
|
| 3968 |
dependencies = [
|
| 3969 |
"anyhow",
|
| 3970 |
"criterion",
|
|
|
|
| 3971 |
"fontdue",
|
| 3972 |
-
"fontique",
|
| 3973 |
"harfrust",
|
| 3974 |
"icu",
|
| 3975 |
"image",
|
|
@@ -3997,6 +3999,7 @@ dependencies = [
|
|
| 3997 |
"imageproc",
|
| 3998 |
"koharu-http",
|
| 3999 |
"koharu-pipeline",
|
|
|
|
| 4000 |
"koharu-types",
|
| 4001 |
"rmcp",
|
| 4002 |
"rmp-serde",
|
|
@@ -4186,12 +4189,6 @@ dependencies = [
|
|
| 4186 |
"vcpkg",
|
| 4187 |
]
|
| 4188 |
|
| 4189 |
-
[[package]]
|
| 4190 |
-
name = "linebender_resource_handle"
|
| 4191 |
-
version = "0.1.1"
|
| 4192 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4193 |
-
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
| 4194 |
-
|
| 4195 |
[[package]]
|
| 4196 |
name = "linux-raw-sys"
|
| 4197 |
version = "0.12.1"
|
|
@@ -4817,16 +4814,6 @@ dependencies = [
|
|
| 4817 |
"objc2-io-surface",
|
| 4818 |
]
|
| 4819 |
|
| 4820 |
-
[[package]]
|
| 4821 |
-
name = "objc2-core-text"
|
| 4822 |
-
version = "0.3.2"
|
| 4823 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4824 |
-
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
| 4825 |
-
dependencies = [
|
| 4826 |
-
"bitflags 2.11.0",
|
| 4827 |
-
"objc2-core-foundation",
|
| 4828 |
-
]
|
| 4829 |
-
|
| 4830 |
[[package]]
|
| 4831 |
name = "objc2-encode"
|
| 4832 |
version = "4.1.0"
|
|
@@ -5609,6 +5596,15 @@ dependencies = [
|
|
| 5609 |
"syn 2.0.117",
|
| 5610 |
]
|
| 5611 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5612 |
[[package]]
|
| 5613 |
name = "pulp"
|
| 5614 |
version = "0.21.5"
|
|
@@ -6001,16 +5997,6 @@ dependencies = [
|
|
| 6001 |
"crossbeam-utils",
|
| 6002 |
]
|
| 6003 |
|
| 6004 |
-
[[package]]
|
| 6005 |
-
name = "read-fonts"
|
| 6006 |
-
version = "0.35.0"
|
| 6007 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6008 |
-
checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"
|
| 6009 |
-
dependencies = [
|
| 6010 |
-
"bytemuck",
|
| 6011 |
-
"font-types 0.10.1",
|
| 6012 |
-
]
|
| 6013 |
-
|
| 6014 |
[[package]]
|
| 6015 |
name = "read-fonts"
|
| 6016 |
version = "0.37.0"
|
|
@@ -6019,7 +6005,7 @@ checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
|
|
| 6019 |
dependencies = [
|
| 6020 |
"bytemuck",
|
| 6021 |
"core_maths",
|
| 6022 |
-
"font-types
|
| 6023 |
]
|
| 6024 |
|
| 6025 |
[[package]]
|
|
@@ -6360,12 +6346,9 @@ dependencies = [
|
|
| 6360 |
|
| 6361 |
[[package]]
|
| 6362 |
name = "roxmltree"
|
| 6363 |
-
version = "0.
|
| 6364 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6365 |
-
checksum = "
|
| 6366 |
-
dependencies = [
|
| 6367 |
-
"memchr",
|
| 6368 |
-
]
|
| 6369 |
|
| 6370 |
[[package]]
|
| 6371 |
name = "rustc-hash"
|
|
@@ -7024,7 +7007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
| 7024 |
checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
|
| 7025 |
dependencies = [
|
| 7026 |
"bytemuck",
|
| 7027 |
-
"read-fonts
|
| 7028 |
]
|
| 7029 |
|
| 7030 |
[[package]]
|
|
@@ -7033,6 +7016,15 @@ version = "0.4.12"
|
|
| 7033 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 7034 |
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
| 7035 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7036 |
[[package]]
|
| 7037 |
name = "smallvec"
|
| 7038 |
version = "1.15.1"
|
|
@@ -8267,6 +8259,9 @@ name = "ttf-parser"
|
|
| 8267 |
version = "0.25.1"
|
| 8268 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 8269 |
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
|
|
|
|
|
|
|
|
|
| 8270 |
|
| 8271 |
[[package]]
|
| 8272 |
name = "tungstenite"
|
|
@@ -8939,8 +8934,8 @@ dependencies = [
|
|
| 8939 |
"webview2-com-sys",
|
| 8940 |
"windows 0.61.3",
|
| 8941 |
"windows-core 0.61.2",
|
| 8942 |
-
"windows-implement
|
| 8943 |
-
"windows-interface
|
| 8944 |
]
|
| 8945 |
|
| 8946 |
[[package]]
|
|
@@ -9027,16 +9022,6 @@ dependencies = [
|
|
| 9027 |
"windows-version",
|
| 9028 |
]
|
| 9029 |
|
| 9030 |
-
[[package]]
|
| 9031 |
-
name = "windows"
|
| 9032 |
-
version = "0.58.0"
|
| 9033 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9034 |
-
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
| 9035 |
-
dependencies = [
|
| 9036 |
-
"windows-core 0.58.0",
|
| 9037 |
-
"windows-targets 0.52.6",
|
| 9038 |
-
]
|
| 9039 |
-
|
| 9040 |
[[package]]
|
| 9041 |
name = "windows"
|
| 9042 |
version = "0.61.3"
|
|
@@ -9080,27 +9065,14 @@ dependencies = [
|
|
| 9080 |
"windows-core 0.62.2",
|
| 9081 |
]
|
| 9082 |
|
| 9083 |
-
[[package]]
|
| 9084 |
-
name = "windows-core"
|
| 9085 |
-
version = "0.58.0"
|
| 9086 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9087 |
-
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
| 9088 |
-
dependencies = [
|
| 9089 |
-
"windows-implement 0.58.0",
|
| 9090 |
-
"windows-interface 0.58.0",
|
| 9091 |
-
"windows-result 0.2.0",
|
| 9092 |
-
"windows-strings 0.1.0",
|
| 9093 |
-
"windows-targets 0.52.6",
|
| 9094 |
-
]
|
| 9095 |
-
|
| 9096 |
[[package]]
|
| 9097 |
name = "windows-core"
|
| 9098 |
version = "0.61.2"
|
| 9099 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9100 |
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
| 9101 |
dependencies = [
|
| 9102 |
-
"windows-implement
|
| 9103 |
-
"windows-interface
|
| 9104 |
"windows-link 0.1.3",
|
| 9105 |
"windows-result 0.3.4",
|
| 9106 |
"windows-strings 0.4.2",
|
|
@@ -9112,8 +9084,8 @@ version = "0.62.2"
|
|
| 9112 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9113 |
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
| 9114 |
dependencies = [
|
| 9115 |
-
"windows-implement
|
| 9116 |
-
"windows-interface
|
| 9117 |
"windows-link 0.2.1",
|
| 9118 |
"windows-result 0.4.1",
|
| 9119 |
"windows-strings 0.5.1",
|
|
@@ -9141,17 +9113,6 @@ dependencies = [
|
|
| 9141 |
"windows-threading 0.2.1",
|
| 9142 |
]
|
| 9143 |
|
| 9144 |
-
[[package]]
|
| 9145 |
-
name = "windows-implement"
|
| 9146 |
-
version = "0.58.0"
|
| 9147 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9148 |
-
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
| 9149 |
-
dependencies = [
|
| 9150 |
-
"proc-macro2",
|
| 9151 |
-
"quote",
|
| 9152 |
-
"syn 2.0.117",
|
| 9153 |
-
]
|
| 9154 |
-
|
| 9155 |
[[package]]
|
| 9156 |
name = "windows-implement"
|
| 9157 |
version = "0.60.2"
|
|
@@ -9163,17 +9124,6 @@ dependencies = [
|
|
| 9163 |
"syn 2.0.117",
|
| 9164 |
]
|
| 9165 |
|
| 9166 |
-
[[package]]
|
| 9167 |
-
name = "windows-interface"
|
| 9168 |
-
version = "0.58.0"
|
| 9169 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9170 |
-
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
| 9171 |
-
dependencies = [
|
| 9172 |
-
"proc-macro2",
|
| 9173 |
-
"quote",
|
| 9174 |
-
"syn 2.0.117",
|
| 9175 |
-
]
|
| 9176 |
-
|
| 9177 |
[[package]]
|
| 9178 |
name = "windows-interface"
|
| 9179 |
version = "0.59.3"
|
|
@@ -9228,15 +9178,6 @@ dependencies = [
|
|
| 9228 |
"windows-strings 0.5.1",
|
| 9229 |
]
|
| 9230 |
|
| 9231 |
-
[[package]]
|
| 9232 |
-
name = "windows-result"
|
| 9233 |
-
version = "0.2.0"
|
| 9234 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9235 |
-
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
| 9236 |
-
dependencies = [
|
| 9237 |
-
"windows-targets 0.52.6",
|
| 9238 |
-
]
|
| 9239 |
-
|
| 9240 |
[[package]]
|
| 9241 |
name = "windows-result"
|
| 9242 |
version = "0.3.4"
|
|
@@ -9255,16 +9196,6 @@ dependencies = [
|
|
| 9255 |
"windows-link 0.2.1",
|
| 9256 |
]
|
| 9257 |
|
| 9258 |
-
[[package]]
|
| 9259 |
-
name = "windows-strings"
|
| 9260 |
-
version = "0.1.0"
|
| 9261 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9262 |
-
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
| 9263 |
-
dependencies = [
|
| 9264 |
-
"windows-result 0.2.0",
|
| 9265 |
-
"windows-targets 0.52.6",
|
| 9266 |
-
]
|
| 9267 |
-
|
| 9268 |
[[package]]
|
| 9269 |
name = "windows-strings"
|
| 9270 |
version = "0.4.2"
|
|
@@ -9763,17 +9694,6 @@ version = "0.8.0"
|
|
| 9763 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9764 |
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
| 9765 |
|
| 9766 |
-
[[package]]
|
| 9767 |
-
name = "yeslogic-fontconfig-sys"
|
| 9768 |
-
version = "6.0.0"
|
| 9769 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9770 |
-
checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd"
|
| 9771 |
-
dependencies = [
|
| 9772 |
-
"dlib",
|
| 9773 |
-
"once_cell",
|
| 9774 |
-
"pkg-config",
|
| 9775 |
-
]
|
| 9776 |
-
|
| 9777 |
[[package]]
|
| 9778 |
name = "yoke"
|
| 9779 |
version = "0.7.5"
|
|
|
|
| 1936 |
|
| 1937 |
[[package]]
|
| 1938 |
name = "font-types"
|
| 1939 |
+
version = "0.11.0"
|
| 1940 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1941 |
+
checksum = "b1e4d2d0cf79d38430cc9dc9aadec84774bff2e1ba30ae2bf6c16cfce9385a23"
|
| 1942 |
dependencies = [
|
| 1943 |
"bytemuck",
|
| 1944 |
]
|
| 1945 |
|
| 1946 |
[[package]]
|
| 1947 |
+
name = "fontconfig-parser"
|
| 1948 |
+
version = "0.5.8"
|
| 1949 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1950 |
+
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
| 1951 |
dependencies = [
|
| 1952 |
+
"roxmltree",
|
| 1953 |
]
|
| 1954 |
|
| 1955 |
[[package]]
|
| 1956 |
+
name = "fontdb"
|
| 1957 |
+
version = "0.23.0"
|
| 1958 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1959 |
+
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
|
| 1960 |
dependencies = [
|
| 1961 |
+
"fontconfig-parser",
|
| 1962 |
+
"log",
|
| 1963 |
+
"memmap2",
|
| 1964 |
+
"slotmap",
|
| 1965 |
+
"tinyvec",
|
| 1966 |
+
"ttf-parser 0.25.1",
|
| 1967 |
]
|
| 1968 |
|
| 1969 |
[[package]]
|
| 1970 |
+
name = "fontdue"
|
| 1971 |
+
version = "0.9.3"
|
| 1972 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1973 |
+
checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
|
| 1974 |
dependencies = [
|
| 1975 |
+
"hashbrown 0.15.5",
|
| 1976 |
+
"ttf-parser 0.21.1",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1977 |
]
|
| 1978 |
|
| 1979 |
[[package]]
|
|
|
|
| 2856 |
"bitflags 2.11.0",
|
| 2857 |
"bytemuck",
|
| 2858 |
"core_maths",
|
| 2859 |
+
"read-fonts",
|
| 2860 |
"smallvec",
|
| 2861 |
]
|
| 2862 |
|
|
|
|
| 3953 |
"uuid",
|
| 3954 |
]
|
| 3955 |
|
| 3956 |
+
[[package]]
|
| 3957 |
+
name = "koharu-psd"
|
| 3958 |
+
version = "0.38.1"
|
| 3959 |
+
dependencies = [
|
| 3960 |
+
"image",
|
| 3961 |
+
"koharu-renderer",
|
| 3962 |
+
"koharu-types",
|
| 3963 |
+
"psd",
|
| 3964 |
+
"thiserror 2.0.18",
|
| 3965 |
+
]
|
| 3966 |
+
|
| 3967 |
[[package]]
|
| 3968 |
name = "koharu-renderer"
|
| 3969 |
version = "0.38.1"
|
| 3970 |
dependencies = [
|
| 3971 |
"anyhow",
|
| 3972 |
"criterion",
|
| 3973 |
+
"fontdb",
|
| 3974 |
"fontdue",
|
|
|
|
| 3975 |
"harfrust",
|
| 3976 |
"icu",
|
| 3977 |
"image",
|
|
|
|
| 3999 |
"imageproc",
|
| 4000 |
"koharu-http",
|
| 4001 |
"koharu-pipeline",
|
| 4002 |
+
"koharu-psd",
|
| 4003 |
"koharu-types",
|
| 4004 |
"rmcp",
|
| 4005 |
"rmp-serde",
|
|
|
|
| 4189 |
"vcpkg",
|
| 4190 |
]
|
| 4191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4192 |
[[package]]
|
| 4193 |
name = "linux-raw-sys"
|
| 4194 |
version = "0.12.1"
|
|
|
|
| 4814 |
"objc2-io-surface",
|
| 4815 |
]
|
| 4816 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4817 |
[[package]]
|
| 4818 |
name = "objc2-encode"
|
| 4819 |
version = "4.1.0"
|
|
|
|
| 5596 |
"syn 2.0.117",
|
| 5597 |
]
|
| 5598 |
|
| 5599 |
+
[[package]]
|
| 5600 |
+
name = "psd"
|
| 5601 |
+
version = "0.3.5"
|
| 5602 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 5603 |
+
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
|
| 5604 |
+
dependencies = [
|
| 5605 |
+
"thiserror 1.0.69",
|
| 5606 |
+
]
|
| 5607 |
+
|
| 5608 |
[[package]]
|
| 5609 |
name = "pulp"
|
| 5610 |
version = "0.21.5"
|
|
|
|
| 5997 |
"crossbeam-utils",
|
| 5998 |
]
|
| 5999 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6000 |
[[package]]
|
| 6001 |
name = "read-fonts"
|
| 6002 |
version = "0.37.0"
|
|
|
|
| 6005 |
dependencies = [
|
| 6006 |
"bytemuck",
|
| 6007 |
"core_maths",
|
| 6008 |
+
"font-types",
|
| 6009 |
]
|
| 6010 |
|
| 6011 |
[[package]]
|
|
|
|
| 6346 |
|
| 6347 |
[[package]]
|
| 6348 |
name = "roxmltree"
|
| 6349 |
+
version = "0.20.0"
|
| 6350 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6351 |
+
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
|
|
|
|
|
|
|
|
|
| 6352 |
|
| 6353 |
[[package]]
|
| 6354 |
name = "rustc-hash"
|
|
|
|
| 7007 |
checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
|
| 7008 |
dependencies = [
|
| 7009 |
"bytemuck",
|
| 7010 |
+
"read-fonts",
|
| 7011 |
]
|
| 7012 |
|
| 7013 |
[[package]]
|
|
|
|
| 7016 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 7017 |
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
| 7018 |
|
| 7019 |
+
[[package]]
|
| 7020 |
+
name = "slotmap"
|
| 7021 |
+
version = "1.1.1"
|
| 7022 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 7023 |
+
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
| 7024 |
+
dependencies = [
|
| 7025 |
+
"version_check",
|
| 7026 |
+
]
|
| 7027 |
+
|
| 7028 |
[[package]]
|
| 7029 |
name = "smallvec"
|
| 7030 |
version = "1.15.1"
|
|
|
|
| 8259 |
version = "0.25.1"
|
| 8260 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 8261 |
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
| 8262 |
+
dependencies = [
|
| 8263 |
+
"core_maths",
|
| 8264 |
+
]
|
| 8265 |
|
| 8266 |
[[package]]
|
| 8267 |
name = "tungstenite"
|
|
|
|
| 8934 |
"webview2-com-sys",
|
| 8935 |
"windows 0.61.3",
|
| 8936 |
"windows-core 0.61.2",
|
| 8937 |
+
"windows-implement",
|
| 8938 |
+
"windows-interface",
|
| 8939 |
]
|
| 8940 |
|
| 8941 |
[[package]]
|
|
|
|
| 9022 |
"windows-version",
|
| 9023 |
]
|
| 9024 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9025 |
[[package]]
|
| 9026 |
name = "windows"
|
| 9027 |
version = "0.61.3"
|
|
|
|
| 9065 |
"windows-core 0.62.2",
|
| 9066 |
]
|
| 9067 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9068 |
[[package]]
|
| 9069 |
name = "windows-core"
|
| 9070 |
version = "0.61.2"
|
| 9071 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9072 |
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
| 9073 |
dependencies = [
|
| 9074 |
+
"windows-implement",
|
| 9075 |
+
"windows-interface",
|
| 9076 |
"windows-link 0.1.3",
|
| 9077 |
"windows-result 0.3.4",
|
| 9078 |
"windows-strings 0.4.2",
|
|
|
|
| 9084 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9085 |
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
| 9086 |
dependencies = [
|
| 9087 |
+
"windows-implement",
|
| 9088 |
+
"windows-interface",
|
| 9089 |
"windows-link 0.2.1",
|
| 9090 |
"windows-result 0.4.1",
|
| 9091 |
"windows-strings 0.5.1",
|
|
|
|
| 9113 |
"windows-threading 0.2.1",
|
| 9114 |
]
|
| 9115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9116 |
[[package]]
|
| 9117 |
name = "windows-implement"
|
| 9118 |
version = "0.60.2"
|
|
|
|
| 9124 |
"syn 2.0.117",
|
| 9125 |
]
|
| 9126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9127 |
[[package]]
|
| 9128 |
name = "windows-interface"
|
| 9129 |
version = "0.59.3"
|
|
|
|
| 9178 |
"windows-strings 0.5.1",
|
| 9179 |
]
|
| 9180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9181 |
[[package]]
|
| 9182 |
name = "windows-result"
|
| 9183 |
version = "0.3.4"
|
|
|
|
| 9196 |
"windows-link 0.2.1",
|
| 9197 |
]
|
| 9198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9199 |
[[package]]
|
| 9200 |
name = "windows-strings"
|
| 9201 |
version = "0.4.2"
|
|
|
|
| 9694 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9695 |
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
| 9696 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9697 |
[[package]]
|
| 9698 |
name = "yoke"
|
| 9699 |
version = "0.7.5"
|
Cargo.toml
CHANGED
|
@@ -4,6 +4,7 @@ members = [
|
|
| 4 |
"koharu-http",
|
| 5 |
"koharu-ml",
|
| 6 |
"koharu-runtime",
|
|
|
|
| 7 |
"koharu-renderer",
|
| 8 |
"koharu-pipeline",
|
| 9 |
"koharu-rpc",
|
|
@@ -31,6 +32,7 @@ publish = false
|
|
| 31 |
koharu-http = { path = "koharu-http", default-features = false }
|
| 32 |
koharu-runtime = { path = "koharu-runtime", default-features = false }
|
| 33 |
koharu-ml = { path = "koharu-ml", default-features = false }
|
|
|
|
| 34 |
koharu-renderer = { path = "koharu-renderer", default-features = false }
|
| 35 |
koharu-pipeline = { path = "koharu-pipeline", default-features = false }
|
| 36 |
koharu-rpc = { path = "koharu-rpc", default-features = false }
|
|
@@ -106,7 +108,7 @@ winreg = "0.56"
|
|
| 106 |
skrifa = "0.40"
|
| 107 |
harfrust = "0.5"
|
| 108 |
icu = "2.1"
|
| 109 |
-
|
| 110 |
fontdue = "0.9"
|
| 111 |
tiny-skia = "0.12"
|
| 112 |
axum = { version = "0.8", features = ["multipart", "ws"] }
|
|
|
|
| 4 |
"koharu-http",
|
| 5 |
"koharu-ml",
|
| 6 |
"koharu-runtime",
|
| 7 |
+
"koharu-psd",
|
| 8 |
"koharu-renderer",
|
| 9 |
"koharu-pipeline",
|
| 10 |
"koharu-rpc",
|
|
|
|
| 32 |
koharu-http = { path = "koharu-http", default-features = false }
|
| 33 |
koharu-runtime = { path = "koharu-runtime", default-features = false }
|
| 34 |
koharu-ml = { path = "koharu-ml", default-features = false }
|
| 35 |
+
koharu-psd = { path = "koharu-psd", default-features = false }
|
| 36 |
koharu-renderer = { path = "koharu-renderer", default-features = false }
|
| 37 |
koharu-pipeline = { path = "koharu-pipeline", default-features = false }
|
| 38 |
koharu-rpc = { path = "koharu-rpc", default-features = false }
|
|
|
|
| 108 |
skrifa = "0.40"
|
| 109 |
harfrust = "0.5"
|
| 110 |
icu = "2.1"
|
| 111 |
+
fontdb = "0.23"
|
| 112 |
fontdue = "0.9"
|
| 113 |
tiny-skia = "0.12"
|
| 114 |
axum = { version = "0.8", features = ["multipart", "ws"] }
|
README.md
CHANGED
|
@@ -18,24 +18,29 @@ Under the hood, Koharu uses [candle](https://github.com/huggingface/candle) for
|
|
| 18 |
> [!NOTE]
|
| 19 |
> For help and support, please join our [Discord server](https://discord.gg/mHvHkxGnUY).
|
| 20 |
|
| 21 |
-
## Features
|
| 22 |
-
|
| 23 |
-
- Automatic speech bubble detection and segmentation
|
| 24 |
-
- OCR for manga text recognition
|
| 25 |
-
- Inpainting to remove original text from images
|
| 26 |
-
- LLM-powered translation
|
| 27 |
-
- Vertical text layout for CJK languages
|
| 28 |
-
-
|
|
|
|
| 29 |
|
| 30 |
## Usage
|
| 31 |
|
| 32 |
-
### Hot keys
|
| 33 |
-
|
| 34 |
-
- <kbd>Ctrl</kbd> + Mouse Wheel: Zoom in/out
|
| 35 |
-
- <kbd>Ctrl</kbd> + Drag: Pan the canvas
|
| 36 |
-
- <kbd>Del</kbd>: Delete selected text block
|
| 37 |
-
|
| 38 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
Koharu has a built-in MCP server that can be used to integrate with AI agents. By default, the MCP server will listen on a random port, but you can specify the port using the `--port` flag.
|
| 41 |
|
|
|
|
| 18 |
> [!NOTE]
|
| 19 |
> For help and support, please join our [Discord server](https://discord.gg/mHvHkxGnUY).
|
| 20 |
|
| 21 |
+
## Features
|
| 22 |
+
|
| 23 |
+
- Automatic speech bubble detection and segmentation
|
| 24 |
+
- OCR for manga text recognition
|
| 25 |
+
- Inpainting to remove original text from images
|
| 26 |
+
- LLM-powered translation
|
| 27 |
+
- Vertical text layout for CJK languages
|
| 28 |
+
- Export to layered PSD with editable text
|
| 29 |
+
- MCP server for AI agents
|
| 30 |
|
| 31 |
## Usage
|
| 32 |
|
| 33 |
+
### Hot keys
|
| 34 |
+
|
| 35 |
+
- <kbd>Ctrl</kbd> + Mouse Wheel: Zoom in/out
|
| 36 |
+
- <kbd>Ctrl</kbd> + Drag: Pan the canvas
|
| 37 |
+
- <kbd>Del</kbd>: Delete selected text block
|
| 38 |
+
|
| 39 |
+
### Export
|
| 40 |
+
|
| 41 |
+
Koharu can export the current page as a rendered image or as a layered Photoshop PSD. PSD export preserves helper layers and writes translated text as editable text layers for further cleanup in Photoshop.
|
| 42 |
+
|
| 43 |
+
### MCP Server
|
| 44 |
|
| 45 |
Koharu has a built-in MCP server that can be used to integrate with AI agents. By default, the MCP server will listen on a random port, but you can specify the port using the `--port` flag.
|
| 46 |
|
docs/README.ja.md
CHANGED
|
@@ -23,6 +23,7 @@ Koharu は、ML の力を活用して翻訳工程を自動化する、新しい
|
|
| 23 |
- 画像から元の文字を消すためのインペインティング
|
| 24 |
- LLM による翻訳
|
| 25 |
- CJK(中国語・日本語・韓国語)向けの縦書きレイアウト
|
|
|
|
| 26 |
- AI エージェントとの連携のための MCP サーバー
|
| 27 |
|
| 28 |
## 使い方
|
|
@@ -33,6 +34,10 @@ Koharu は、ML の力を活用して翻訳工程を自動化する、新しい
|
|
| 33 |
- <kbd>Ctrl</kbd> + ドラッグ: キャンバスのパン(移動)
|
| 34 |
- <kbd>Del</kbd>: 選択したテキストブロックを削除
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
### MCP サーバー
|
| 37 |
|
| 38 |
Koharu には MCP サーバーが内蔵されており、AI エージェントとの連携に使用できます。デフォルトでは、MCP サーバーはランダムなポートでリッスンしますが、`--port` フラグを使用してポートを指定できます。
|
|
|
|
| 23 |
- 画像から元の文字を消すためのインペインティング
|
| 24 |
- LLM による翻訳
|
| 25 |
- CJK(中国語・日本語・韓国語)向けの縦書きレイアウト
|
| 26 |
+
- 編集可能なテキスト付きのレイヤー PSD 書き出し
|
| 27 |
- AI エージェントとの連携のための MCP サーバー
|
| 28 |
|
| 29 |
## 使い方
|
|
|
|
| 34 |
- <kbd>Ctrl</kbd> + ドラッグ: キャンバスのパン(移動)
|
| 35 |
- <kbd>Del</kbd>: 選択したテキストブロックを削除
|
| 36 |
|
| 37 |
+
### 書き出し
|
| 38 |
+
|
| 39 |
+
Koharu は現在のページをレンダリング済み画像として書き出すだけでなく、レイヤー付きの Photoshop PSD としても書き出せます。PSD 書き出しでは補助レイヤーを保持しつつ、翻訳済みテキストを編集可能なテキストレイヤーとして保存できます。
|
| 40 |
+
|
| 41 |
### MCP サーバー
|
| 42 |
|
| 43 |
Koharu には MCP サーバーが内蔵されており、AI エージェントとの連携に使用できます。デフォルトでは、MCP サーバーはランダムなポートでリッスンしますが、`--port` フラグを使用してポートを指定できます。
|
docs/README.zh-CN.md
CHANGED
|
@@ -23,6 +23,7 @@ Koharu 引入了一种新的漫画翻译工作流,利用机器学习能力自
|
|
| 23 |
- 通过图像修复去除原图文字
|
| 24 |
- 基于 LLM 的翻译
|
| 25 |
- 面向 CJK 语言的竖排文本布局
|
|
|
|
| 26 |
- 面向 AI Agent 的 MCP 服务器
|
| 27 |
|
| 28 |
## 使用方法
|
|
@@ -33,6 +34,10 @@ Koharu 引入了一种新的漫画翻译工作流,利用机器学习能力自
|
|
| 33 |
- <kbd>Ctrl</kbd> + 拖动:平移画布
|
| 34 |
- <kbd>Del</kbd>:删除选中的文本块
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
### MCP 服务器
|
| 37 |
|
| 38 |
Koharu 内置 MCP 服务器,可用于与 AI Agent 集成。默认情况下,MCP 服务器会监听一个随机端口;你也可以通过 `--port` 参数指定端口。
|
|
|
|
| 23 |
- 通过图像修复去除原图文字
|
| 24 |
- 基于 LLM 的翻译
|
| 25 |
- 面向 CJK 语言的竖排文本布局
|
| 26 |
+
- 支持导出带可编辑文字图层的 PSD
|
| 27 |
- 面向 AI Agent 的 MCP 服务器
|
| 28 |
|
| 29 |
## 使用方法
|
|
|
|
| 34 |
- <kbd>Ctrl</kbd> + 拖动:平移画布
|
| 35 |
- <kbd>Del</kbd>:删除选中的文本块
|
| 36 |
|
| 37 |
+
### 导出
|
| 38 |
+
|
| 39 |
+
Koharu 既可以将当前页面导出为渲染后的图片,也可以导出为带图层的 Photoshop PSD。PSD 导出会保留辅助图层,并将翻译后的文字写成可编辑的文字图层,方便在 Photoshop 中继续调整。
|
| 40 |
+
|
| 41 |
### MCP 服务器
|
| 42 |
|
| 43 |
Koharu 内置 MCP 服务器,可用于与 AI Agent 集成。默认情况下,MCP 服务器会监听一个随机端口;你也可以通过 `--port` 参数指定端口。
|
koharu-pipeline/src/ops/edit.rs
CHANGED
|
@@ -308,6 +308,7 @@ pub async fn update_text_block(
|
|
| 308 |
}
|
| 309 |
|
| 310 |
block.rendered = None;
|
|
|
|
| 311 |
Ok(to_block_info(payload.text_block_index, block))
|
| 312 |
},
|
| 313 |
)
|
|
|
|
| 308 |
}
|
| 309 |
|
| 310 |
block.rendered = None;
|
| 311 |
+
block.rendered_direction = None;
|
| 312 |
Ok(to_block_info(payload.text_block_index, block))
|
| 313 |
},
|
| 314 |
)
|
koharu-pipeline/src/ops/vision.rs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
use koharu_types::commands::{IndexPayload, RenderPayload};
|
| 2 |
use tracing::instrument;
|
| 3 |
|
|
@@ -66,6 +67,6 @@ pub async fn render(state: AppResources, payload: RenderPayload) -> anyhow::Resu
|
|
| 66 |
.await
|
| 67 |
}
|
| 68 |
|
| 69 |
-
pub async fn list_font_families(state: AppResources) -> anyhow::Result<Vec<
|
| 70 |
state.renderer.available_fonts()
|
| 71 |
}
|
|
|
|
| 1 |
+
use koharu_types::FontFaceInfo;
|
| 2 |
use koharu_types::commands::{IndexPayload, RenderPayload};
|
| 3 |
use tracing::instrument;
|
| 4 |
|
|
|
|
| 67 |
.await
|
| 68 |
}
|
| 69 |
|
| 70 |
+
pub async fn list_font_families(state: AppResources) -> anyhow::Result<Vec<FontFaceInfo>> {
|
| 71 |
state.renderer.available_fonts()
|
| 72 |
}
|
koharu-psd/Cargo.toml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "koharu-psd"
|
| 3 |
+
version.workspace = true
|
| 4 |
+
edition.workspace = true
|
| 5 |
+
description.workspace = true
|
| 6 |
+
license.workspace = true
|
| 7 |
+
authors.workspace = true
|
| 8 |
+
readme.workspace = true
|
| 9 |
+
homepage.workspace = true
|
| 10 |
+
repository.workspace = true
|
| 11 |
+
keywords.workspace = true
|
| 12 |
+
publish.workspace = true
|
| 13 |
+
|
| 14 |
+
[dependencies]
|
| 15 |
+
koharu-types = { workspace = true }
|
| 16 |
+
image = { workspace = true }
|
| 17 |
+
thiserror = { workspace = true }
|
| 18 |
+
|
| 19 |
+
[dev-dependencies]
|
| 20 |
+
koharu-renderer = { workspace = true }
|
| 21 |
+
psd = "0.3.5"
|
koharu-psd/examples/from_image.rs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::{error::Error, fs, path::PathBuf};
|
| 2 |
+
|
| 3 |
+
use image::{DynamicImage, GrayImage, Luma, Rgba, RgbaImage};
|
| 4 |
+
use koharu_psd::{PsdExportOptions, TextLayerMode, export_document};
|
| 5 |
+
use koharu_renderer::facade::Renderer;
|
| 6 |
+
use koharu_types::{
|
| 7 |
+
Document, FontPrediction, NamedFontPrediction, SerializableDynamicImage, TextAlign, TextBlock,
|
| 8 |
+
TextDirection, TextShaderEffect, TextStyle,
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
fn main() -> Result<(), Box<dyn Error>> {
|
| 12 |
+
let (input, output, editable) = parse_args()?;
|
| 13 |
+
|
| 14 |
+
if let Some(parent) = output.parent() {
|
| 15 |
+
fs::create_dir_all(parent)?;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
let mut document = Document::open(input.clone())?;
|
| 19 |
+
apply_demo_layers(&mut document);
|
| 20 |
+
render_demo_text(&mut document)?;
|
| 21 |
+
|
| 22 |
+
let options = PsdExportOptions {
|
| 23 |
+
text_layer_mode: if editable {
|
| 24 |
+
TextLayerMode::Editable
|
| 25 |
+
} else {
|
| 26 |
+
TextLayerMode::Rasterized
|
| 27 |
+
},
|
| 28 |
+
..Default::default()
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
let bytes = export_document(&document, &options)?;
|
| 32 |
+
fs::write(&output, bytes)?;
|
| 33 |
+
|
| 34 |
+
println!("input {}", input.display());
|
| 35 |
+
println!(
|
| 36 |
+
"mode {}",
|
| 37 |
+
match options.text_layer_mode {
|
| 38 |
+
TextLayerMode::Rasterized => "rasterized",
|
| 39 |
+
TextLayerMode::Editable => "editable",
|
| 40 |
+
}
|
| 41 |
+
);
|
| 42 |
+
println!("wrote {}", output.display());
|
| 43 |
+
Ok(())
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
fn parse_args() -> Result<(PathBuf, PathBuf, bool), Box<dyn Error>> {
|
| 47 |
+
let mut input = None;
|
| 48 |
+
let mut output = None;
|
| 49 |
+
let mut editable = false;
|
| 50 |
+
|
| 51 |
+
for arg in std::env::args().skip(1) {
|
| 52 |
+
match arg.as_str() {
|
| 53 |
+
"--editable" => editable = true,
|
| 54 |
+
other if input.is_none() => input = Some(PathBuf::from(other)),
|
| 55 |
+
other if output.is_none() => output = Some(PathBuf::from(other)),
|
| 56 |
+
_ => {
|
| 57 |
+
return Err(
|
| 58 |
+
"usage: cargo run -p koharu-psd --example from_image -- [--editable] <input-image> [output.psd]"
|
| 59 |
+
.into(),
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
let input = input.ok_or_else(|| {
|
| 66 |
+
"usage: cargo run -p koharu-psd --example from_image -- [--editable] <input-image> [output.psd]"
|
| 67 |
+
.to_string()
|
| 68 |
+
})?;
|
| 69 |
+
let output = output.unwrap_or_else(|| input.with_extension("psd"));
|
| 70 |
+
Ok((input, output, editable))
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
fn apply_demo_layers(document: &mut Document) {
|
| 74 |
+
let width = document.width.max(document.image.width());
|
| 75 |
+
let height = document.height.max(document.image.height());
|
| 76 |
+
document.width = width;
|
| 77 |
+
document.height = height;
|
| 78 |
+
|
| 79 |
+
let base = document.image.to_rgba8();
|
| 80 |
+
let inpainted = softened_copy(&base);
|
| 81 |
+
let segment = build_segment(width, height);
|
| 82 |
+
let brush = build_brush(width, height);
|
| 83 |
+
|
| 84 |
+
document.inpainted = Some(to_serializable_rgba(inpainted));
|
| 85 |
+
document.segment = Some(to_serializable_luma(segment));
|
| 86 |
+
document.brush_layer = Some(to_serializable_rgba(brush));
|
| 87 |
+
document.rendered = None;
|
| 88 |
+
document.text_blocks = vec![
|
| 89 |
+
TextBlock {
|
| 90 |
+
id: "top-callout".to_string(),
|
| 91 |
+
x: (width as f32 * 0.08).floor(),
|
| 92 |
+
y: (height as f32 * 0.08).floor(),
|
| 93 |
+
width: width.min(420).max(180) as f32,
|
| 94 |
+
height: (height / 5).max(72) as f32,
|
| 95 |
+
translation: Some("Generated from your image".to_string()),
|
| 96 |
+
style: Some(TextStyle {
|
| 97 |
+
font_families: vec!["ArialMT".to_string()],
|
| 98 |
+
font_size: Some((height as f32 * 0.06).max(18.0)),
|
| 99 |
+
color: [24, 24, 28, 255],
|
| 100 |
+
effect: Some(TextShaderEffect {
|
| 101 |
+
italic: false,
|
| 102 |
+
bold: true,
|
| 103 |
+
}),
|
| 104 |
+
stroke: None,
|
| 105 |
+
text_align: Some(TextAlign::Center),
|
| 106 |
+
}),
|
| 107 |
+
lock_layout_box: true,
|
| 108 |
+
..Default::default()
|
| 109 |
+
},
|
| 110 |
+
TextBlock {
|
| 111 |
+
id: "side-note".to_string(),
|
| 112 |
+
x: (width as f32 * 0.78).floor(),
|
| 113 |
+
y: (height as f32 * 0.18).floor(),
|
| 114 |
+
width: (width as f32 * 0.14).max(64.0),
|
| 115 |
+
height: (height as f32 * 0.42).max(180.0),
|
| 116 |
+
translation: Some("\u{7e26}\u{66f8}\u{304d}".to_string()),
|
| 117 |
+
source_direction: Some(TextDirection::Vertical),
|
| 118 |
+
style: Some(TextStyle {
|
| 119 |
+
font_families: vec!["YuGothic-Regular".to_string()],
|
| 120 |
+
font_size: Some((height as f32 * 0.05).max(18.0)),
|
| 121 |
+
color: [26, 54, 96, 255],
|
| 122 |
+
effect: None,
|
| 123 |
+
stroke: None,
|
| 124 |
+
text_align: None,
|
| 125 |
+
}),
|
| 126 |
+
font_prediction: Some(FontPrediction {
|
| 127 |
+
named_fonts: vec![NamedFontPrediction {
|
| 128 |
+
index: 0,
|
| 129 |
+
name: "YuGothic-Regular".to_string(),
|
| 130 |
+
language: Some("ja".to_string()),
|
| 131 |
+
probability: 0.9,
|
| 132 |
+
serif: false,
|
| 133 |
+
}],
|
| 134 |
+
direction: TextDirection::Vertical,
|
| 135 |
+
text_color: [26, 54, 96],
|
| 136 |
+
font_size_px: (height as f32 * 0.05).max(18.0),
|
| 137 |
+
angle_deg: 6.0,
|
| 138 |
+
..Default::default()
|
| 139 |
+
}),
|
| 140 |
+
lock_layout_box: true,
|
| 141 |
+
..Default::default()
|
| 142 |
+
},
|
| 143 |
+
];
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
fn softened_copy(base: &RgbaImage) -> RgbaImage {
|
| 147 |
+
let mut out = base.clone();
|
| 148 |
+
for pixel in out.pixels_mut() {
|
| 149 |
+
pixel.0[0] = ((u16::from(pixel.0[0]) * 9 + 255) / 10) as u8;
|
| 150 |
+
pixel.0[1] = ((u16::from(pixel.0[1]) * 9 + 248) / 10) as u8;
|
| 151 |
+
pixel.0[2] = ((u16::from(pixel.0[2]) * 9 + 240) / 10) as u8;
|
| 152 |
+
}
|
| 153 |
+
out
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
fn build_segment(width: u32, height: u32) -> GrayImage {
|
| 157 |
+
let mut image = GrayImage::new(width, height);
|
| 158 |
+
let x0 = width / 10;
|
| 159 |
+
let x1 = width.saturating_mul(9) / 10;
|
| 160 |
+
let y0 = height / 12;
|
| 161 |
+
let y1 = height.saturating_mul(11) / 12;
|
| 162 |
+
|
| 163 |
+
for y in 0..height {
|
| 164 |
+
for x in 0..width {
|
| 165 |
+
let bright = (x0..x1).contains(&x) && (y0..y1).contains(&y);
|
| 166 |
+
image.put_pixel(x, y, Luma([if bright { 150 } else { 24 }]));
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
image
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
fn build_brush(width: u32, height: u32) -> RgbaImage {
|
| 173 |
+
let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
|
| 174 |
+
let left = width / 8;
|
| 175 |
+
let right = width.saturating_mul(7) / 8;
|
| 176 |
+
let top = height.saturating_mul(3) / 5;
|
| 177 |
+
|
| 178 |
+
for x in left..right {
|
| 179 |
+
for offset in 0..6 {
|
| 180 |
+
let y = top + offset;
|
| 181 |
+
if y < height {
|
| 182 |
+
image.put_pixel(x, y, Rgba([255, 0, 128, 120]));
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
image
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
fn render_demo_text(document: &mut Document) -> Result<(), Box<dyn Error>> {
|
| 191 |
+
Renderer::new()?.render(document, None, TextShaderEffect::none(), None, None)?;
|
| 192 |
+
Ok(())
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
fn to_serializable_rgba(image: RgbaImage) -> SerializableDynamicImage {
|
| 196 |
+
SerializableDynamicImage(DynamicImage::ImageRgba8(image))
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
fn to_serializable_luma(image: GrayImage) -> SerializableDynamicImage {
|
| 200 |
+
SerializableDynamicImage(DynamicImage::ImageLuma8(image))
|
| 201 |
+
}
|
koharu-psd/examples/full_fixture.rs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::{error::Error, fs, path::PathBuf};
|
| 2 |
+
|
| 3 |
+
use image::{DynamicImage, GrayImage, Luma, Rgba, RgbaImage};
|
| 4 |
+
use koharu_psd::{PsdExportOptions, TextLayerMode, export_document};
|
| 5 |
+
use koharu_renderer::facade::Renderer;
|
| 6 |
+
use koharu_types::{
|
| 7 |
+
Document, FontPrediction, NamedFontPrediction, SerializableDynamicImage, TextAlign, TextBlock,
|
| 8 |
+
TextDirection, TextShaderEffect, TextStyle,
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
fn main() -> Result<(), Box<dyn Error>> {
|
| 12 |
+
let (output, editable) = parse_args()?;
|
| 13 |
+
|
| 14 |
+
if let Some(parent) = output.parent() {
|
| 15 |
+
fs::create_dir_all(parent)?;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
let mut document = build_document();
|
| 19 |
+
render_demo_text(&mut document)?;
|
| 20 |
+
|
| 21 |
+
let options = PsdExportOptions {
|
| 22 |
+
text_layer_mode: if editable {
|
| 23 |
+
TextLayerMode::Editable
|
| 24 |
+
} else {
|
| 25 |
+
TextLayerMode::Rasterized
|
| 26 |
+
},
|
| 27 |
+
..Default::default()
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
let bytes = export_document(&document, &options)?;
|
| 31 |
+
fs::write(&output, bytes)?;
|
| 32 |
+
|
| 33 |
+
println!(
|
| 34 |
+
"mode {}",
|
| 35 |
+
match options.text_layer_mode {
|
| 36 |
+
TextLayerMode::Rasterized => "rasterized",
|
| 37 |
+
TextLayerMode::Editable => "editable",
|
| 38 |
+
}
|
| 39 |
+
);
|
| 40 |
+
println!("wrote {}", output.display());
|
| 41 |
+
Ok(())
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
fn parse_args() -> Result<(PathBuf, bool), Box<dyn Error>> {
|
| 45 |
+
let mut output = None;
|
| 46 |
+
let mut editable = false;
|
| 47 |
+
|
| 48 |
+
for arg in std::env::args().skip(1) {
|
| 49 |
+
match arg.as_str() {
|
| 50 |
+
"--editable" => editable = true,
|
| 51 |
+
other if output.is_none() => output = Some(PathBuf::from(other)),
|
| 52 |
+
_ => {
|
| 53 |
+
return Err(
|
| 54 |
+
"usage: cargo run -p koharu-psd --example full_fixture -- [--editable] [output.psd]"
|
| 55 |
+
.into(),
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
Ok((
|
| 62 |
+
output.unwrap_or_else(|| PathBuf::from("target/koharu-psd/full-fixture.psd")),
|
| 63 |
+
editable,
|
| 64 |
+
))
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
fn build_document() -> Document {
|
| 68 |
+
let width = 960;
|
| 69 |
+
let height = 640;
|
| 70 |
+
|
| 71 |
+
let original = build_original(width, height);
|
| 72 |
+
let inpainted = build_inpainted(&original);
|
| 73 |
+
let segment = build_segment(width, height);
|
| 74 |
+
let brush_layer = build_brush_layer(width, height);
|
| 75 |
+
|
| 76 |
+
Document {
|
| 77 |
+
id: "full-fixture".to_string(),
|
| 78 |
+
path: PathBuf::from("full-fixture.png"),
|
| 79 |
+
name: "full-fixture".to_string(),
|
| 80 |
+
image: to_serializable_rgba(original),
|
| 81 |
+
width,
|
| 82 |
+
height,
|
| 83 |
+
revision: 0,
|
| 84 |
+
text_blocks: vec![
|
| 85 |
+
TextBlock {
|
| 86 |
+
id: "hero-title".to_string(),
|
| 87 |
+
x: 130.0,
|
| 88 |
+
y: 84.0,
|
| 89 |
+
width: 300.0,
|
| 90 |
+
height: 110.0,
|
| 91 |
+
translation: Some("Editable PSD text".to_string()),
|
| 92 |
+
style: Some(TextStyle {
|
| 93 |
+
font_families: vec!["ArialMT".to_string()],
|
| 94 |
+
font_size: Some(44.0),
|
| 95 |
+
color: [25, 24, 28, 255],
|
| 96 |
+
effect: Some(TextShaderEffect {
|
| 97 |
+
italic: false,
|
| 98 |
+
bold: true,
|
| 99 |
+
}),
|
| 100 |
+
stroke: None,
|
| 101 |
+
text_align: Some(TextAlign::Center),
|
| 102 |
+
}),
|
| 103 |
+
lock_layout_box: true,
|
| 104 |
+
..Default::default()
|
| 105 |
+
},
|
| 106 |
+
TextBlock {
|
| 107 |
+
id: "side-note".to_string(),
|
| 108 |
+
x: 760.0,
|
| 109 |
+
y: 170.0,
|
| 110 |
+
width: 110.0,
|
| 111 |
+
height: 250.0,
|
| 112 |
+
translation: Some("\u{7e26}\u{66f8}\u{304d}".to_string()),
|
| 113 |
+
source_direction: Some(TextDirection::Vertical),
|
| 114 |
+
style: Some(TextStyle {
|
| 115 |
+
font_families: vec!["YuGothic-Regular".to_string()],
|
| 116 |
+
font_size: Some(36.0),
|
| 117 |
+
color: [28, 50, 92, 255],
|
| 118 |
+
effect: None,
|
| 119 |
+
stroke: None,
|
| 120 |
+
text_align: None,
|
| 121 |
+
}),
|
| 122 |
+
font_prediction: Some(FontPrediction {
|
| 123 |
+
named_fonts: vec![NamedFontPrediction {
|
| 124 |
+
index: 0,
|
| 125 |
+
name: "YuGothic-Regular".to_string(),
|
| 126 |
+
language: Some("ja".to_string()),
|
| 127 |
+
probability: 0.94,
|
| 128 |
+
serif: false,
|
| 129 |
+
}],
|
| 130 |
+
direction: TextDirection::Vertical,
|
| 131 |
+
text_color: [28, 50, 92],
|
| 132 |
+
font_size_px: 36.0,
|
| 133 |
+
angle_deg: 4.0,
|
| 134 |
+
..Default::default()
|
| 135 |
+
}),
|
| 136 |
+
lock_layout_box: true,
|
| 137 |
+
..Default::default()
|
| 138 |
+
},
|
| 139 |
+
],
|
| 140 |
+
segment: Some(to_serializable_luma(segment)),
|
| 141 |
+
inpainted: Some(to_serializable_rgba(inpainted)),
|
| 142 |
+
rendered: None,
|
| 143 |
+
brush_layer: Some(to_serializable_rgba(brush_layer)),
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
fn build_original(width: u32, height: u32) -> RgbaImage {
|
| 148 |
+
let mut image = RgbaImage::new(width, height);
|
| 149 |
+
for y in 0..height {
|
| 150 |
+
for x in 0..width {
|
| 151 |
+
let warm = 210u8.saturating_add((x / 32 % 2) as u8 * 12);
|
| 152 |
+
let cool = 190u8.saturating_add((y / 24 % 2) as u8 * 18);
|
| 153 |
+
let blue = 170u8.saturating_add(((x + y) / 48 % 2) as u8 * 20);
|
| 154 |
+
image.put_pixel(x, y, Rgba([warm, cool, blue, 255]));
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
image
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
fn build_inpainted(original: &RgbaImage) -> RgbaImage {
|
| 161 |
+
let mut image = original.clone();
|
| 162 |
+
for y in 210..455 {
|
| 163 |
+
for x in 250..700 {
|
| 164 |
+
image.put_pixel(x, y, Rgba([244, 240, 230, 255]));
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
image
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
fn build_segment(width: u32, height: u32) -> GrayImage {
|
| 171 |
+
let mut image = GrayImage::new(width, height);
|
| 172 |
+
for y in 0..height {
|
| 173 |
+
for x in 0..width {
|
| 174 |
+
let inside = (120..440).contains(&x) && (60..220).contains(&y)
|
| 175 |
+
|| (730..890).contains(&x) && (150..470).contains(&y)
|
| 176 |
+
|| (240..720).contains(&x) && (210..470).contains(&y);
|
| 177 |
+
image.put_pixel(x, y, Luma([if inside { 180 } else { 32 }]));
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
image
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
fn build_brush_layer(width: u32, height: u32) -> RgbaImage {
|
| 184 |
+
let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
|
| 185 |
+
for x in 240..720 {
|
| 186 |
+
for thickness in 0..8 {
|
| 187 |
+
image.put_pixel(x, 208 + thickness, Rgba([255, 0, 128, 150]));
|
| 188 |
+
image.put_pixel(x, 468 - thickness, Rgba([255, 0, 128, 150]));
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
for y in 208..468 {
|
| 192 |
+
for thickness in 0..8 {
|
| 193 |
+
image.put_pixel(240 + thickness, y, Rgba([255, 0, 128, 150]));
|
| 194 |
+
image.put_pixel(712 - thickness, y, Rgba([255, 0, 128, 150]));
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
image
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
fn render_demo_text(document: &mut Document) -> Result<(), Box<dyn Error>> {
|
| 201 |
+
Renderer::new()?.render(document, None, TextShaderEffect::none(), None, None)?;
|
| 202 |
+
Ok(())
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
fn to_serializable_rgba(image: RgbaImage) -> SerializableDynamicImage {
|
| 206 |
+
SerializableDynamicImage(DynamicImage::ImageRgba8(image))
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
fn to_serializable_luma(image: GrayImage) -> SerializableDynamicImage {
|
| 210 |
+
SerializableDynamicImage(DynamicImage::ImageLuma8(image))
|
| 211 |
+
}
|
koharu-psd/src/descriptor.rs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::{error::PsdExportError, writer::PsdWriter};
|
| 2 |
+
|
| 3 |
+
#[derive(Debug, Clone)]
|
| 4 |
+
pub struct DescriptorObject {
|
| 5 |
+
pub name: String,
|
| 6 |
+
pub class_id: String,
|
| 7 |
+
pub items: Vec<DescriptorItem>,
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
#[derive(Debug, Clone)]
|
| 11 |
+
pub struct DescriptorItem {
|
| 12 |
+
pub key: String,
|
| 13 |
+
pub value: DescriptorValue,
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
#[derive(Debug, Clone)]
|
| 17 |
+
pub enum DescriptorValue {
|
| 18 |
+
Text(String),
|
| 19 |
+
Enum { type_id: String, value: String },
|
| 20 |
+
Integer(i32),
|
| 21 |
+
Double(f64),
|
| 22 |
+
UnitPixels(f64),
|
| 23 |
+
Raw(Vec<u8>),
|
| 24 |
+
Object(DescriptorObject),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
impl DescriptorObject {
|
| 28 |
+
pub fn new(name: impl Into<String>, class_id: impl Into<String>) -> Self {
|
| 29 |
+
Self {
|
| 30 |
+
name: name.into(),
|
| 31 |
+
class_id: class_id.into(),
|
| 32 |
+
items: Vec::new(),
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
pub fn with_item(mut self, key: impl Into<String>, value: DescriptorValue) -> Self {
|
| 37 |
+
self.items.push(DescriptorItem {
|
| 38 |
+
key: key.into(),
|
| 39 |
+
value,
|
| 40 |
+
});
|
| 41 |
+
self
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
pub fn write_versioned_descriptor(
|
| 46 |
+
writer: &mut PsdWriter,
|
| 47 |
+
descriptor: &DescriptorObject,
|
| 48 |
+
) -> Result<(), PsdExportError> {
|
| 49 |
+
writer.write_u32(16);
|
| 50 |
+
write_descriptor_object(writer, descriptor)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
pub fn bounds_descriptor(
|
| 54 |
+
class_id: &str,
|
| 55 |
+
left: f64,
|
| 56 |
+
top: f64,
|
| 57 |
+
right: f64,
|
| 58 |
+
bottom: f64,
|
| 59 |
+
) -> DescriptorObject {
|
| 60 |
+
DescriptorObject::new("", class_id)
|
| 61 |
+
.with_item("Left", DescriptorValue::UnitPixels(left))
|
| 62 |
+
.with_item("Top ", DescriptorValue::UnitPixels(top))
|
| 63 |
+
.with_item("Rght", DescriptorValue::UnitPixels(right))
|
| 64 |
+
.with_item("Btom", DescriptorValue::UnitPixels(bottom))
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
fn write_descriptor_object(
|
| 68 |
+
writer: &mut PsdWriter,
|
| 69 |
+
descriptor: &DescriptorObject,
|
| 70 |
+
) -> Result<(), PsdExportError> {
|
| 71 |
+
validate_descriptor_id(&descriptor.class_id)?;
|
| 72 |
+
writer.write_unicode_string_with_padding(&descriptor.name);
|
| 73 |
+
writer.write_ascii_or_class_id(&descriptor.class_id);
|
| 74 |
+
writer.write_u32(descriptor.items.len() as u32);
|
| 75 |
+
|
| 76 |
+
for item in &descriptor.items {
|
| 77 |
+
validate_descriptor_key(&item.key)?;
|
| 78 |
+
writer.write_ascii_or_class_id(&item.key);
|
| 79 |
+
write_descriptor_value(writer, &item.value)?;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
Ok(())
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
fn write_descriptor_value(
|
| 86 |
+
writer: &mut PsdWriter,
|
| 87 |
+
value: &DescriptorValue,
|
| 88 |
+
) -> Result<(), PsdExportError> {
|
| 89 |
+
match value {
|
| 90 |
+
DescriptorValue::Text(text) => {
|
| 91 |
+
writer.write_signature("TEXT");
|
| 92 |
+
writer.write_unicode_string_with_padding(text);
|
| 93 |
+
}
|
| 94 |
+
DescriptorValue::Enum { type_id, value } => {
|
| 95 |
+
validate_descriptor_id(type_id)?;
|
| 96 |
+
validate_descriptor_id(value)?;
|
| 97 |
+
writer.write_signature("enum");
|
| 98 |
+
writer.write_ascii_or_class_id(type_id);
|
| 99 |
+
writer.write_ascii_or_class_id(value);
|
| 100 |
+
}
|
| 101 |
+
DescriptorValue::Integer(number) => {
|
| 102 |
+
writer.write_signature("long");
|
| 103 |
+
writer.write_i32(*number);
|
| 104 |
+
}
|
| 105 |
+
DescriptorValue::Double(number) => {
|
| 106 |
+
writer.write_signature("doub");
|
| 107 |
+
writer.write_f64(*number);
|
| 108 |
+
}
|
| 109 |
+
DescriptorValue::UnitPixels(number) => {
|
| 110 |
+
writer.write_signature("UntF");
|
| 111 |
+
writer.write_signature("#Pxl");
|
| 112 |
+
writer.write_f64(*number);
|
| 113 |
+
}
|
| 114 |
+
DescriptorValue::Raw(bytes) => {
|
| 115 |
+
writer.write_signature("tdta");
|
| 116 |
+
writer.write_u32(bytes.len() as u32);
|
| 117 |
+
writer.write_bytes(bytes);
|
| 118 |
+
}
|
| 119 |
+
DescriptorValue::Object(object) => {
|
| 120 |
+
writer.write_signature("Objc");
|
| 121 |
+
write_descriptor_object(writer, object)?;
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
Ok(())
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
fn validate_descriptor_id(value: &str) -> Result<(), PsdExportError> {
|
| 129 |
+
if value.is_empty() {
|
| 130 |
+
return Err(PsdExportError::InvalidDescriptor(
|
| 131 |
+
"descriptor IDs must not be empty".to_string(),
|
| 132 |
+
));
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if !value.is_ascii() {
|
| 136 |
+
return Err(PsdExportError::InvalidDescriptor(format!(
|
| 137 |
+
"descriptor IDs must be ASCII: {value:?}"
|
| 138 |
+
)));
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
Ok(())
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
fn validate_descriptor_key(value: &str) -> Result<(), PsdExportError> {
|
| 145 |
+
validate_descriptor_id(value)
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
#[cfg(test)]
|
| 149 |
+
mod tests {
|
| 150 |
+
use super::{DescriptorObject, DescriptorValue, bounds_descriptor, write_versioned_descriptor};
|
| 151 |
+
use crate::writer::PsdWriter;
|
| 152 |
+
|
| 153 |
+
#[test]
|
| 154 |
+
fn versioned_descriptor_writes_expected_signatures() {
|
| 155 |
+
let descriptor = DescriptorObject::new("", "TxLr")
|
| 156 |
+
.with_item("Txt ", DescriptorValue::Text("HELLO".to_string()))
|
| 157 |
+
.with_item(
|
| 158 |
+
"Ornt",
|
| 159 |
+
DescriptorValue::Enum {
|
| 160 |
+
type_id: "Ornt".to_string(),
|
| 161 |
+
value: "Hrzn".to_string(),
|
| 162 |
+
},
|
| 163 |
+
)
|
| 164 |
+
.with_item("TextIndex", DescriptorValue::Integer(1))
|
| 165 |
+
.with_item(
|
| 166 |
+
"bounds",
|
| 167 |
+
DescriptorValue::Object(bounds_descriptor("bounds", 1.0, 2.0, 3.0, 4.0)),
|
| 168 |
+
);
|
| 169 |
+
|
| 170 |
+
let mut writer = PsdWriter::new();
|
| 171 |
+
write_versioned_descriptor(&mut writer, &descriptor).expect("descriptor");
|
| 172 |
+
let bytes = writer.into_inner();
|
| 173 |
+
|
| 174 |
+
assert_eq!(&bytes[..4], &[0, 0, 0, 16]);
|
| 175 |
+
assert!(bytes.windows(4).any(|window| window == b"TxLr"));
|
| 176 |
+
assert!(bytes.windows(4).any(|window| window == b"TEXT"));
|
| 177 |
+
assert!(bytes.windows(4).any(|window| window == b"enum"));
|
| 178 |
+
assert!(bytes.windows(4).any(|window| window == b"long"));
|
| 179 |
+
assert!(bytes.windows(4).any(|window| window == b"UntF"));
|
| 180 |
+
}
|
| 181 |
+
}
|
koharu-psd/src/engine_data.rs
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 2 |
+
pub enum TextOrientation {
|
| 3 |
+
Horizontal,
|
| 4 |
+
Vertical,
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 8 |
+
pub enum TextJustification {
|
| 9 |
+
Left,
|
| 10 |
+
Center,
|
| 11 |
+
Right,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#[derive(Debug, Clone)]
|
| 15 |
+
pub struct TextEngineSpec {
|
| 16 |
+
pub text: String,
|
| 17 |
+
pub font_name: String,
|
| 18 |
+
pub font_size: f64,
|
| 19 |
+
pub color: [u8; 4],
|
| 20 |
+
pub faux_bold: bool,
|
| 21 |
+
pub faux_italic: bool,
|
| 22 |
+
pub orientation: TextOrientation,
|
| 23 |
+
pub justification: TextJustification,
|
| 24 |
+
pub box_width: f64,
|
| 25 |
+
pub box_height: f64,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[derive(Debug, Clone)]
|
| 29 |
+
enum EngineValue {
|
| 30 |
+
Int(i32),
|
| 31 |
+
Float(f64),
|
| 32 |
+
Bool(bool),
|
| 33 |
+
String(String),
|
| 34 |
+
Array(Vec<EngineValue>),
|
| 35 |
+
Dict(Vec<(String, EngineValue)>),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
pub fn encode_engine_data(spec: &TextEngineSpec) -> Vec<u8> {
|
| 39 |
+
let text = normalize_text(&spec.text);
|
| 40 |
+
let paragraph_lengths = paragraph_run_lengths(&text);
|
| 41 |
+
let total_length = utf16_len(&text) as i32;
|
| 42 |
+
|
| 43 |
+
let font_name = if spec.font_name.trim().is_empty() {
|
| 44 |
+
"ArialMT"
|
| 45 |
+
} else {
|
| 46 |
+
spec.font_name.trim()
|
| 47 |
+
};
|
| 48 |
+
let font_index = if font_name == "AdobeInvisFont" { 0 } else { 1 };
|
| 49 |
+
let writing_direction = match spec.orientation {
|
| 50 |
+
TextOrientation::Horizontal => 0,
|
| 51 |
+
TextOrientation::Vertical => 2,
|
| 52 |
+
};
|
| 53 |
+
let procession = match spec.orientation {
|
| 54 |
+
TextOrientation::Horizontal => 0,
|
| 55 |
+
TextOrientation::Vertical => 1,
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
let paragraph_properties = paragraph_properties(spec.justification);
|
| 59 |
+
let base_style_sheet = base_style_sheet(font_index);
|
| 60 |
+
let style_run_sheet = style_run_sheet(spec, font_index);
|
| 61 |
+
let font_set = font_set(font_name);
|
| 62 |
+
|
| 63 |
+
let root = EngineValue::Dict(vec![
|
| 64 |
+
(
|
| 65 |
+
"EngineDict".to_string(),
|
| 66 |
+
EngineValue::Dict(vec![
|
| 67 |
+
(
|
| 68 |
+
"Editor".to_string(),
|
| 69 |
+
EngineValue::Dict(vec![("Text".to_string(), EngineValue::String(text))]),
|
| 70 |
+
),
|
| 71 |
+
(
|
| 72 |
+
"ParagraphRun".to_string(),
|
| 73 |
+
EngineValue::Dict(vec![
|
| 74 |
+
(
|
| 75 |
+
"DefaultRunData".to_string(),
|
| 76 |
+
EngineValue::Dict(vec![
|
| 77 |
+
(
|
| 78 |
+
"ParagraphSheet".to_string(),
|
| 79 |
+
EngineValue::Dict(vec![
|
| 80 |
+
("DefaultStyleSheet".to_string(), EngineValue::Int(0)),
|
| 81 |
+
("Properties".to_string(), EngineValue::Dict(Vec::new())),
|
| 82 |
+
]),
|
| 83 |
+
),
|
| 84 |
+
(
|
| 85 |
+
"Adjustments".to_string(),
|
| 86 |
+
EngineValue::Dict(vec![
|
| 87 |
+
(
|
| 88 |
+
"Axis".to_string(),
|
| 89 |
+
EngineValue::Array(vec![
|
| 90 |
+
EngineValue::Float(1.0),
|
| 91 |
+
EngineValue::Float(0.0),
|
| 92 |
+
EngineValue::Float(1.0),
|
| 93 |
+
]),
|
| 94 |
+
),
|
| 95 |
+
(
|
| 96 |
+
"XY".to_string(),
|
| 97 |
+
EngineValue::Array(vec![
|
| 98 |
+
EngineValue::Float(0.0),
|
| 99 |
+
EngineValue::Float(0.0),
|
| 100 |
+
]),
|
| 101 |
+
),
|
| 102 |
+
]),
|
| 103 |
+
),
|
| 104 |
+
]),
|
| 105 |
+
),
|
| 106 |
+
(
|
| 107 |
+
"RunArray".to_string(),
|
| 108 |
+
EngineValue::Array(
|
| 109 |
+
paragraph_lengths
|
| 110 |
+
.iter()
|
| 111 |
+
.map(|_| {
|
| 112 |
+
EngineValue::Dict(vec![
|
| 113 |
+
(
|
| 114 |
+
"ParagraphSheet".to_string(),
|
| 115 |
+
EngineValue::Dict(vec![
|
| 116 |
+
(
|
| 117 |
+
"DefaultStyleSheet".to_string(),
|
| 118 |
+
EngineValue::Int(0),
|
| 119 |
+
),
|
| 120 |
+
(
|
| 121 |
+
"Properties".to_string(),
|
| 122 |
+
EngineValue::Dict(
|
| 123 |
+
paragraph_properties.clone(),
|
| 124 |
+
),
|
| 125 |
+
),
|
| 126 |
+
]),
|
| 127 |
+
),
|
| 128 |
+
(
|
| 129 |
+
"Adjustments".to_string(),
|
| 130 |
+
EngineValue::Dict(vec![
|
| 131 |
+
(
|
| 132 |
+
"Axis".to_string(),
|
| 133 |
+
EngineValue::Array(vec![
|
| 134 |
+
EngineValue::Float(1.0),
|
| 135 |
+
EngineValue::Float(0.0),
|
| 136 |
+
EngineValue::Float(1.0),
|
| 137 |
+
]),
|
| 138 |
+
),
|
| 139 |
+
(
|
| 140 |
+
"XY".to_string(),
|
| 141 |
+
EngineValue::Array(vec![
|
| 142 |
+
EngineValue::Float(0.0),
|
| 143 |
+
EngineValue::Float(0.0),
|
| 144 |
+
]),
|
| 145 |
+
),
|
| 146 |
+
]),
|
| 147 |
+
),
|
| 148 |
+
])
|
| 149 |
+
})
|
| 150 |
+
.collect(),
|
| 151 |
+
),
|
| 152 |
+
),
|
| 153 |
+
(
|
| 154 |
+
"RunLengthArray".to_string(),
|
| 155 |
+
EngineValue::Array(
|
| 156 |
+
paragraph_lengths
|
| 157 |
+
.iter()
|
| 158 |
+
.copied()
|
| 159 |
+
.map(EngineValue::Int)
|
| 160 |
+
.collect(),
|
| 161 |
+
),
|
| 162 |
+
),
|
| 163 |
+
("IsJoinable".to_string(), EngineValue::Int(1)),
|
| 164 |
+
]),
|
| 165 |
+
),
|
| 166 |
+
(
|
| 167 |
+
"StyleRun".to_string(),
|
| 168 |
+
EngineValue::Dict(vec![
|
| 169 |
+
(
|
| 170 |
+
"DefaultRunData".to_string(),
|
| 171 |
+
EngineValue::Dict(vec![(
|
| 172 |
+
"StyleSheet".to_string(),
|
| 173 |
+
EngineValue::Dict(vec![(
|
| 174 |
+
"StyleSheetData".to_string(),
|
| 175 |
+
EngineValue::Dict(Vec::new()),
|
| 176 |
+
)]),
|
| 177 |
+
)]),
|
| 178 |
+
),
|
| 179 |
+
(
|
| 180 |
+
"RunArray".to_string(),
|
| 181 |
+
EngineValue::Array(vec![EngineValue::Dict(vec![(
|
| 182 |
+
"StyleSheet".to_string(),
|
| 183 |
+
EngineValue::Dict(vec![(
|
| 184 |
+
"StyleSheetData".to_string(),
|
| 185 |
+
EngineValue::Dict(style_run_sheet.clone()),
|
| 186 |
+
)]),
|
| 187 |
+
)])]),
|
| 188 |
+
),
|
| 189 |
+
(
|
| 190 |
+
"RunLengthArray".to_string(),
|
| 191 |
+
EngineValue::Array(vec![EngineValue::Int(total_length)]),
|
| 192 |
+
),
|
| 193 |
+
("IsJoinable".to_string(), EngineValue::Int(2)),
|
| 194 |
+
]),
|
| 195 |
+
),
|
| 196 |
+
(
|
| 197 |
+
"GridInfo".to_string(),
|
| 198 |
+
EngineValue::Dict(vec![
|
| 199 |
+
("GridIsOn".to_string(), EngineValue::Bool(false)),
|
| 200 |
+
("ShowGrid".to_string(), EngineValue::Bool(false)),
|
| 201 |
+
("GridSize".to_string(), EngineValue::Float(18.0)),
|
| 202 |
+
("GridLeading".to_string(), EngineValue::Float(22.0)),
|
| 203 |
+
(
|
| 204 |
+
"GridColor".to_string(),
|
| 205 |
+
EngineValue::Dict(color_type_values([0, 0, 255, 255])),
|
| 206 |
+
),
|
| 207 |
+
(
|
| 208 |
+
"GridLeadingFillColor".to_string(),
|
| 209 |
+
EngineValue::Dict(color_type_values([0, 0, 255, 255])),
|
| 210 |
+
),
|
| 211 |
+
(
|
| 212 |
+
"AlignLineHeightToGridFlags".to_string(),
|
| 213 |
+
EngineValue::Bool(false),
|
| 214 |
+
),
|
| 215 |
+
]),
|
| 216 |
+
),
|
| 217 |
+
("AntiAlias".to_string(), EngineValue::Int(4)),
|
| 218 |
+
(
|
| 219 |
+
"UseFractionalGlyphWidths".to_string(),
|
| 220 |
+
EngineValue::Bool(true),
|
| 221 |
+
),
|
| 222 |
+
(
|
| 223 |
+
"Rendered".to_string(),
|
| 224 |
+
EngineValue::Dict(vec![
|
| 225 |
+
("Version".to_string(), EngineValue::Int(1)),
|
| 226 |
+
(
|
| 227 |
+
"Shapes".to_string(),
|
| 228 |
+
EngineValue::Dict(vec![
|
| 229 |
+
(
|
| 230 |
+
"WritingDirection".to_string(),
|
| 231 |
+
EngineValue::Int(writing_direction),
|
| 232 |
+
),
|
| 233 |
+
(
|
| 234 |
+
"Children".to_string(),
|
| 235 |
+
EngineValue::Array(vec![EngineValue::Dict(vec![
|
| 236 |
+
("ShapeType".to_string(), EngineValue::Int(1)),
|
| 237 |
+
("Procession".to_string(), EngineValue::Int(procession)),
|
| 238 |
+
(
|
| 239 |
+
"Lines".to_string(),
|
| 240 |
+
EngineValue::Dict(vec![
|
| 241 |
+
(
|
| 242 |
+
"WritingDirection".to_string(),
|
| 243 |
+
EngineValue::Int(writing_direction),
|
| 244 |
+
),
|
| 245 |
+
(
|
| 246 |
+
"Children".to_string(),
|
| 247 |
+
EngineValue::Array(Vec::new()),
|
| 248 |
+
),
|
| 249 |
+
]),
|
| 250 |
+
),
|
| 251 |
+
(
|
| 252 |
+
"Cookie".to_string(),
|
| 253 |
+
EngineValue::Dict(vec![(
|
| 254 |
+
"Photoshop".to_string(),
|
| 255 |
+
EngineValue::Dict(vec![
|
| 256 |
+
("ShapeType".to_string(), EngineValue::Int(1)),
|
| 257 |
+
(
|
| 258 |
+
"BoxBounds".to_string(),
|
| 259 |
+
EngineValue::Array(vec![
|
| 260 |
+
EngineValue::Float(0.0),
|
| 261 |
+
EngineValue::Float(0.0),
|
| 262 |
+
EngineValue::Float(spec.box_width),
|
| 263 |
+
EngineValue::Float(spec.box_height),
|
| 264 |
+
]),
|
| 265 |
+
),
|
| 266 |
+
(
|
| 267 |
+
"Base".to_string(),
|
| 268 |
+
EngineValue::Dict(vec![
|
| 269 |
+
(
|
| 270 |
+
"ShapeType".to_string(),
|
| 271 |
+
EngineValue::Int(1),
|
| 272 |
+
),
|
| 273 |
+
(
|
| 274 |
+
"TransformPoint0".to_string(),
|
| 275 |
+
EngineValue::Array(vec![
|
| 276 |
+
EngineValue::Float(1.0),
|
| 277 |
+
EngineValue::Float(0.0),
|
| 278 |
+
]),
|
| 279 |
+
),
|
| 280 |
+
(
|
| 281 |
+
"TransformPoint1".to_string(),
|
| 282 |
+
EngineValue::Array(vec![
|
| 283 |
+
EngineValue::Float(0.0),
|
| 284 |
+
EngineValue::Float(1.0),
|
| 285 |
+
]),
|
| 286 |
+
),
|
| 287 |
+
(
|
| 288 |
+
"TransformPoint2".to_string(),
|
| 289 |
+
EngineValue::Array(vec![
|
| 290 |
+
EngineValue::Float(0.0),
|
| 291 |
+
EngineValue::Float(0.0),
|
| 292 |
+
]),
|
| 293 |
+
),
|
| 294 |
+
]),
|
| 295 |
+
),
|
| 296 |
+
]),
|
| 297 |
+
)]),
|
| 298 |
+
),
|
| 299 |
+
])]),
|
| 300 |
+
),
|
| 301 |
+
]),
|
| 302 |
+
),
|
| 303 |
+
]),
|
| 304 |
+
),
|
| 305 |
+
]),
|
| 306 |
+
),
|
| 307 |
+
(
|
| 308 |
+
"ResourceDict".to_string(),
|
| 309 |
+
EngineValue::Dict(resource_dict(
|
| 310 |
+
font_set.clone(),
|
| 311 |
+
paragraph_properties.clone(),
|
| 312 |
+
base_style_sheet.clone(),
|
| 313 |
+
)),
|
| 314 |
+
),
|
| 315 |
+
(
|
| 316 |
+
"DocumentResources".to_string(),
|
| 317 |
+
EngineValue::Dict(resource_dict(
|
| 318 |
+
font_set,
|
| 319 |
+
paragraph_properties,
|
| 320 |
+
base_style_sheet,
|
| 321 |
+
)),
|
| 322 |
+
),
|
| 323 |
+
]);
|
| 324 |
+
|
| 325 |
+
let mut out = Vec::new();
|
| 326 |
+
out.extend_from_slice(b"\n\n");
|
| 327 |
+
write_value(&mut out, &root, 0, false, None);
|
| 328 |
+
out
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
fn normalize_text(text: &str) -> String {
|
| 332 |
+
let normalized = text
|
| 333 |
+
.replace("\r\n", "\n")
|
| 334 |
+
.replace('\r', "\n")
|
| 335 |
+
.replace('\n', "\r");
|
| 336 |
+
format!("{normalized}\r")
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
fn paragraph_run_lengths(text: &str) -> Vec<i32> {
|
| 340 |
+
text.split_inclusive('\r')
|
| 341 |
+
.map(|run| utf16_len(run) as i32)
|
| 342 |
+
.collect()
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
fn utf16_len(text: &str) -> usize {
|
| 346 |
+
text.encode_utf16().count()
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
fn paragraph_properties(justification: TextJustification) -> Vec<(String, EngineValue)> {
|
| 350 |
+
vec![
|
| 351 |
+
(
|
| 352 |
+
"Justification".to_string(),
|
| 353 |
+
EngineValue::Int(match justification {
|
| 354 |
+
TextJustification::Left => 0,
|
| 355 |
+
TextJustification::Right => 1,
|
| 356 |
+
TextJustification::Center => 2,
|
| 357 |
+
}),
|
| 358 |
+
),
|
| 359 |
+
("FirstLineIndent".to_string(), EngineValue::Float(0.0)),
|
| 360 |
+
("StartIndent".to_string(), EngineValue::Float(0.0)),
|
| 361 |
+
("EndIndent".to_string(), EngineValue::Float(0.0)),
|
| 362 |
+
("SpaceBefore".to_string(), EngineValue::Float(0.0)),
|
| 363 |
+
("SpaceAfter".to_string(), EngineValue::Float(0.0)),
|
| 364 |
+
("AutoHyphenate".to_string(), EngineValue::Bool(true)),
|
| 365 |
+
("HyphenatedWordSize".to_string(), EngineValue::Int(6)),
|
| 366 |
+
("PreHyphen".to_string(), EngineValue::Int(2)),
|
| 367 |
+
("PostHyphen".to_string(), EngineValue::Int(2)),
|
| 368 |
+
("ConsecutiveHyphens".to_string(), EngineValue::Int(8)),
|
| 369 |
+
("Zone".to_string(), EngineValue::Float(36.0)),
|
| 370 |
+
(
|
| 371 |
+
"WordSpacing".to_string(),
|
| 372 |
+
EngineValue::Array(vec![
|
| 373 |
+
EngineValue::Float(0.8),
|
| 374 |
+
EngineValue::Float(1.0),
|
| 375 |
+
EngineValue::Float(1.33),
|
| 376 |
+
]),
|
| 377 |
+
),
|
| 378 |
+
(
|
| 379 |
+
"LetterSpacing".to_string(),
|
| 380 |
+
EngineValue::Array(vec![
|
| 381 |
+
EngineValue::Float(0.0),
|
| 382 |
+
EngineValue::Float(0.0),
|
| 383 |
+
EngineValue::Float(0.0),
|
| 384 |
+
]),
|
| 385 |
+
),
|
| 386 |
+
(
|
| 387 |
+
"GlyphSpacing".to_string(),
|
| 388 |
+
EngineValue::Array(vec![
|
| 389 |
+
EngineValue::Float(1.0),
|
| 390 |
+
EngineValue::Float(1.0),
|
| 391 |
+
EngineValue::Float(1.0),
|
| 392 |
+
]),
|
| 393 |
+
),
|
| 394 |
+
("AutoLeading".to_string(), EngineValue::Float(1.2)),
|
| 395 |
+
("LeadingType".to_string(), EngineValue::Int(0)),
|
| 396 |
+
("Hanging".to_string(), EngineValue::Bool(false)),
|
| 397 |
+
("Burasagari".to_string(), EngineValue::Bool(false)),
|
| 398 |
+
("KinsokuOrder".to_string(), EngineValue::Int(0)),
|
| 399 |
+
("EveryLineComposer".to_string(), EngineValue::Bool(false)),
|
| 400 |
+
]
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
fn base_style_sheet(font_index: i32) -> Vec<(String, EngineValue)> {
|
| 404 |
+
vec![
|
| 405 |
+
("Font".to_string(), EngineValue::Int(font_index)),
|
| 406 |
+
("FontSize".to_string(), EngineValue::Float(12.0)),
|
| 407 |
+
("FauxBold".to_string(), EngineValue::Bool(false)),
|
| 408 |
+
("FauxItalic".to_string(), EngineValue::Bool(false)),
|
| 409 |
+
("AutoLeading".to_string(), EngineValue::Bool(true)),
|
| 410 |
+
("Leading".to_string(), EngineValue::Float(0.0)),
|
| 411 |
+
("HorizontalScale".to_string(), EngineValue::Float(1.0)),
|
| 412 |
+
("VerticalScale".to_string(), EngineValue::Float(1.0)),
|
| 413 |
+
("Tracking".to_string(), EngineValue::Int(0)),
|
| 414 |
+
("AutoKerning".to_string(), EngineValue::Bool(true)),
|
| 415 |
+
("Kerning".to_string(), EngineValue::Int(0)),
|
| 416 |
+
("BaselineShift".to_string(), EngineValue::Float(0.0)),
|
| 417 |
+
("FontCaps".to_string(), EngineValue::Int(0)),
|
| 418 |
+
("FontBaseline".to_string(), EngineValue::Int(0)),
|
| 419 |
+
("Underline".to_string(), EngineValue::Bool(false)),
|
| 420 |
+
("Strikethrough".to_string(), EngineValue::Bool(false)),
|
| 421 |
+
("Ligatures".to_string(), EngineValue::Bool(true)),
|
| 422 |
+
("DLigatures".to_string(), EngineValue::Bool(false)),
|
| 423 |
+
("BaselineDirection".to_string(), EngineValue::Int(2)),
|
| 424 |
+
("Tsume".to_string(), EngineValue::Float(0.0)),
|
| 425 |
+
("StyleRunAlignment".to_string(), EngineValue::Int(2)),
|
| 426 |
+
("Language".to_string(), EngineValue::Int(0)),
|
| 427 |
+
("NoBreak".to_string(), EngineValue::Bool(false)),
|
| 428 |
+
(
|
| 429 |
+
"FillColor".to_string(),
|
| 430 |
+
EngineValue::Dict(color_type_values([0, 0, 0, 255])),
|
| 431 |
+
),
|
| 432 |
+
(
|
| 433 |
+
"StrokeColor".to_string(),
|
| 434 |
+
EngineValue::Dict(color_type_values([0, 0, 0, 255])),
|
| 435 |
+
),
|
| 436 |
+
("FillFlag".to_string(), EngineValue::Bool(true)),
|
| 437 |
+
("StrokeFlag".to_string(), EngineValue::Bool(false)),
|
| 438 |
+
("FillFirst".to_string(), EngineValue::Bool(true)),
|
| 439 |
+
("YUnderline".to_string(), EngineValue::Int(1)),
|
| 440 |
+
("OutlineWidth".to_string(), EngineValue::Float(1.0)),
|
| 441 |
+
("CharacterDirection".to_string(), EngineValue::Int(0)),
|
| 442 |
+
("HindiNumbers".to_string(), EngineValue::Bool(false)),
|
| 443 |
+
("Kashida".to_string(), EngineValue::Int(1)),
|
| 444 |
+
("DiacriticPos".to_string(), EngineValue::Int(2)),
|
| 445 |
+
]
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
fn style_run_sheet(spec: &TextEngineSpec, font_index: i32) -> Vec<(String, EngineValue)> {
|
| 449 |
+
vec![
|
| 450 |
+
("Font".to_string(), EngineValue::Int(font_index)),
|
| 451 |
+
("FontSize".to_string(), EngineValue::Float(spec.font_size)),
|
| 452 |
+
("FauxBold".to_string(), EngineValue::Bool(spec.faux_bold)),
|
| 453 |
+
(
|
| 454 |
+
"FauxItalic".to_string(),
|
| 455 |
+
EngineValue::Bool(spec.faux_italic),
|
| 456 |
+
),
|
| 457 |
+
("AutoKerning".to_string(), EngineValue::Bool(true)),
|
| 458 |
+
("Kerning".to_string(), EngineValue::Int(0)),
|
| 459 |
+
(
|
| 460 |
+
"FillColor".to_string(),
|
| 461 |
+
EngineValue::Dict(color_type_values(spec.color)),
|
| 462 |
+
),
|
| 463 |
+
]
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
fn resource_dict(
|
| 467 |
+
font_set: Vec<EngineValue>,
|
| 468 |
+
paragraph_properties: Vec<(String, EngineValue)>,
|
| 469 |
+
style_sheet: Vec<(String, EngineValue)>,
|
| 470 |
+
) -> Vec<(String, EngineValue)> {
|
| 471 |
+
vec![
|
| 472 |
+
(
|
| 473 |
+
"KinsokuSet".to_string(),
|
| 474 |
+
EngineValue::Array(vec![
|
| 475 |
+
EngineValue::Dict(vec![
|
| 476 |
+
(
|
| 477 |
+
"Name".to_string(),
|
| 478 |
+
EngineValue::String("PhotoshopKinsokuHard".to_string()),
|
| 479 |
+
),
|
| 480 |
+
(
|
| 481 |
+
"NoStart".to_string(),
|
| 482 |
+
EngineValue::String(
|
| 483 |
+
"\u{3001}\u{3002}\u{ff0c}\u{ff0e}\u{30fb}\u{ff1a}\u{ff1b}\u{ff1f}\u{ff01}\u{30fc}\u{2015}\u{2019}\u{201d}\u{ff09}\u{3015}\u{ff3d}\u{ff5d}\u{3009}\u{300b}\u{300d}\u{300f}\u{3011}\u{30fd}\u{30fe}\u{309d}\u{309e}\u{3005}\u{3041}\u{3043}\u{3045}\u{3047}\u{3049}\u{3063}\u{3083}\u{3085}\u{3087}\u{308e}\u{30a1}\u{30a3}\u{30a5}\u{30a7}\u{30a9}\u{30c3}\u{30e3}\u{30e5}\u{30e7}\u{30ee}\u{30f5}\u{30f6}\u{309b}\u{309c}?!)]},.:;\u{2103}\u{2109}\u{00a2}\u{ff05}\u{2030}".to_string(),
|
| 484 |
+
),
|
| 485 |
+
),
|
| 486 |
+
(
|
| 487 |
+
"NoEnd".to_string(),
|
| 488 |
+
EngineValue::String(
|
| 489 |
+
"\u{2018}\u{201c}\u{ff08}\u{3014}\u{ff3b}\u{ff5b}\u{3008}\u{300a}\u{300c}\u{300e}\u{3010}([{\u{ffe5}\u{ff04}\u{00a3}\u{ff20}\u{00a7}\u{3012}\u{ff03}".to_string(),
|
| 490 |
+
),
|
| 491 |
+
),
|
| 492 |
+
(
|
| 493 |
+
"Keep".to_string(),
|
| 494 |
+
EngineValue::String("\u{2015}\u{2025}".to_string()),
|
| 495 |
+
),
|
| 496 |
+
(
|
| 497 |
+
"Hanging".to_string(),
|
| 498 |
+
EngineValue::String("\u{3001}\u{3002}.,".to_string()),
|
| 499 |
+
),
|
| 500 |
+
]),
|
| 501 |
+
EngineValue::Dict(vec![
|
| 502 |
+
(
|
| 503 |
+
"Name".to_string(),
|
| 504 |
+
EngineValue::String("PhotoshopKinsokuSoft".to_string()),
|
| 505 |
+
),
|
| 506 |
+
(
|
| 507 |
+
"NoStart".to_string(),
|
| 508 |
+
EngineValue::String(
|
| 509 |
+
"\u{3001}\u{3002}\u{ff0c}\u{ff0e}\u{30fb}\u{ff1a}\u{ff1b}\u{ff1f}\u{ff01}\u{2019}\u{201d}\u{ff09}\u{3015}\u{ff3d}\u{ff5d}\u{3009}\u{300b}\u{300d}\u{300f}\u{3011}\u{30fd}\u{30fe}\u{309d}\u{309e}\u{3005}".to_string(),
|
| 510 |
+
),
|
| 511 |
+
),
|
| 512 |
+
(
|
| 513 |
+
"NoEnd".to_string(),
|
| 514 |
+
EngineValue::String(
|
| 515 |
+
"\u{2018}\u{201c}\u{ff08}\u{3014}\u{ff3b}\u{ff5b}\u{3008}\u{300a}\u{300c}\u{300e}\u{3010}".to_string(),
|
| 516 |
+
),
|
| 517 |
+
),
|
| 518 |
+
(
|
| 519 |
+
"Keep".to_string(),
|
| 520 |
+
EngineValue::String("\u{2015}\u{2025}".to_string()),
|
| 521 |
+
),
|
| 522 |
+
(
|
| 523 |
+
"Hanging".to_string(),
|
| 524 |
+
EngineValue::String("\u{3001}\u{3002}.,".to_string()),
|
| 525 |
+
),
|
| 526 |
+
]),
|
| 527 |
+
]),
|
| 528 |
+
),
|
| 529 |
+
(
|
| 530 |
+
"MojiKumiSet".to_string(),
|
| 531 |
+
EngineValue::Array(vec![
|
| 532 |
+
EngineValue::Dict(vec![(
|
| 533 |
+
"InternalName".to_string(),
|
| 534 |
+
EngineValue::String("Photoshop6MojiKumiSet1".to_string()),
|
| 535 |
+
)]),
|
| 536 |
+
EngineValue::Dict(vec![(
|
| 537 |
+
"InternalName".to_string(),
|
| 538 |
+
EngineValue::String("Photoshop6MojiKumiSet2".to_string()),
|
| 539 |
+
)]),
|
| 540 |
+
EngineValue::Dict(vec![(
|
| 541 |
+
"InternalName".to_string(),
|
| 542 |
+
EngineValue::String("Photoshop6MojiKumiSet3".to_string()),
|
| 543 |
+
)]),
|
| 544 |
+
EngineValue::Dict(vec![(
|
| 545 |
+
"InternalName".to_string(),
|
| 546 |
+
EngineValue::String("Photoshop6MojiKumiSet4".to_string()),
|
| 547 |
+
)]),
|
| 548 |
+
]),
|
| 549 |
+
),
|
| 550 |
+
("TheNormalStyleSheet".to_string(), EngineValue::Int(0)),
|
| 551 |
+
("TheNormalParagraphSheet".to_string(), EngineValue::Int(0)),
|
| 552 |
+
(
|
| 553 |
+
"ParagraphSheetSet".to_string(),
|
| 554 |
+
EngineValue::Array(vec![EngineValue::Dict(vec![
|
| 555 |
+
(
|
| 556 |
+
"Name".to_string(),
|
| 557 |
+
EngineValue::String("Normal RGB".to_string()),
|
| 558 |
+
),
|
| 559 |
+
("DefaultStyleSheet".to_string(), EngineValue::Int(0)),
|
| 560 |
+
(
|
| 561 |
+
"Properties".to_string(),
|
| 562 |
+
EngineValue::Dict(paragraph_properties),
|
| 563 |
+
),
|
| 564 |
+
])]),
|
| 565 |
+
),
|
| 566 |
+
(
|
| 567 |
+
"StyleSheetSet".to_string(),
|
| 568 |
+
EngineValue::Array(vec![EngineValue::Dict(vec![
|
| 569 |
+
(
|
| 570 |
+
"Name".to_string(),
|
| 571 |
+
EngineValue::String("Normal RGB".to_string()),
|
| 572 |
+
),
|
| 573 |
+
("StyleSheetData".to_string(), EngineValue::Dict(style_sheet)),
|
| 574 |
+
])]),
|
| 575 |
+
),
|
| 576 |
+
("FontSet".to_string(), EngineValue::Array(font_set)),
|
| 577 |
+
("SuperscriptSize".to_string(), EngineValue::Float(0.583)),
|
| 578 |
+
("SuperscriptPosition".to_string(), EngineValue::Float(0.333)),
|
| 579 |
+
("SubscriptSize".to_string(), EngineValue::Float(0.583)),
|
| 580 |
+
("SubscriptPosition".to_string(), EngineValue::Float(0.333)),
|
| 581 |
+
("SmallCapSize".to_string(), EngineValue::Float(0.7)),
|
| 582 |
+
]
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
fn font_set(font_name: &str) -> Vec<EngineValue> {
|
| 586 |
+
let mut fonts = vec![font_descriptor("AdobeInvisFont")];
|
| 587 |
+
if font_name != "AdobeInvisFont" {
|
| 588 |
+
fonts.push(font_descriptor(font_name));
|
| 589 |
+
}
|
| 590 |
+
fonts
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
fn font_descriptor(name: &str) -> EngineValue {
|
| 594 |
+
EngineValue::Dict(vec![
|
| 595 |
+
("Name".to_string(), EngineValue::String(name.to_string())),
|
| 596 |
+
("Script".to_string(), EngineValue::Int(0)),
|
| 597 |
+
("FontType".to_string(), EngineValue::Int(0)),
|
| 598 |
+
("Synthetic".to_string(), EngineValue::Int(0)),
|
| 599 |
+
])
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
fn color_type_values(color: [u8; 4]) -> Vec<(String, EngineValue)> {
|
| 603 |
+
vec![
|
| 604 |
+
("Type".to_string(), EngineValue::Int(1)),
|
| 605 |
+
(
|
| 606 |
+
"Values".to_string(),
|
| 607 |
+
EngineValue::Array(vec![
|
| 608 |
+
EngineValue::Float(color[3] as f64 / 255.0),
|
| 609 |
+
EngineValue::Float(color[0] as f64 / 255.0),
|
| 610 |
+
EngineValue::Float(color[1] as f64 / 255.0),
|
| 611 |
+
EngineValue::Float(color[2] as f64 / 255.0),
|
| 612 |
+
]),
|
| 613 |
+
),
|
| 614 |
+
]
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
fn write_value(
|
| 618 |
+
out: &mut Vec<u8>,
|
| 619 |
+
value: &EngineValue,
|
| 620 |
+
indent: usize,
|
| 621 |
+
in_property: bool,
|
| 622 |
+
key: Option<&str>,
|
| 623 |
+
) {
|
| 624 |
+
match value {
|
| 625 |
+
EngineValue::Int(number) => {
|
| 626 |
+
write_prefix(out, indent, in_property);
|
| 627 |
+
out.extend_from_slice(number.to_string().as_bytes());
|
| 628 |
+
}
|
| 629 |
+
EngineValue::Float(number) => {
|
| 630 |
+
write_prefix(out, indent, in_property);
|
| 631 |
+
out.extend_from_slice(serialize_float(*number, key).as_bytes());
|
| 632 |
+
}
|
| 633 |
+
EngineValue::Bool(flag) => {
|
| 634 |
+
write_prefix(out, indent, in_property);
|
| 635 |
+
out.extend_from_slice(if *flag { b"true" } else { b"false" });
|
| 636 |
+
}
|
| 637 |
+
EngineValue::String(text) => {
|
| 638 |
+
write_prefix(out, indent, in_property);
|
| 639 |
+
out.push(b'(');
|
| 640 |
+
out.push(0xFE);
|
| 641 |
+
out.push(0xFF);
|
| 642 |
+
for unit in text.encode_utf16() {
|
| 643 |
+
write_escaped_byte(out, (unit >> 8) as u8);
|
| 644 |
+
write_escaped_byte(out, unit as u8);
|
| 645 |
+
}
|
| 646 |
+
out.push(b')');
|
| 647 |
+
}
|
| 648 |
+
EngineValue::Array(items) => {
|
| 649 |
+
write_prefix(out, indent, in_property);
|
| 650 |
+
if items.iter().all(is_scalar) {
|
| 651 |
+
out.extend_from_slice(b"[");
|
| 652 |
+
for item in items {
|
| 653 |
+
out.push(b' ');
|
| 654 |
+
write_inline_value(out, item, key);
|
| 655 |
+
}
|
| 656 |
+
out.extend_from_slice(b" ]");
|
| 657 |
+
} else {
|
| 658 |
+
out.extend_from_slice(b"[\n");
|
| 659 |
+
for item in items {
|
| 660 |
+
write_value(out, item, indent + 1, false, key);
|
| 661 |
+
out.push(b'\n');
|
| 662 |
+
}
|
| 663 |
+
write_indent(out, indent);
|
| 664 |
+
out.extend_from_slice(b"]");
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
+
EngineValue::Dict(entries) => {
|
| 668 |
+
if in_property {
|
| 669 |
+
out.push(b'\n');
|
| 670 |
+
} else {
|
| 671 |
+
write_indent(out, indent);
|
| 672 |
+
}
|
| 673 |
+
out.extend_from_slice(b"<<\n");
|
| 674 |
+
for (entry_key, entry_value) in entries {
|
| 675 |
+
write_indent(out, indent + 1);
|
| 676 |
+
out.push(b'/');
|
| 677 |
+
out.extend_from_slice(entry_key.as_bytes());
|
| 678 |
+
write_value(out, entry_value, indent + 1, true, Some(entry_key));
|
| 679 |
+
out.push(b'\n');
|
| 680 |
+
}
|
| 681 |
+
write_indent(out, indent);
|
| 682 |
+
out.extend_from_slice(b">>");
|
| 683 |
+
}
|
| 684 |
+
}
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
fn write_inline_value(out: &mut Vec<u8>, value: &EngineValue, key: Option<&str>) {
|
| 688 |
+
match value {
|
| 689 |
+
EngineValue::Int(number) => out.extend_from_slice(number.to_string().as_bytes()),
|
| 690 |
+
EngineValue::Float(number) => {
|
| 691 |
+
out.extend_from_slice(serialize_float(*number, key).as_bytes())
|
| 692 |
+
}
|
| 693 |
+
EngineValue::Bool(flag) => out.extend_from_slice(if *flag { b"true" } else { b"false" }),
|
| 694 |
+
_ => write_value(out, value, 0, false, key),
|
| 695 |
+
}
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
fn write_prefix(out: &mut Vec<u8>, indent: usize, in_property: bool) {
|
| 699 |
+
if in_property {
|
| 700 |
+
out.push(b' ');
|
| 701 |
+
} else {
|
| 702 |
+
write_indent(out, indent);
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
fn write_indent(out: &mut Vec<u8>, indent: usize) {
|
| 707 |
+
for _ in 0..indent {
|
| 708 |
+
out.push(b'\t');
|
| 709 |
+
}
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
fn write_escaped_byte(out: &mut Vec<u8>, byte: u8) {
|
| 713 |
+
if matches!(byte, b'(' | b')' | b'\\') {
|
| 714 |
+
out.push(b'\\');
|
| 715 |
+
}
|
| 716 |
+
out.push(byte);
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
fn is_scalar(value: &EngineValue) -> bool {
|
| 720 |
+
matches!(
|
| 721 |
+
value,
|
| 722 |
+
EngineValue::Int(_) | EngineValue::Float(_) | EngineValue::Bool(_) | EngineValue::String(_)
|
| 723 |
+
)
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
fn serialize_float(value: f64, key: Option<&str>) -> String {
|
| 727 |
+
let is_float = matches!(
|
| 728 |
+
key,
|
| 729 |
+
Some(
|
| 730 |
+
"Axis"
|
| 731 |
+
| "XY"
|
| 732 |
+
| "Zone"
|
| 733 |
+
| "WordSpacing"
|
| 734 |
+
| "FirstLineIndent"
|
| 735 |
+
| "GlyphSpacing"
|
| 736 |
+
| "StartIndent"
|
| 737 |
+
| "EndIndent"
|
| 738 |
+
| "SpaceBefore"
|
| 739 |
+
| "SpaceAfter"
|
| 740 |
+
| "LetterSpacing"
|
| 741 |
+
| "Values"
|
| 742 |
+
| "GridSize"
|
| 743 |
+
| "GridLeading"
|
| 744 |
+
| "PointBase"
|
| 745 |
+
| "BoxBounds"
|
| 746 |
+
| "TransformPoint0"
|
| 747 |
+
| "TransformPoint1"
|
| 748 |
+
| "TransformPoint2"
|
| 749 |
+
| "FontSize"
|
| 750 |
+
| "Leading"
|
| 751 |
+
| "HorizontalScale"
|
| 752 |
+
| "VerticalScale"
|
| 753 |
+
| "BaselineShift"
|
| 754 |
+
| "Tsume"
|
| 755 |
+
| "OutlineWidth"
|
| 756 |
+
| "AutoLeading"
|
| 757 |
+
)
|
| 758 |
+
) || value.fract() != 0.0;
|
| 759 |
+
|
| 760 |
+
if !is_float {
|
| 761 |
+
return (value as i32).to_string();
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
let mut formatted = format!("{value:.5}");
|
| 765 |
+
if let Some(dot) = formatted.find('.') {
|
| 766 |
+
while formatted.ends_with('0') && formatted.len() > dot + 2 {
|
| 767 |
+
formatted.pop();
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
if formatted.starts_with("0.")
|
| 771 |
+
&& formatted
|
| 772 |
+
.as_bytes()
|
| 773 |
+
.get(2)
|
| 774 |
+
.is_some_and(|digit| *digit != b'0')
|
| 775 |
+
{
|
| 776 |
+
formatted.remove(0);
|
| 777 |
+
} else if formatted.starts_with("-0.0")
|
| 778 |
+
&& formatted
|
| 779 |
+
.as_bytes()
|
| 780 |
+
.get(4)
|
| 781 |
+
.is_some_and(|digit| digit.is_ascii_digit() && *digit != b'0')
|
| 782 |
+
{
|
| 783 |
+
formatted.remove(1);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
formatted
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
#[cfg(test)]
|
| 790 |
+
mod tests {
|
| 791 |
+
use super::{TextEngineSpec, TextJustification, TextOrientation, encode_engine_data};
|
| 792 |
+
|
| 793 |
+
#[test]
|
| 794 |
+
fn engine_data_contains_expected_sections_and_utf16_text() {
|
| 795 |
+
let bytes = encode_engine_data(&TextEngineSpec {
|
| 796 |
+
text: "Hello".to_string(),
|
| 797 |
+
font_name: "ArialMT".to_string(),
|
| 798 |
+
font_size: 14.0,
|
| 799 |
+
color: [1, 2, 3, 255],
|
| 800 |
+
faux_bold: true,
|
| 801 |
+
faux_italic: false,
|
| 802 |
+
orientation: TextOrientation::Horizontal,
|
| 803 |
+
justification: TextJustification::Center,
|
| 804 |
+
box_width: 100.0,
|
| 805 |
+
box_height: 32.0,
|
| 806 |
+
});
|
| 807 |
+
|
| 808 |
+
assert!(
|
| 809 |
+
bytes
|
| 810 |
+
.windows("/EngineDict".len())
|
| 811 |
+
.any(|window| window == b"/EngineDict")
|
| 812 |
+
);
|
| 813 |
+
assert!(
|
| 814 |
+
bytes
|
| 815 |
+
.windows("/FontSet".len())
|
| 816 |
+
.any(|window| window == b"/FontSet")
|
| 817 |
+
);
|
| 818 |
+
assert!(
|
| 819 |
+
bytes
|
| 820 |
+
.windows("/RunLengthArray".len())
|
| 821 |
+
.any(|window| window == b"/RunLengthArray")
|
| 822 |
+
);
|
| 823 |
+
assert!(bytes.windows(2).any(|window| window == [0xFE, 0xFF]));
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
#[test]
|
| 827 |
+
fn engine_data_keeps_float_tokens_for_font_size_and_transforms() {
|
| 828 |
+
let bytes = encode_engine_data(&TextEngineSpec {
|
| 829 |
+
text: "Hello".to_string(),
|
| 830 |
+
font_name: "ArialMT".to_string(),
|
| 831 |
+
font_size: 14.0,
|
| 832 |
+
color: [1, 2, 3, 255],
|
| 833 |
+
faux_bold: true,
|
| 834 |
+
faux_italic: false,
|
| 835 |
+
orientation: TextOrientation::Horizontal,
|
| 836 |
+
justification: TextJustification::Center,
|
| 837 |
+
box_width: 100.0,
|
| 838 |
+
box_height: 32.0,
|
| 839 |
+
});
|
| 840 |
+
|
| 841 |
+
assert!(
|
| 842 |
+
bytes
|
| 843 |
+
.windows("/FontSize 14.0".len())
|
| 844 |
+
.any(|w| w == b"/FontSize 14.0")
|
| 845 |
+
);
|
| 846 |
+
assert!(
|
| 847 |
+
bytes
|
| 848 |
+
.windows("/Axis [ 1.0 0.0 1.0 ]".len())
|
| 849 |
+
.any(|w| w == b"/Axis [ 1.0 0.0 1.0 ]")
|
| 850 |
+
);
|
| 851 |
+
}
|
| 852 |
+
}
|
koharu-psd/src/error.rs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use thiserror::Error;
|
| 2 |
+
|
| 3 |
+
#[derive(Debug, Error)]
|
| 4 |
+
pub enum PsdExportError {
|
| 5 |
+
#[error("classic PSD only supports dimensions up to 30000x30000, got {width}x{height}")]
|
| 6 |
+
UnsupportedDimensions { width: u32, height: u32 },
|
| 7 |
+
#[error("document is missing base image data")]
|
| 8 |
+
MissingBaseImage,
|
| 9 |
+
#[error("invalid layer bounds for {layer}: {width}x{height}")]
|
| 10 |
+
InvalidLayerBounds {
|
| 11 |
+
layer: String,
|
| 12 |
+
width: i32,
|
| 13 |
+
height: i32,
|
| 14 |
+
},
|
| 15 |
+
#[error("RLE row {row} for {layer} exceeded PSD limits ({length} bytes)")]
|
| 16 |
+
InvalidChannelEncoding {
|
| 17 |
+
layer: String,
|
| 18 |
+
row: usize,
|
| 19 |
+
length: usize,
|
| 20 |
+
},
|
| 21 |
+
#[error("invalid descriptor data: {0}")]
|
| 22 |
+
InvalidDescriptor(String),
|
| 23 |
+
#[error("I/O error: {0}")]
|
| 24 |
+
Io(#[from] std::io::Error),
|
| 25 |
+
}
|
koharu-psd/src/export.rs
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::io::Write;
|
| 2 |
+
|
| 3 |
+
use image::{DynamicImage, GrayImage, Rgba, RgbaImage, imageops::overlay};
|
| 4 |
+
use koharu_types::{
|
| 5 |
+
Document, FontPrediction, SerializableDynamicImage, TextAlign, TextBlock, TextDirection,
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
use crate::{
|
| 9 |
+
descriptor::{
|
| 10 |
+
DescriptorObject, DescriptorValue, bounds_descriptor, write_versioned_descriptor,
|
| 11 |
+
},
|
| 12 |
+
engine_data::{TextEngineSpec, TextJustification, TextOrientation, encode_engine_data},
|
| 13 |
+
error::PsdExportError,
|
| 14 |
+
packbits::{ChannelId, encode_image_rle},
|
| 15 |
+
writer::PsdWriter,
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 19 |
+
pub enum TextLayerMode {
|
| 20 |
+
Rasterized,
|
| 21 |
+
Editable,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
#[derive(Debug, Clone)]
|
| 25 |
+
pub struct PsdExportOptions {
|
| 26 |
+
pub include_original: bool,
|
| 27 |
+
pub include_inpainted: bool,
|
| 28 |
+
pub include_segment_mask: bool,
|
| 29 |
+
pub include_brush_layer: bool,
|
| 30 |
+
pub text_layer_mode: TextLayerMode,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
impl Default for PsdExportOptions {
|
| 34 |
+
fn default() -> Self {
|
| 35 |
+
Self {
|
| 36 |
+
include_original: true,
|
| 37 |
+
include_inpainted: true,
|
| 38 |
+
include_segment_mask: true,
|
| 39 |
+
include_brush_layer: true,
|
| 40 |
+
text_layer_mode: TextLayerMode::Rasterized,
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#[derive(Debug, Clone)]
|
| 46 |
+
struct ExportLayer {
|
| 47 |
+
name: String,
|
| 48 |
+
left: i32,
|
| 49 |
+
top: i32,
|
| 50 |
+
pixels: RgbaImage,
|
| 51 |
+
hidden: bool,
|
| 52 |
+
text: Option<TextLayerMetadata>,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
#[derive(Debug, Clone)]
|
| 56 |
+
struct TextLayerMetadata {
|
| 57 |
+
index: i32,
|
| 58 |
+
text: String,
|
| 59 |
+
bounds: [f64; 4],
|
| 60 |
+
transform: [f64; 6],
|
| 61 |
+
orientation: TextOrientation,
|
| 62 |
+
justification: TextJustification,
|
| 63 |
+
font_name: String,
|
| 64 |
+
font_size: f64,
|
| 65 |
+
color: [u8; 4],
|
| 66 |
+
faux_bold: bool,
|
| 67 |
+
faux_italic: bool,
|
| 68 |
+
box_width: f64,
|
| 69 |
+
box_height: f64,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
pub fn export_document(
|
| 73 |
+
document: &Document,
|
| 74 |
+
options: &PsdExportOptions,
|
| 75 |
+
) -> Result<Vec<u8>, PsdExportError> {
|
| 76 |
+
let mut bytes = Vec::new();
|
| 77 |
+
write_document(&mut bytes, document, options)?;
|
| 78 |
+
Ok(bytes)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
pub fn write_document<W: Write>(
|
| 82 |
+
mut writer: W,
|
| 83 |
+
document: &Document,
|
| 84 |
+
options: &PsdExportOptions,
|
| 85 |
+
) -> Result<(), PsdExportError> {
|
| 86 |
+
let (width, height) = document_dimensions(document)?;
|
| 87 |
+
let layers_bottom_to_top = collect_layers(document, options)?;
|
| 88 |
+
let composite = merged_composite(document, &layers_bottom_to_top, width, height);
|
| 89 |
+
let layers_top_to_bottom: Vec<&ExportLayer> = layers_bottom_to_top.iter().rev().collect();
|
| 90 |
+
|
| 91 |
+
let mut psd = PsdWriter::new();
|
| 92 |
+
write_header(&mut psd, width, height);
|
| 93 |
+
psd.write_u32(0);
|
| 94 |
+
psd.write_u32(0);
|
| 95 |
+
|
| 96 |
+
let layer_mask_info = build_layer_and_mask_info(&layers_top_to_bottom)?;
|
| 97 |
+
psd.write_u32(layer_mask_info.len() as u32);
|
| 98 |
+
psd.write_bytes(&layer_mask_info);
|
| 99 |
+
|
| 100 |
+
write_image_data(&mut psd, &composite, "Merged Composite")?;
|
| 101 |
+
|
| 102 |
+
writer.write_all(&psd.into_inner())?;
|
| 103 |
+
Ok(())
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
fn document_dimensions(document: &Document) -> Result<(u32, u32), PsdExportError> {
|
| 107 |
+
let width = if document.width > 0 {
|
| 108 |
+
document.width
|
| 109 |
+
} else {
|
| 110 |
+
document.image.width()
|
| 111 |
+
};
|
| 112 |
+
let height = if document.height > 0 {
|
| 113 |
+
document.height
|
| 114 |
+
} else {
|
| 115 |
+
document.image.height()
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
if width == 0 || height == 0 {
|
| 119 |
+
return Err(PsdExportError::MissingBaseImage);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if width > 30_000 || height > 30_000 {
|
| 123 |
+
return Err(PsdExportError::UnsupportedDimensions { width, height });
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
Ok((width, height))
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
fn write_header(writer: &mut PsdWriter, width: u32, height: u32) {
|
| 130 |
+
writer.write_signature("8BPS");
|
| 131 |
+
writer.write_u16(1);
|
| 132 |
+
writer.write_zeroes(6);
|
| 133 |
+
writer.write_u16(4);
|
| 134 |
+
writer.write_u32(height);
|
| 135 |
+
writer.write_u32(width);
|
| 136 |
+
writer.write_u16(8);
|
| 137 |
+
writer.write_u16(3);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
fn collect_layers(
|
| 141 |
+
document: &Document,
|
| 142 |
+
options: &PsdExportOptions,
|
| 143 |
+
) -> Result<Vec<ExportLayer>, PsdExportError> {
|
| 144 |
+
let mut layers = Vec::new();
|
| 145 |
+
let include_inpainted = options.include_inpainted && document.inpainted.is_some();
|
| 146 |
+
|
| 147 |
+
if options.include_original {
|
| 148 |
+
let pixels = dynamic_to_rgba(&document.image);
|
| 149 |
+
validate_layer_pixels("Original Image", &pixels)?;
|
| 150 |
+
layers.push(ExportLayer {
|
| 151 |
+
name: "Original Image".to_string(),
|
| 152 |
+
left: 0,
|
| 153 |
+
top: 0,
|
| 154 |
+
pixels,
|
| 155 |
+
hidden: include_inpainted,
|
| 156 |
+
text: None,
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
if let Some(image) = document
|
| 161 |
+
.inpainted
|
| 162 |
+
.as_ref()
|
| 163 |
+
.filter(|_| options.include_inpainted)
|
| 164 |
+
{
|
| 165 |
+
let pixels = dynamic_to_rgba(image);
|
| 166 |
+
validate_layer_pixels("Inpainted", &pixels)?;
|
| 167 |
+
layers.push(ExportLayer {
|
| 168 |
+
name: "Inpainted".to_string(),
|
| 169 |
+
left: 0,
|
| 170 |
+
top: 0,
|
| 171 |
+
pixels,
|
| 172 |
+
hidden: false,
|
| 173 |
+
text: None,
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if let Some(mask) = document
|
| 178 |
+
.segment
|
| 179 |
+
.as_ref()
|
| 180 |
+
.filter(|_| options.include_segment_mask)
|
| 181 |
+
{
|
| 182 |
+
let pixels = grayscale_mask_rgba(mask);
|
| 183 |
+
validate_layer_pixels("Segmentation Mask", &pixels)?;
|
| 184 |
+
layers.push(ExportLayer {
|
| 185 |
+
name: "Segmentation Mask".to_string(),
|
| 186 |
+
left: 0,
|
| 187 |
+
top: 0,
|
| 188 |
+
pixels,
|
| 189 |
+
hidden: true,
|
| 190 |
+
text: None,
|
| 191 |
+
});
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
if let Some(brush) = document
|
| 195 |
+
.brush_layer
|
| 196 |
+
.as_ref()
|
| 197 |
+
.filter(|_| options.include_brush_layer)
|
| 198 |
+
{
|
| 199 |
+
let pixels = dynamic_to_rgba(brush);
|
| 200 |
+
validate_layer_pixels("Brush Layer", &pixels)?;
|
| 201 |
+
layers.push(ExportLayer {
|
| 202 |
+
name: "Brush Layer".to_string(),
|
| 203 |
+
left: 0,
|
| 204 |
+
top: 0,
|
| 205 |
+
pixels,
|
| 206 |
+
hidden: false,
|
| 207 |
+
text: None,
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
let mut text_index = 1i32;
|
| 212 |
+
for block in &document.text_blocks {
|
| 213 |
+
if let Some(layer) = text_layer(block, text_index, options.text_layer_mode)? {
|
| 214 |
+
layers.push(layer);
|
| 215 |
+
text_index += 1;
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
Ok(layers)
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
fn text_layer(
|
| 223 |
+
block: &TextBlock,
|
| 224 |
+
index: i32,
|
| 225 |
+
mode: TextLayerMode,
|
| 226 |
+
) -> Result<Option<ExportLayer>, PsdExportError> {
|
| 227 |
+
let text = block.translation.clone().unwrap_or_default();
|
| 228 |
+
let trimmed = text.trim();
|
| 229 |
+
if trimmed.is_empty() {
|
| 230 |
+
return Ok(None);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
let left = block.x.trunc() as i32;
|
| 234 |
+
let top = block.y.trunc() as i32;
|
| 235 |
+
|
| 236 |
+
let pixels = if let Some(rendered) = block.rendered.as_ref() {
|
| 237 |
+
dynamic_to_rgba(rendered)
|
| 238 |
+
} else {
|
| 239 |
+
let width = block.width.ceil().max(1.0) as u32;
|
| 240 |
+
let height = block.height.ceil().max(1.0) as u32;
|
| 241 |
+
RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]))
|
| 242 |
+
};
|
| 243 |
+
validate_layer_pixels(&block.id, &pixels)?;
|
| 244 |
+
|
| 245 |
+
let text = match mode {
|
| 246 |
+
TextLayerMode::Rasterized => None,
|
| 247 |
+
TextLayerMode::Editable => {
|
| 248 |
+
let orientation = infer_orientation(block);
|
| 249 |
+
let justification = infer_justification(block, trimmed, orientation);
|
| 250 |
+
let font_name = infer_font_name(block);
|
| 251 |
+
let font_size = infer_font_size(block);
|
| 252 |
+
let color = infer_color(block);
|
| 253 |
+
let faux_bold = block
|
| 254 |
+
.style
|
| 255 |
+
.as_ref()
|
| 256 |
+
.and_then(|style| style.effect)
|
| 257 |
+
.map(|effect| effect.bold)
|
| 258 |
+
.unwrap_or(false);
|
| 259 |
+
let faux_italic = block
|
| 260 |
+
.style
|
| 261 |
+
.as_ref()
|
| 262 |
+
.and_then(|style| style.effect)
|
| 263 |
+
.map(|effect| effect.italic)
|
| 264 |
+
.unwrap_or(false);
|
| 265 |
+
let rotation_deg = block
|
| 266 |
+
.rotation_deg
|
| 267 |
+
.or_else(|| {
|
| 268 |
+
block
|
| 269 |
+
.font_prediction
|
| 270 |
+
.as_ref()
|
| 271 |
+
.map(|prediction| prediction.angle_deg)
|
| 272 |
+
})
|
| 273 |
+
.unwrap_or(0.0) as f64;
|
| 274 |
+
let rotation_rad = rotation_deg.to_radians();
|
| 275 |
+
let transform = [
|
| 276 |
+
rotation_rad.cos(),
|
| 277 |
+
rotation_rad.sin(),
|
| 278 |
+
-rotation_rad.sin(),
|
| 279 |
+
rotation_rad.cos(),
|
| 280 |
+
block.x as f64,
|
| 281 |
+
block.y as f64,
|
| 282 |
+
];
|
| 283 |
+
let bounds = [
|
| 284 |
+
block.x as f64,
|
| 285 |
+
block.y as f64,
|
| 286 |
+
block.x as f64 + block.width as f64,
|
| 287 |
+
block.y as f64 + block.height as f64,
|
| 288 |
+
];
|
| 289 |
+
|
| 290 |
+
Some(TextLayerMetadata {
|
| 291 |
+
index,
|
| 292 |
+
text: trimmed.to_string(),
|
| 293 |
+
bounds,
|
| 294 |
+
transform,
|
| 295 |
+
orientation,
|
| 296 |
+
justification,
|
| 297 |
+
font_name,
|
| 298 |
+
font_size,
|
| 299 |
+
color,
|
| 300 |
+
faux_bold,
|
| 301 |
+
faux_italic,
|
| 302 |
+
box_width: block.width.max(1.0) as f64,
|
| 303 |
+
box_height: block.height.max(1.0) as f64,
|
| 304 |
+
})
|
| 305 |
+
}
|
| 306 |
+
};
|
| 307 |
+
|
| 308 |
+
Ok(Some(ExportLayer {
|
| 309 |
+
name: format!("TL {index:03} {}", block.id),
|
| 310 |
+
left,
|
| 311 |
+
top,
|
| 312 |
+
pixels,
|
| 313 |
+
hidden: false,
|
| 314 |
+
text,
|
| 315 |
+
}))
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
fn validate_layer_pixels(layer: &str, pixels: &RgbaImage) -> Result<(), PsdExportError> {
|
| 319 |
+
let width = pixels.width() as i32;
|
| 320 |
+
let height = pixels.height() as i32;
|
| 321 |
+
if width <= 0 || height <= 0 {
|
| 322 |
+
return Err(PsdExportError::InvalidLayerBounds {
|
| 323 |
+
layer: layer.to_string(),
|
| 324 |
+
width,
|
| 325 |
+
height,
|
| 326 |
+
});
|
| 327 |
+
}
|
| 328 |
+
Ok(())
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
fn dynamic_to_rgba(image: &SerializableDynamicImage) -> RgbaImage {
|
| 332 |
+
image.to_rgba8()
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
fn grayscale_mask_rgba(image: &SerializableDynamicImage) -> RgbaImage {
|
| 336 |
+
let mask: GrayImage = image.to_luma8();
|
| 337 |
+
let mut rgba = RgbaImage::new(mask.width(), mask.height());
|
| 338 |
+
for (x, y, pixel) in mask.enumerate_pixels() {
|
| 339 |
+
rgba.put_pixel(x, y, Rgba([pixel[0], pixel[0], pixel[0], 255]));
|
| 340 |
+
}
|
| 341 |
+
rgba
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
fn merged_composite(
|
| 345 |
+
document: &Document,
|
| 346 |
+
layers_bottom_to_top: &[ExportLayer],
|
| 347 |
+
width: u32,
|
| 348 |
+
height: u32,
|
| 349 |
+
) -> RgbaImage {
|
| 350 |
+
if let Some(rendered) = document.rendered.as_ref() {
|
| 351 |
+
return place_on_canvas(
|
| 352 |
+
&DynamicImage::from(rendered.clone()).to_rgba8(),
|
| 353 |
+
width,
|
| 354 |
+
height,
|
| 355 |
+
);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
|
| 359 |
+
for layer in layers_bottom_to_top.iter().filter(|layer| !layer.hidden) {
|
| 360 |
+
overlay(
|
| 361 |
+
&mut canvas,
|
| 362 |
+
&layer.pixels,
|
| 363 |
+
i64::from(layer.left),
|
| 364 |
+
i64::from(layer.top),
|
| 365 |
+
);
|
| 366 |
+
}
|
| 367 |
+
canvas
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
fn place_on_canvas(image: &RgbaImage, width: u32, height: u32) -> RgbaImage {
|
| 371 |
+
if image.width() == width && image.height() == height {
|
| 372 |
+
return image.clone();
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
|
| 376 |
+
overlay(&mut canvas, image, 0, 0);
|
| 377 |
+
canvas
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
fn build_layer_and_mask_info(layers: &[&ExportLayer]) -> Result<Vec<u8>, PsdExportError> {
|
| 381 |
+
let mut layer_info = PsdWriter::new();
|
| 382 |
+
if layers.is_empty() {
|
| 383 |
+
layer_info.write_i16(0);
|
| 384 |
+
} else {
|
| 385 |
+
layer_info.write_i16(-(layers.len() as i16));
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
let mut encoded_layers = Vec::with_capacity(layers.len());
|
| 389 |
+
let mut extra_data = Vec::with_capacity(layers.len());
|
| 390 |
+
|
| 391 |
+
for layer in layers {
|
| 392 |
+
let channels = encode_image_rle(
|
| 393 |
+
&layer.pixels,
|
| 394 |
+
&[
|
| 395 |
+
ChannelId::Red,
|
| 396 |
+
ChannelId::Green,
|
| 397 |
+
ChannelId::Blue,
|
| 398 |
+
ChannelId::Alpha,
|
| 399 |
+
],
|
| 400 |
+
&layer.name,
|
| 401 |
+
)?;
|
| 402 |
+
let extra = build_extra_data(layer)?;
|
| 403 |
+
encoded_layers.push(channels);
|
| 404 |
+
extra_data.push(extra);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
for ((layer, channels), extra) in layers.iter().zip(&encoded_layers).zip(&extra_data) {
|
| 408 |
+
let width = i32::try_from(layer.pixels.width()).map_err(|_| {
|
| 409 |
+
PsdExportError::InvalidLayerBounds {
|
| 410 |
+
layer: layer.name.clone(),
|
| 411 |
+
width: i32::MAX,
|
| 412 |
+
height: layer.pixels.height() as i32,
|
| 413 |
+
}
|
| 414 |
+
})?;
|
| 415 |
+
let height = i32::try_from(layer.pixels.height()).map_err(|_| {
|
| 416 |
+
PsdExportError::InvalidLayerBounds {
|
| 417 |
+
layer: layer.name.clone(),
|
| 418 |
+
width,
|
| 419 |
+
height: i32::MAX,
|
| 420 |
+
}
|
| 421 |
+
})?;
|
| 422 |
+
let right =
|
| 423 |
+
layer
|
| 424 |
+
.left
|
| 425 |
+
.checked_add(width)
|
| 426 |
+
.ok_or_else(|| PsdExportError::InvalidLayerBounds {
|
| 427 |
+
layer: layer.name.clone(),
|
| 428 |
+
width,
|
| 429 |
+
height,
|
| 430 |
+
})?;
|
| 431 |
+
let bottom =
|
| 432 |
+
layer
|
| 433 |
+
.top
|
| 434 |
+
.checked_add(height)
|
| 435 |
+
.ok_or_else(|| PsdExportError::InvalidLayerBounds {
|
| 436 |
+
layer: layer.name.clone(),
|
| 437 |
+
width,
|
| 438 |
+
height,
|
| 439 |
+
})?;
|
| 440 |
+
|
| 441 |
+
layer_info.write_i32(layer.top);
|
| 442 |
+
layer_info.write_i32(layer.left);
|
| 443 |
+
layer_info.write_i32(bottom);
|
| 444 |
+
layer_info.write_i32(right);
|
| 445 |
+
layer_info.write_u16(channels.len() as u16);
|
| 446 |
+
|
| 447 |
+
for channel in channels {
|
| 448 |
+
layer_info.write_i16(channel.channel_id);
|
| 449 |
+
layer_info.write_u32((2 + channel.data.len()) as u32);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
layer_info.write_signature("8BIM");
|
| 453 |
+
layer_info.write_signature("norm");
|
| 454 |
+
layer_info.write_u8(255);
|
| 455 |
+
layer_info.write_u8(0);
|
| 456 |
+
layer_info.write_u8(if layer.hidden { 0x0A } else { 0x08 });
|
| 457 |
+
layer_info.write_u8(0);
|
| 458 |
+
layer_info.write_u32(extra.len() as u32);
|
| 459 |
+
layer_info.write_bytes(extra);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
for channels in &encoded_layers {
|
| 463 |
+
for channel in channels {
|
| 464 |
+
layer_info.write_u16(1);
|
| 465 |
+
layer_info.write_bytes(&channel.data);
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
layer_info.pad_to_multiple(4);
|
| 469 |
+
|
| 470 |
+
let mut full = PsdWriter::new();
|
| 471 |
+
full.write_u32(layer_info.len() as u32);
|
| 472 |
+
full.write_bytes(&layer_info.into_inner());
|
| 473 |
+
full.write_u32(0);
|
| 474 |
+
Ok(full.into_inner())
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
fn build_extra_data(layer: &ExportLayer) -> Result<Vec<u8>, PsdExportError> {
|
| 478 |
+
let mut extra = PsdWriter::new();
|
| 479 |
+
extra.write_u32(0);
|
| 480 |
+
extra.write_u32(0);
|
| 481 |
+
extra.write_pascal_string(&layer.name, 4);
|
| 482 |
+
|
| 483 |
+
if let Some(text) = layer.text.as_ref() {
|
| 484 |
+
write_additional_info_block(&mut extra, "luni", &luni_body(&layer.name), 4);
|
| 485 |
+
write_additional_info_block(&mut extra, "TySh", &tysh_body(text)?, 2);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
Ok(extra.into_inner())
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
fn luni_body(name: &str) -> Vec<u8> {
|
| 492 |
+
let mut body = PsdWriter::new();
|
| 493 |
+
body.write_unicode_string(name);
|
| 494 |
+
body.into_inner()
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
fn tysh_body(text: &TextLayerMetadata) -> Result<Vec<u8>, PsdExportError> {
|
| 498 |
+
let engine_data = encode_engine_data(&TextEngineSpec {
|
| 499 |
+
text: text.text.clone(),
|
| 500 |
+
font_name: text.font_name.clone(),
|
| 501 |
+
font_size: text.font_size,
|
| 502 |
+
color: text.color,
|
| 503 |
+
faux_bold: text.faux_bold,
|
| 504 |
+
faux_italic: text.faux_italic,
|
| 505 |
+
orientation: text.orientation,
|
| 506 |
+
justification: text.justification,
|
| 507 |
+
box_width: text.box_width,
|
| 508 |
+
box_height: text.box_height,
|
| 509 |
+
});
|
| 510 |
+
|
| 511 |
+
let bounds = bounds_descriptor(
|
| 512 |
+
"bounds",
|
| 513 |
+
text.bounds[0],
|
| 514 |
+
text.bounds[1],
|
| 515 |
+
text.bounds[2],
|
| 516 |
+
text.bounds[3],
|
| 517 |
+
);
|
| 518 |
+
let bounding_box = bounds_descriptor(
|
| 519 |
+
"boundingBox",
|
| 520 |
+
text.bounds[0],
|
| 521 |
+
text.bounds[1],
|
| 522 |
+
text.bounds[2],
|
| 523 |
+
text.bounds[3],
|
| 524 |
+
);
|
| 525 |
+
|
| 526 |
+
let text_descriptor = DescriptorObject::new("", "TxLr")
|
| 527 |
+
.with_item("Txt ", DescriptorValue::Text(text.text.clone()))
|
| 528 |
+
.with_item(
|
| 529 |
+
"textGridding",
|
| 530 |
+
DescriptorValue::Enum {
|
| 531 |
+
type_id: "textGridding".to_string(),
|
| 532 |
+
value: "None".to_string(),
|
| 533 |
+
},
|
| 534 |
+
)
|
| 535 |
+
.with_item(
|
| 536 |
+
"Ornt",
|
| 537 |
+
DescriptorValue::Enum {
|
| 538 |
+
type_id: "Ornt".to_string(),
|
| 539 |
+
value: match text.orientation {
|
| 540 |
+
TextOrientation::Horizontal => "Hrzn".to_string(),
|
| 541 |
+
TextOrientation::Vertical => "Vrtc".to_string(),
|
| 542 |
+
},
|
| 543 |
+
},
|
| 544 |
+
)
|
| 545 |
+
.with_item(
|
| 546 |
+
"AntA",
|
| 547 |
+
DescriptorValue::Enum {
|
| 548 |
+
type_id: "Annt".to_string(),
|
| 549 |
+
value: "antiAliasSharp".to_string(),
|
| 550 |
+
},
|
| 551 |
+
)
|
| 552 |
+
.with_item("bounds", DescriptorValue::Object(bounds))
|
| 553 |
+
.with_item("boundingBox", DescriptorValue::Object(bounding_box))
|
| 554 |
+
.with_item("TextIndex", DescriptorValue::Integer(text.index))
|
| 555 |
+
.with_item("EngineData", DescriptorValue::Raw(engine_data));
|
| 556 |
+
|
| 557 |
+
let warp_descriptor = DescriptorObject::new("", "warp")
|
| 558 |
+
.with_item(
|
| 559 |
+
"warpStyle",
|
| 560 |
+
DescriptorValue::Enum {
|
| 561 |
+
type_id: "warpStyle".to_string(),
|
| 562 |
+
value: "warpNone".to_string(),
|
| 563 |
+
},
|
| 564 |
+
)
|
| 565 |
+
.with_item("warpValue", DescriptorValue::Double(0.0))
|
| 566 |
+
.with_item("warpPerspective", DescriptorValue::Double(0.0))
|
| 567 |
+
.with_item("warpPerspectiveOther", DescriptorValue::Double(0.0))
|
| 568 |
+
.with_item(
|
| 569 |
+
"warpRotate",
|
| 570 |
+
DescriptorValue::Enum {
|
| 571 |
+
type_id: "Ornt".to_string(),
|
| 572 |
+
value: match text.orientation {
|
| 573 |
+
TextOrientation::Horizontal => "Hrzn".to_string(),
|
| 574 |
+
TextOrientation::Vertical => "Vrtc".to_string(),
|
| 575 |
+
},
|
| 576 |
+
},
|
| 577 |
+
)
|
| 578 |
+
.with_item(
|
| 579 |
+
"bounds",
|
| 580 |
+
DescriptorValue::Object(bounds_descriptor(
|
| 581 |
+
"bounds",
|
| 582 |
+
text.bounds[0],
|
| 583 |
+
text.bounds[1],
|
| 584 |
+
text.bounds[2],
|
| 585 |
+
text.bounds[3],
|
| 586 |
+
)),
|
| 587 |
+
);
|
| 588 |
+
|
| 589 |
+
let mut body = PsdWriter::new();
|
| 590 |
+
body.write_i16(1);
|
| 591 |
+
for value in text.transform {
|
| 592 |
+
body.write_f64(value);
|
| 593 |
+
}
|
| 594 |
+
body.write_i16(50);
|
| 595 |
+
write_versioned_descriptor(&mut body, &text_descriptor)?;
|
| 596 |
+
body.write_i16(1);
|
| 597 |
+
write_versioned_descriptor(&mut body, &warp_descriptor)?;
|
| 598 |
+
for value in text.bounds {
|
| 599 |
+
body.write_f32(value as f32);
|
| 600 |
+
}
|
| 601 |
+
Ok(body.into_inner())
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
fn write_additional_info_block(writer: &mut PsdWriter, key: &str, body: &[u8], alignment: usize) {
|
| 605 |
+
let padding = (alignment - (body.len() % alignment)) % alignment;
|
| 606 |
+
|
| 607 |
+
writer.write_signature("8BIM");
|
| 608 |
+
writer.write_signature(key);
|
| 609 |
+
writer.write_u32((body.len() + padding) as u32);
|
| 610 |
+
writer.write_bytes(body);
|
| 611 |
+
writer.write_zeroes(padding);
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
fn write_image_data(
|
| 615 |
+
writer: &mut PsdWriter,
|
| 616 |
+
image: &RgbaImage,
|
| 617 |
+
name: &str,
|
| 618 |
+
) -> Result<(), PsdExportError> {
|
| 619 |
+
writer.write_u16(1);
|
| 620 |
+
let channels = encode_image_rle(
|
| 621 |
+
image,
|
| 622 |
+
&[
|
| 623 |
+
ChannelId::Red,
|
| 624 |
+
ChannelId::Green,
|
| 625 |
+
ChannelId::Blue,
|
| 626 |
+
ChannelId::Alpha,
|
| 627 |
+
],
|
| 628 |
+
name,
|
| 629 |
+
)?;
|
| 630 |
+
|
| 631 |
+
let row_lengths_len = image.height() as usize * 2;
|
| 632 |
+
for channel in &channels {
|
| 633 |
+
writer.write_bytes(&channel.data[..row_lengths_len]);
|
| 634 |
+
}
|
| 635 |
+
for channel in &channels {
|
| 636 |
+
writer.write_bytes(&channel.data[row_lengths_len..]);
|
| 637 |
+
}
|
| 638 |
+
Ok(())
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
fn infer_orientation(block: &TextBlock) -> TextOrientation {
|
| 642 |
+
match block.rendered_direction.or(block.source_direction) {
|
| 643 |
+
Some(TextDirection::Vertical) => TextOrientation::Vertical,
|
| 644 |
+
_ => TextOrientation::Horizontal,
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
fn infer_justification(
|
| 649 |
+
block: &TextBlock,
|
| 650 |
+
text: &str,
|
| 651 |
+
orientation: TextOrientation,
|
| 652 |
+
) -> TextJustification {
|
| 653 |
+
if let Some(alignment) = block.style.as_ref().and_then(|style| style.text_align) {
|
| 654 |
+
return match alignment {
|
| 655 |
+
TextAlign::Left => TextJustification::Left,
|
| 656 |
+
TextAlign::Center => TextJustification::Center,
|
| 657 |
+
TextAlign::Right => TextJustification::Right,
|
| 658 |
+
};
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
if orientation == TextOrientation::Horizontal && is_probably_latin(text) {
|
| 662 |
+
TextJustification::Center
|
| 663 |
+
} else {
|
| 664 |
+
TextJustification::Left
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
fn infer_font_name(block: &TextBlock) -> String {
|
| 669 |
+
if let Some(style_font) = block.style.as_ref().and_then(|style| {
|
| 670 |
+
style
|
| 671 |
+
.font_families
|
| 672 |
+
.iter()
|
| 673 |
+
.find(|font| !font.trim().is_empty())
|
| 674 |
+
}) {
|
| 675 |
+
return style_font.trim().to_string();
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
if let Some(predicted_font) = block.font_prediction.as_ref().and_then(|prediction| {
|
| 679 |
+
prediction
|
| 680 |
+
.named_fonts
|
| 681 |
+
.iter()
|
| 682 |
+
.find(|font| !font.name.trim().is_empty())
|
| 683 |
+
}) {
|
| 684 |
+
return predicted_font.name.trim().to_string();
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
"ArialMT".to_string()
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
fn infer_font_size(block: &TextBlock) -> f64 {
|
| 691 |
+
if let Some(size) = block.style.as_ref().and_then(|style| style.font_size)
|
| 692 |
+
&& size.is_finite() && size > 0.0 {
|
| 693 |
+
return size as f64;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
if let Some(prediction) = block.font_prediction.as_ref()
|
| 697 |
+
&& prediction.font_size_px.is_finite() && prediction.font_size_px > 0.0 {
|
| 698 |
+
return prediction.font_size_px as f64;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
if let Some(size) = block.detected_font_size_px
|
| 702 |
+
&& size.is_finite() && size > 0.0 {
|
| 703 |
+
return size as f64;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
f64::max(6.0, f64::from(block.width.min(block.height)) * 0.7)
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
fn infer_color(block: &TextBlock) -> [u8; 4] {
|
| 710 |
+
if let Some(style) = block.style.as_ref() {
|
| 711 |
+
return style.color;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
if let Some(FontPrediction { text_color, .. }) = block.font_prediction.as_ref() {
|
| 715 |
+
return [text_color[0], text_color[1], text_color[2], 255];
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
[0, 0, 0, 255]
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
fn contains_cjk(text: &str) -> bool {
|
| 722 |
+
text.chars().any(|ch| {
|
| 723 |
+
matches!(
|
| 724 |
+
ch as u32,
|
| 725 |
+
0x3040..=0x30FF
|
| 726 |
+
| 0x3400..=0x4DBF
|
| 727 |
+
| 0x4E00..=0x9FFF
|
| 728 |
+
| 0xAC00..=0xD7AF
|
| 729 |
+
| 0xF900..=0xFAFF
|
| 730 |
+
| 0xFF66..=0xFF9D
|
| 731 |
+
)
|
| 732 |
+
})
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
fn is_probably_latin(text: &str) -> bool {
|
| 736 |
+
text.chars().any(|ch| ch.is_ascii_alphabetic()) && !contains_cjk(text)
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
#[cfg(test)]
|
| 740 |
+
mod tests {
|
| 741 |
+
use image::{Rgba, RgbaImage};
|
| 742 |
+
|
| 743 |
+
use crate::writer::PsdWriter;
|
| 744 |
+
|
| 745 |
+
use koharu_types::{TextBlock, TextDirection};
|
| 746 |
+
|
| 747 |
+
use super::{
|
| 748 |
+
TextOrientation, contains_cjk, infer_font_name, infer_orientation, is_probably_latin,
|
| 749 |
+
place_on_canvas, write_image_data,
|
| 750 |
+
};
|
| 751 |
+
|
| 752 |
+
#[test]
|
| 753 |
+
fn place_on_canvas_keeps_size_stable() {
|
| 754 |
+
let image = RgbaImage::new(4, 4);
|
| 755 |
+
let canvas = place_on_canvas(&image, 8, 6);
|
| 756 |
+
assert_eq!(canvas.width(), 8);
|
| 757 |
+
assert_eq!(canvas.height(), 6);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
#[test]
|
| 761 |
+
fn language_heuristics_detect_cjk_vs_latin() {
|
| 762 |
+
assert!(contains_cjk("縦書き"));
|
| 763 |
+
assert!(is_probably_latin("HELLO"));
|
| 764 |
+
assert!(!is_probably_latin("縦書き"));
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
#[test]
|
| 768 |
+
fn orientation_uses_rendered_direction_not_geometry() {
|
| 769 |
+
let tall_english_block = TextBlock {
|
| 770 |
+
width: 40.0,
|
| 771 |
+
height: 120.0,
|
| 772 |
+
translation: Some("HELLO".to_string()),
|
| 773 |
+
..Default::default()
|
| 774 |
+
};
|
| 775 |
+
assert_eq!(
|
| 776 |
+
infer_orientation(&tall_english_block),
|
| 777 |
+
TextOrientation::Horizontal
|
| 778 |
+
);
|
| 779 |
+
|
| 780 |
+
let vertical_block = TextBlock {
|
| 781 |
+
rendered_direction: Some(TextDirection::Vertical),
|
| 782 |
+
..Default::default()
|
| 783 |
+
};
|
| 784 |
+
assert_eq!(
|
| 785 |
+
infer_orientation(&vertical_block),
|
| 786 |
+
TextOrientation::Vertical
|
| 787 |
+
);
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
#[test]
|
| 791 |
+
fn composite_image_data_groups_row_tables_before_channel_payloads() {
|
| 792 |
+
let mut image = RgbaImage::new(1, 1);
|
| 793 |
+
image.put_pixel(0, 0, Rgba([1, 2, 3, 4]));
|
| 794 |
+
|
| 795 |
+
let mut writer = PsdWriter::new();
|
| 796 |
+
write_image_data(&mut writer, &image, "Merged Composite").expect("write image data");
|
| 797 |
+
|
| 798 |
+
assert_eq!(
|
| 799 |
+
writer.into_inner(),
|
| 800 |
+
vec![
|
| 801 |
+
0, 1, // compression
|
| 802 |
+
0, 2, 0, 2, 0, 2, 0, 2, // row lengths
|
| 803 |
+
0, 1, // red
|
| 804 |
+
0, 2, // green
|
| 805 |
+
0, 3, // blue
|
| 806 |
+
0, 4, // alpha
|
| 807 |
+
]
|
| 808 |
+
);
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
#[test]
|
| 812 |
+
fn style_font_name_is_used_for_editable_export() {
|
| 813 |
+
let block = TextBlock {
|
| 814 |
+
style: Some(koharu_types::TextStyle {
|
| 815 |
+
font_families: vec!["ArialMT".to_string()],
|
| 816 |
+
font_size: None,
|
| 817 |
+
color: [0, 0, 0, 255],
|
| 818 |
+
effect: None,
|
| 819 |
+
stroke: None,
|
| 820 |
+
text_align: None,
|
| 821 |
+
}),
|
| 822 |
+
..Default::default()
|
| 823 |
+
};
|
| 824 |
+
|
| 825 |
+
assert_eq!(infer_font_name(&block), "ArialMT");
|
| 826 |
+
}
|
| 827 |
+
}
|
koharu-psd/src/lib.rs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod descriptor;
|
| 2 |
+
mod engine_data;
|
| 3 |
+
mod error;
|
| 4 |
+
mod export;
|
| 5 |
+
mod packbits;
|
| 6 |
+
mod writer;
|
| 7 |
+
|
| 8 |
+
pub use error::PsdExportError;
|
| 9 |
+
pub use export::{PsdExportOptions, TextLayerMode, export_document, write_document};
|
koharu-psd/src/packbits.rs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use image::RgbaImage;
|
| 2 |
+
|
| 3 |
+
use crate::error::PsdExportError;
|
| 4 |
+
|
| 5 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 6 |
+
pub enum ChannelId {
|
| 7 |
+
Red,
|
| 8 |
+
Green,
|
| 9 |
+
Blue,
|
| 10 |
+
Alpha,
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
impl ChannelId {
|
| 14 |
+
pub fn psd_id(self) -> i16 {
|
| 15 |
+
match self {
|
| 16 |
+
Self::Red => 0,
|
| 17 |
+
Self::Green => 1,
|
| 18 |
+
Self::Blue => 2,
|
| 19 |
+
Self::Alpha => -1,
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
fn rgba_offset(self) -> usize {
|
| 24 |
+
match self {
|
| 25 |
+
Self::Red => 0,
|
| 26 |
+
Self::Green => 1,
|
| 27 |
+
Self::Blue => 2,
|
| 28 |
+
Self::Alpha => 3,
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
#[derive(Debug, Clone)]
|
| 34 |
+
pub struct EncodedChannel {
|
| 35 |
+
pub channel_id: i16,
|
| 36 |
+
pub data: Vec<u8>,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
pub fn encode_image_rle(
|
| 40 |
+
image: &RgbaImage,
|
| 41 |
+
channels: &[ChannelId],
|
| 42 |
+
layer_name: &str,
|
| 43 |
+
) -> Result<Vec<EncodedChannel>, PsdExportError> {
|
| 44 |
+
let mut encoded = Vec::with_capacity(channels.len());
|
| 45 |
+
|
| 46 |
+
for channel in channels {
|
| 47 |
+
let mut lengths = Vec::with_capacity(image.height() as usize);
|
| 48 |
+
let mut data = Vec::new();
|
| 49 |
+
let width = image.width() as usize;
|
| 50 |
+
let offset = channel.rgba_offset();
|
| 51 |
+
|
| 52 |
+
for y in 0..image.height() {
|
| 53 |
+
let start = data.len();
|
| 54 |
+
let mut row = Vec::with_capacity(width);
|
| 55 |
+
for x in 0..image.width() {
|
| 56 |
+
row.push(image.get_pixel(x, y).0[offset]);
|
| 57 |
+
}
|
| 58 |
+
encode_row(&row, &mut data);
|
| 59 |
+
let row_length = data.len() - start;
|
| 60 |
+
if row_length > u16::MAX as usize {
|
| 61 |
+
return Err(PsdExportError::InvalidChannelEncoding {
|
| 62 |
+
layer: layer_name.to_string(),
|
| 63 |
+
row: y as usize,
|
| 64 |
+
length: row_length,
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
lengths.push(row_length as u16);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
let mut out = Vec::with_capacity(lengths.len() * 2 + data.len());
|
| 71 |
+
for length in lengths {
|
| 72 |
+
out.extend_from_slice(&length.to_be_bytes());
|
| 73 |
+
}
|
| 74 |
+
out.extend_from_slice(&data);
|
| 75 |
+
encoded.push(EncodedChannel {
|
| 76 |
+
channel_id: channel.psd_id(),
|
| 77 |
+
data: out,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
Ok(encoded)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
fn encode_row(row: &[u8], out: &mut Vec<u8>) {
|
| 85 |
+
let mut i = 0usize;
|
| 86 |
+
while i < row.len() {
|
| 87 |
+
let run_len = repeated_run_len(row, i);
|
| 88 |
+
if run_len >= 3 {
|
| 89 |
+
let chunk = run_len.min(128);
|
| 90 |
+
out.push((1i16 - chunk as i16) as u8);
|
| 91 |
+
out.push(row[i]);
|
| 92 |
+
i += chunk;
|
| 93 |
+
continue;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
let literal_start = i;
|
| 97 |
+
let mut literal_len = 0usize;
|
| 98 |
+
while i < row.len() && literal_len < 128 {
|
| 99 |
+
let next_run = repeated_run_len(row, i);
|
| 100 |
+
if next_run >= 3 {
|
| 101 |
+
break;
|
| 102 |
+
}
|
| 103 |
+
i += 1;
|
| 104 |
+
literal_len += 1;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
out.push((literal_len - 1) as u8);
|
| 108 |
+
out.extend_from_slice(&row[literal_start..literal_start + literal_len]);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
fn repeated_run_len(row: &[u8], start: usize) -> usize {
|
| 113 |
+
let value = row[start];
|
| 114 |
+
let mut len = 1usize;
|
| 115 |
+
while start + len < row.len() && row[start + len] == value && len < 128 {
|
| 116 |
+
len += 1;
|
| 117 |
+
}
|
| 118 |
+
len
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
#[cfg(test)]
|
| 122 |
+
mod tests {
|
| 123 |
+
use image::{Rgba, RgbaImage};
|
| 124 |
+
|
| 125 |
+
use super::{ChannelId, encode_image_rle};
|
| 126 |
+
|
| 127 |
+
#[test]
|
| 128 |
+
fn packbits_repeated_rows_encode_with_short_repeat_packets() {
|
| 129 |
+
let mut image = RgbaImage::new(4, 1);
|
| 130 |
+
for x in 0..4 {
|
| 131 |
+
image.put_pixel(x, 0, Rgba([10, 0, 0, 255]));
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
let channels = encode_image_rle(&image, &[ChannelId::Red], "row").expect("encode");
|
| 135 |
+
assert_eq!(channels.len(), 1);
|
| 136 |
+
assert_eq!(channels[0].data, vec![0, 2, 253, 10]);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
#[test]
|
| 140 |
+
fn packbits_literal_rows_keep_original_order() {
|
| 141 |
+
let mut image = RgbaImage::new(4, 1);
|
| 142 |
+
let values = [1u8, 2, 3, 4];
|
| 143 |
+
for (x, value) in values.into_iter().enumerate() {
|
| 144 |
+
image.put_pixel(x as u32, 0, Rgba([value, 0, 0, 255]));
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
let channels = encode_image_rle(&image, &[ChannelId::Red], "row").expect("encode");
|
| 148 |
+
assert_eq!(channels[0].data, vec![0, 5, 3, 1, 2, 3, 4]);
|
| 149 |
+
}
|
| 150 |
+
}
|
koharu-psd/src/writer.rs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#[derive(Debug, Default, Clone)]
|
| 2 |
+
pub struct PsdWriter {
|
| 3 |
+
bytes: Vec<u8>,
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
impl PsdWriter {
|
| 7 |
+
pub fn new() -> Self {
|
| 8 |
+
Self::default()
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
pub fn len(&self) -> usize {
|
| 12 |
+
self.bytes.len()
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
pub fn into_inner(self) -> Vec<u8> {
|
| 16 |
+
self.bytes
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
pub fn write_bytes(&mut self, bytes: &[u8]) {
|
| 20 |
+
self.bytes.extend_from_slice(bytes);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
pub fn write_zeroes(&mut self, count: usize) {
|
| 24 |
+
self.bytes.resize(self.bytes.len() + count, 0);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
pub fn write_u8(&mut self, value: u8) {
|
| 28 |
+
self.bytes.push(value);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
pub fn write_i16(&mut self, value: i16) {
|
| 32 |
+
self.write_bytes(&value.to_be_bytes());
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
pub fn write_u16(&mut self, value: u16) {
|
| 36 |
+
self.write_bytes(&value.to_be_bytes());
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
pub fn write_i32(&mut self, value: i32) {
|
| 40 |
+
self.write_bytes(&value.to_be_bytes());
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
pub fn write_u32(&mut self, value: u32) {
|
| 44 |
+
self.write_bytes(&value.to_be_bytes());
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
pub fn write_f32(&mut self, value: f32) {
|
| 48 |
+
self.write_bytes(&value.to_be_bytes());
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
pub fn write_f64(&mut self, value: f64) {
|
| 52 |
+
self.write_bytes(&value.to_be_bytes());
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
pub fn write_signature(&mut self, signature: &str) {
|
| 56 |
+
assert_eq!(signature.len(), 4, "PSD signatures must be 4 bytes");
|
| 57 |
+
self.write_bytes(signature.as_bytes());
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
pub fn write_ascii_or_class_id(&mut self, value: &str) {
|
| 61 |
+
let treat_as_class_id =
|
| 62 |
+
value.len() == 4 && !matches!(value, "warp" | "time" | "hold" | "list");
|
| 63 |
+
if treat_as_class_id {
|
| 64 |
+
self.write_i32(0);
|
| 65 |
+
self.write_signature(value);
|
| 66 |
+
} else {
|
| 67 |
+
self.write_i32(value.len() as i32);
|
| 68 |
+
self.write_bytes(value.as_bytes());
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
pub fn write_pascal_string(&mut self, text: &str, pad_to: usize) {
|
| 73 |
+
let ascii = ascii_legacy(text, 255);
|
| 74 |
+
self.write_u8(ascii.len() as u8);
|
| 75 |
+
self.write_bytes(ascii.as_bytes());
|
| 76 |
+
|
| 77 |
+
let mut total = ascii.len() + 1;
|
| 78 |
+
while !total.is_multiple_of(pad_to) {
|
| 79 |
+
self.write_u8(0);
|
| 80 |
+
total += 1;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
pub fn write_unicode_string(&mut self, text: &str) {
|
| 85 |
+
let utf16: Vec<u16> = text.encode_utf16().collect();
|
| 86 |
+
self.write_u32(utf16.len() as u32);
|
| 87 |
+
for unit in utf16 {
|
| 88 |
+
self.write_u16(unit);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
pub fn write_unicode_string_with_padding(&mut self, text: &str) {
|
| 93 |
+
let utf16: Vec<u16> = text.encode_utf16().collect();
|
| 94 |
+
self.write_u32((utf16.len() + 1) as u32);
|
| 95 |
+
for unit in utf16 {
|
| 96 |
+
self.write_u16(unit);
|
| 97 |
+
}
|
| 98 |
+
self.write_u16(0);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
pub fn pad_to_multiple(&mut self, multiple: usize) {
|
| 102 |
+
while !self.bytes.len().is_multiple_of(multiple) {
|
| 103 |
+
self.write_u8(0);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
pub fn ascii_legacy(text: &str, max_bytes: usize) -> String {
|
| 109 |
+
let mut out = String::new();
|
| 110 |
+
for ch in text.chars() {
|
| 111 |
+
let mapped = if ch.is_ascii() { ch } else { '?' };
|
| 112 |
+
if out.len() + mapped.len_utf8() > max_bytes {
|
| 113 |
+
break;
|
| 114 |
+
}
|
| 115 |
+
out.push(mapped);
|
| 116 |
+
}
|
| 117 |
+
out
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#[cfg(test)]
|
| 121 |
+
mod tests {
|
| 122 |
+
use super::{PsdWriter, ascii_legacy};
|
| 123 |
+
|
| 124 |
+
#[test]
|
| 125 |
+
fn ascii_legacy_replaces_unicode_and_truncates() {
|
| 126 |
+
assert_eq!(ascii_legacy("hello世界", 8), "hello??");
|
| 127 |
+
assert_eq!(ascii_legacy("abcdef", 4), "abcd");
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
#[test]
|
| 131 |
+
fn pascal_string_pads_to_requested_multiple() {
|
| 132 |
+
let mut writer = PsdWriter::new();
|
| 133 |
+
writer.write_pascal_string("abc", 4);
|
| 134 |
+
assert_eq!(writer.into_inner(), vec![3, b'a', b'b', b'c']);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#[test]
|
| 138 |
+
fn unicode_string_with_padding_appends_trailing_nul() {
|
| 139 |
+
let mut writer = PsdWriter::new();
|
| 140 |
+
writer.write_unicode_string_with_padding("A");
|
| 141 |
+
assert_eq!(writer.into_inner(), vec![0, 0, 0, 2, 0, 65, 0, 0]);
|
| 142 |
+
}
|
| 143 |
+
}
|
koharu-psd/tests/export.rs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::PathBuf;
|
| 2 |
+
|
| 3 |
+
use image::{DynamicImage, GrayImage, Rgba, RgbaImage};
|
| 4 |
+
use koharu_psd::{PsdExportError, PsdExportOptions, TextLayerMode, export_document};
|
| 5 |
+
use koharu_types::{
|
| 6 |
+
Document, FontPrediction, NamedFontPrediction, SerializableDynamicImage, TextAlign, TextBlock,
|
| 7 |
+
TextDirection, TextShaderEffect, TextStrokeStyle, TextStyle,
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
fn rgba_image(width: u32, height: u32, color: [u8; 4]) -> SerializableDynamicImage {
|
| 11 |
+
SerializableDynamicImage(DynamicImage::ImageRgba8(RgbaImage::from_pixel(
|
| 12 |
+
width,
|
| 13 |
+
height,
|
| 14 |
+
Rgba(color),
|
| 15 |
+
)))
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
fn gray_image(width: u32, height: u32, value: u8) -> SerializableDynamicImage {
|
| 19 |
+
SerializableDynamicImage(DynamicImage::ImageLuma8(GrayImage::from_pixel(
|
| 20 |
+
width,
|
| 21 |
+
height,
|
| 22 |
+
image::Luma([value]),
|
| 23 |
+
)))
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
fn sample_document() -> Document {
|
| 27 |
+
Document {
|
| 28 |
+
id: "doc-1".to_string(),
|
| 29 |
+
path: PathBuf::from("sample.png"),
|
| 30 |
+
name: "sample".to_string(),
|
| 31 |
+
image: rgba_image(16, 12, [240, 240, 240, 255]),
|
| 32 |
+
width: 16,
|
| 33 |
+
height: 12,
|
| 34 |
+
revision: 0,
|
| 35 |
+
text_blocks: vec![
|
| 36 |
+
TextBlock {
|
| 37 |
+
id: "block-h".to_string(),
|
| 38 |
+
x: 2.0,
|
| 39 |
+
y: 3.0,
|
| 40 |
+
width: 8.0,
|
| 41 |
+
height: 4.0,
|
| 42 |
+
translation: Some("HELLO".to_string()),
|
| 43 |
+
style: Some(TextStyle {
|
| 44 |
+
font_families: vec!["ArialMT".to_string()],
|
| 45 |
+
font_size: Some(14.0),
|
| 46 |
+
color: [1, 2, 3, 255],
|
| 47 |
+
effect: Some(TextShaderEffect {
|
| 48 |
+
italic: false,
|
| 49 |
+
bold: true,
|
| 50 |
+
}),
|
| 51 |
+
stroke: Some(TextStrokeStyle::default()),
|
| 52 |
+
text_align: Some(TextAlign::Center),
|
| 53 |
+
}),
|
| 54 |
+
rendered: Some(rgba_image(8, 4, [255, 0, 0, 200])),
|
| 55 |
+
..Default::default()
|
| 56 |
+
},
|
| 57 |
+
TextBlock {
|
| 58 |
+
id: "block-v".to_string(),
|
| 59 |
+
x: 10.0,
|
| 60 |
+
y: 1.0,
|
| 61 |
+
width: 3.0,
|
| 62 |
+
height: 8.0,
|
| 63 |
+
translation: Some("縦書き".to_string()),
|
| 64 |
+
source_direction: Some(TextDirection::Vertical),
|
| 65 |
+
font_prediction: Some(FontPrediction {
|
| 66 |
+
named_fonts: vec![NamedFontPrediction {
|
| 67 |
+
index: 0,
|
| 68 |
+
name: "YuGothic-Regular".to_string(),
|
| 69 |
+
language: Some("ja".to_string()),
|
| 70 |
+
probability: 0.9,
|
| 71 |
+
serif: false,
|
| 72 |
+
}],
|
| 73 |
+
text_color: [20, 40, 60],
|
| 74 |
+
font_size_px: 13.0,
|
| 75 |
+
angle_deg: 12.0,
|
| 76 |
+
..Default::default()
|
| 77 |
+
}),
|
| 78 |
+
..Default::default()
|
| 79 |
+
},
|
| 80 |
+
],
|
| 81 |
+
segment: Some(gray_image(16, 12, 96)),
|
| 82 |
+
inpainted: Some(rgba_image(16, 12, [220, 220, 220, 255])),
|
| 83 |
+
rendered: Some(rgba_image(16, 12, [200, 210, 220, 255])),
|
| 84 |
+
brush_layer: Some(rgba_image(16, 12, [0, 255, 0, 100])),
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
fn count_occurrences(bytes: &[u8], needle: &[u8]) -> usize {
|
| 89 |
+
bytes
|
| 90 |
+
.windows(needle.len())
|
| 91 |
+
.filter(|window| *window == needle)
|
| 92 |
+
.count()
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
fn layer_count(bytes: &[u8]) -> i16 {
|
| 96 |
+
i16::from_be_bytes([bytes[42], bytes[43]])
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
#[test]
|
| 100 |
+
fn exports_layered_psd_with_warning_free_raster_text_by_default() {
|
| 101 |
+
let document = sample_document();
|
| 102 |
+
let bytes = export_document(&document, &PsdExportOptions::default()).expect("export");
|
| 103 |
+
|
| 104 |
+
assert_eq!(&bytes[..4], b"8BPS");
|
| 105 |
+
assert_eq!(&bytes[12..14], &[0, 4]);
|
| 106 |
+
assert_eq!(&bytes[14..18], &[0, 0, 0, 12]);
|
| 107 |
+
assert_eq!(&bytes[18..22], &[0, 0, 0, 16]);
|
| 108 |
+
assert_eq!(&bytes[22..24], &[0, 8]);
|
| 109 |
+
assert_eq!(&bytes[24..26], &[0, 3]);
|
| 110 |
+
assert_eq!(layer_count(&bytes), -6);
|
| 111 |
+
assert_eq!(count_occurrences(&bytes, b"luni"), 0);
|
| 112 |
+
assert_eq!(count_occurrences(&bytes, b"TySh"), 0);
|
| 113 |
+
assert!(
|
| 114 |
+
bytes
|
| 115 |
+
.windows("TL 001 block-h".len())
|
| 116 |
+
.any(|window| window == b"TL 001 block-h")
|
| 117 |
+
);
|
| 118 |
+
assert!(
|
| 119 |
+
bytes
|
| 120 |
+
.windows("TL 002 block-v".len())
|
| 121 |
+
.any(|window| window == b"TL 002 block-v")
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#[test]
|
| 126 |
+
fn editable_text_layers_are_opt_in() {
|
| 127 |
+
let document = sample_document();
|
| 128 |
+
let options = PsdExportOptions {
|
| 129 |
+
text_layer_mode: TextLayerMode::Editable,
|
| 130 |
+
..Default::default()
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
let bytes = export_document(&document, &options).expect("export");
|
| 134 |
+
assert_eq!(count_occurrences(&bytes, b"luni"), 2);
|
| 135 |
+
assert_eq!(count_occurrences(&bytes, b"TySh"), 2);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
#[test]
|
| 139 |
+
fn parse_smoke_test_uses_reader_crate_for_basic_validation() {
|
| 140 |
+
let document = sample_document();
|
| 141 |
+
let bytes = export_document(&document, &PsdExportOptions::default()).expect("export");
|
| 142 |
+
|
| 143 |
+
let parsed = psd::Psd::from_bytes(&bytes).expect("parse");
|
| 144 |
+
assert_eq!(parsed.width(), 16);
|
| 145 |
+
assert_eq!(parsed.height(), 12);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
#[test]
|
| 149 |
+
fn empty_translations_are_skipped() {
|
| 150 |
+
let mut document = sample_document();
|
| 151 |
+
document.text_blocks.push(TextBlock {
|
| 152 |
+
id: "block-empty".to_string(),
|
| 153 |
+
x: 0.0,
|
| 154 |
+
y: 0.0,
|
| 155 |
+
width: 4.0,
|
| 156 |
+
height: 4.0,
|
| 157 |
+
translation: Some(" ".to_string()),
|
| 158 |
+
..Default::default()
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
let bytes = export_document(&document, &PsdExportOptions::default()).expect("export");
|
| 162 |
+
assert_eq!(layer_count(&bytes), -6);
|
| 163 |
+
assert_eq!(count_occurrences(&bytes, b"TySh"), 0);
|
| 164 |
+
assert!(
|
| 165 |
+
!bytes
|
| 166 |
+
.windows("TL 003 block-empty".len())
|
| 167 |
+
.any(|window| window == b"TL 003 block-empty")
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
#[test]
|
| 172 |
+
fn dimensions_above_classic_psd_limit_fail() {
|
| 173 |
+
let mut document = sample_document();
|
| 174 |
+
document.width = 30_001;
|
| 175 |
+
|
| 176 |
+
let error = export_document(&document, &PsdExportOptions::default()).expect_err("too large");
|
| 177 |
+
assert!(matches!(
|
| 178 |
+
error,
|
| 179 |
+
PsdExportError::UnsupportedDimensions {
|
| 180 |
+
width: 30_001,
|
| 181 |
+
height: 12
|
| 182 |
+
}
|
| 183 |
+
));
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
#[test]
|
| 187 |
+
fn missing_rendered_text_bitmap_still_exports_editable_layer() {
|
| 188 |
+
let mut document = sample_document();
|
| 189 |
+
document.text_blocks[0].rendered = None;
|
| 190 |
+
let options = PsdExportOptions {
|
| 191 |
+
text_layer_mode: TextLayerMode::Editable,
|
| 192 |
+
..Default::default()
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
let bytes = export_document(&document, &options).expect("export");
|
| 196 |
+
assert_eq!(count_occurrences(&bytes, b"TySh"), 2);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
#[test]
|
| 200 |
+
fn optional_helper_layers_can_be_disabled() {
|
| 201 |
+
let document = sample_document();
|
| 202 |
+
let options = PsdExportOptions {
|
| 203 |
+
include_original: false,
|
| 204 |
+
include_inpainted: false,
|
| 205 |
+
include_segment_mask: false,
|
| 206 |
+
include_brush_layer: false,
|
| 207 |
+
text_layer_mode: TextLayerMode::Rasterized,
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
let bytes = export_document(&document, &options).expect("export");
|
| 211 |
+
assert_eq!(layer_count(&bytes), -2);
|
| 212 |
+
assert_eq!(count_occurrences(&bytes, b"luni"), 0);
|
| 213 |
+
assert_eq!(count_occurrences(&bytes, b"TySh"), 0);
|
| 214 |
+
}
|
koharu-renderer/Cargo.toml
CHANGED
|
@@ -21,7 +21,7 @@ serde = { workspace = true }
|
|
| 21 |
skrifa = { workspace = true }
|
| 22 |
harfrust = { workspace = true }
|
| 23 |
icu = { workspace = true }
|
| 24 |
-
|
| 25 |
fontdue = { workspace = true }
|
| 26 |
tiny-skia = { workspace = true }
|
| 27 |
tracing = { workspace = true }
|
|
|
|
| 21 |
skrifa = { workspace = true }
|
| 22 |
harfrust = { workspace = true }
|
| 23 |
icu = { workspace = true }
|
| 24 |
+
fontdb = { workspace = true }
|
| 25 |
fontdue = { workspace = true }
|
| 26 |
tiny-skia = { workspace = true }
|
| 27 |
tracing = { workspace = true }
|
koharu-renderer/benches/rendering.rs
CHANGED
|
@@ -4,7 +4,7 @@ use std::hint::black_box;
|
|
| 4 |
|
| 5 |
use criterion::{Criterion, criterion_group, criterion_main};
|
| 6 |
use koharu_renderer::{
|
| 7 |
-
font::
|
| 8 |
layout::{TextLayout, WritingMode},
|
| 9 |
renderer::{RenderOptions, TinySkiaRenderer},
|
| 10 |
};
|
|
@@ -15,8 +15,14 @@ const SAMPLE_TEXT: &str = "The quick brown fox jumps over the lazy dog.";
|
|
| 15 |
fn rendering_benchmark(c: &mut Criterion) {
|
| 16 |
let mut fontbook = FontBook::new();
|
| 17 |
let renderer = TinySkiaRenderer::new().expect("Failed to create renderer");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
let font = fontbook
|
| 19 |
-
.query(&
|
| 20 |
.expect("Failed to find font");
|
| 21 |
let _ = font.fontdue().expect("Failed to load font");
|
| 22 |
let layout = TextLayout::new(&font, Some(FONT_SIZE))
|
|
|
|
| 4 |
|
| 5 |
use criterion::{Criterion, criterion_group, criterion_main};
|
| 6 |
use koharu_renderer::{
|
| 7 |
+
font::FontBook,
|
| 8 |
layout::{TextLayout, WritingMode},
|
| 9 |
renderer::{RenderOptions, TinySkiaRenderer},
|
| 10 |
};
|
|
|
|
| 15 |
fn rendering_benchmark(c: &mut Criterion) {
|
| 16 |
let mut fontbook = FontBook::new();
|
| 17 |
let renderer = TinySkiaRenderer::new().expect("Failed to create renderer");
|
| 18 |
+
let post_script_name = fontbook
|
| 19 |
+
.all_families()
|
| 20 |
+
.into_iter()
|
| 21 |
+
.find(|face| !face.post_script_name.is_empty())
|
| 22 |
+
.map(|face| face.post_script_name)
|
| 23 |
+
.expect("Failed to find font");
|
| 24 |
let font = fontbook
|
| 25 |
+
.query(&post_script_name)
|
| 26 |
.expect("Failed to find font");
|
| 27 |
let _ = font.fontdue().expect("Failed to load font");
|
| 28 |
let layout = TextLayout::new(&font, Some(FONT_SIZE))
|
koharu-renderer/src/facade.rs
CHANGED
|
@@ -5,12 +5,12 @@ use image::{DynamicImage, GrayImage, imageops};
|
|
| 5 |
use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
|
| 6 |
|
| 7 |
use koharu_types::{
|
| 8 |
-
Document, SerializableDynamicImage, TextAlign, TextBlock, TextShaderEffect,
|
| 9 |
-
TextStyle,
|
| 10 |
};
|
| 11 |
|
| 12 |
use crate::{
|
| 13 |
-
font::{
|
| 14 |
layout::{LayoutRun, TextLayout, WritingMode},
|
| 15 |
renderer::{RenderOptions, RenderStrokeOptions, TinySkiaRenderer},
|
| 16 |
text::{
|
|
@@ -43,14 +43,26 @@ impl Renderer {
|
|
| 43 |
})
|
| 44 |
}
|
| 45 |
|
| 46 |
-
pub fn available_fonts(&self) -> Result<Vec<
|
| 47 |
-
let
|
| 48 |
.fontbook
|
| 49 |
.lock()
|
| 50 |
.map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
|
| 51 |
-
let mut
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
pub fn render(
|
|
@@ -134,6 +146,8 @@ impl Renderer {
|
|
| 134 |
width: seed_width,
|
| 135 |
height: seed_height,
|
| 136 |
translation: Some(translation.clone()),
|
|
|
|
|
|
|
| 137 |
..Default::default()
|
| 138 |
};
|
| 139 |
|
|
@@ -264,7 +278,20 @@ impl Renderer {
|
|
| 264 |
text_block.y = layout_box.y;
|
| 265 |
text_block.width = layout_box.width;
|
| 266 |
text_block.height = layout_box.height;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
text_block.rendered = Some(SerializableDynamicImage(DynamicImage::ImageRgba8(rendered)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
Ok(())
|
| 269 |
}
|
| 270 |
|
|
@@ -273,16 +300,15 @@ impl Renderer {
|
|
| 273 |
.fontbook
|
| 274 |
.lock()
|
| 275 |
.map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
|
| 276 |
-
let
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
)
|
| 285 |
-
Ok(font)
|
| 286 |
}
|
| 287 |
}
|
| 288 |
|
|
@@ -439,7 +465,6 @@ fn center_layout_vertically(layout: &mut LayoutRun<'_>, container_height: f32) {
|
|
| 439 |
}
|
| 440 |
|
| 441 |
fn load_symbol_fallbacks(fontbook: &mut FontBook) -> Vec<Font> {
|
| 442 |
-
let props = Properties::default();
|
| 443 |
let candidates = [
|
| 444 |
"Segoe UI Symbol",
|
| 445 |
"Segoe UI Emoji",
|
|
@@ -451,13 +476,26 @@ fn load_symbol_fallbacks(fontbook: &mut FontBook) -> Vec<Font> {
|
|
| 451 |
"Symbola",
|
| 452 |
"Arial Unicode MS",
|
| 453 |
];
|
| 454 |
-
let
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
|
| 463 |
#[cfg(test)]
|
|
|
|
| 5 |
use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
|
| 6 |
|
| 7 |
use koharu_types::{
|
| 8 |
+
Document, FontFaceInfo, SerializableDynamicImage, TextAlign, TextBlock, TextShaderEffect,
|
| 9 |
+
TextStrokeStyle, TextStyle,
|
| 10 |
};
|
| 11 |
|
| 12 |
use crate::{
|
| 13 |
+
font::{FaceInfo, Font, FontBook},
|
| 14 |
layout::{LayoutRun, TextLayout, WritingMode},
|
| 15 |
renderer::{RenderOptions, RenderStrokeOptions, TinySkiaRenderer},
|
| 16 |
text::{
|
|
|
|
| 43 |
})
|
| 44 |
}
|
| 45 |
|
| 46 |
+
pub fn available_fonts(&self) -> Result<Vec<FontFaceInfo>> {
|
| 47 |
+
let fontbook = self
|
| 48 |
.fontbook
|
| 49 |
.lock()
|
| 50 |
.map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
|
| 51 |
+
let mut fonts = fontbook
|
| 52 |
+
.all_families()
|
| 53 |
+
.into_iter()
|
| 54 |
+
.filter(|face| !face.post_script_name.is_empty())
|
| 55 |
+
.map(|face| FontFaceInfo {
|
| 56 |
+
family_name: face
|
| 57 |
+
.families
|
| 58 |
+
.first()
|
| 59 |
+
.map(|(family, _)| family.clone())
|
| 60 |
+
.unwrap_or_else(|| face.post_script_name.clone()),
|
| 61 |
+
post_script_name: face.post_script_name,
|
| 62 |
+
})
|
| 63 |
+
.collect::<Vec<_>>();
|
| 64 |
+
fonts.sort();
|
| 65 |
+
Ok(fonts)
|
| 66 |
}
|
| 67 |
|
| 68 |
pub fn render(
|
|
|
|
| 146 |
width: seed_width,
|
| 147 |
height: seed_height,
|
| 148 |
translation: Some(translation.clone()),
|
| 149 |
+
source_direction: text_block.source_direction,
|
| 150 |
+
rendered_direction: text_block.rendered_direction,
|
| 151 |
..Default::default()
|
| 152 |
};
|
| 153 |
|
|
|
|
| 278 |
text_block.y = layout_box.y;
|
| 279 |
text_block.width = layout_box.width;
|
| 280 |
text_block.height = layout_box.height;
|
| 281 |
+
text_block.rendered_direction = Some(match writing_mode {
|
| 282 |
+
WritingMode::Horizontal => koharu_types::TextDirection::Horizontal,
|
| 283 |
+
WritingMode::VerticalRl => koharu_types::TextDirection::Vertical,
|
| 284 |
+
});
|
| 285 |
text_block.rendered = Some(SerializableDynamicImage(DynamicImage::ImageRgba8(rendered)));
|
| 286 |
+
let persisted_style = text_block.style.get_or_insert_with(|| TextStyle {
|
| 287 |
+
font_families: Vec::new(),
|
| 288 |
+
font_size: None,
|
| 289 |
+
color,
|
| 290 |
+
effect: None,
|
| 291 |
+
stroke: None,
|
| 292 |
+
text_align: None,
|
| 293 |
+
});
|
| 294 |
+
persisted_style.font_families = vec![font.post_script_name().to_string()];
|
| 295 |
Ok(())
|
| 296 |
}
|
| 297 |
|
|
|
|
| 300 |
.fontbook
|
| 301 |
.lock()
|
| 302 |
.map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
|
| 303 |
+
let faces = fontbook.all_families();
|
| 304 |
+
let post_script_name = style
|
| 305 |
+
.font_families
|
| 306 |
+
.iter()
|
| 307 |
+
.find_map(|candidate| face_post_script_name(&faces, candidate))
|
| 308 |
+
.ok_or_else(|| {
|
| 309 |
+
anyhow::anyhow!("no font found for candidates: {:?}", style.font_families)
|
| 310 |
+
})?;
|
| 311 |
+
fontbook.query(&post_script_name)
|
|
|
|
| 312 |
}
|
| 313 |
}
|
| 314 |
|
|
|
|
| 465 |
}
|
| 466 |
|
| 467 |
fn load_symbol_fallbacks(fontbook: &mut FontBook) -> Vec<Font> {
|
|
|
|
| 468 |
let candidates = [
|
| 469 |
"Segoe UI Symbol",
|
| 470 |
"Segoe UI Emoji",
|
|
|
|
| 476 |
"Symbola",
|
| 477 |
"Arial Unicode MS",
|
| 478 |
];
|
| 479 |
+
let faces = fontbook.all_families();
|
| 480 |
+
candidates
|
| 481 |
+
.iter()
|
| 482 |
+
.filter_map(|candidate| face_post_script_name(&faces, candidate))
|
| 483 |
+
.filter_map(|post_script_name| fontbook.query(&post_script_name).ok())
|
| 484 |
+
.collect()
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
fn face_post_script_name(faces: &[FaceInfo], candidate: &str) -> Option<String> {
|
| 488 |
+
faces
|
| 489 |
+
.iter()
|
| 490 |
+
.find(|face| {
|
| 491 |
+
face.post_script_name == candidate
|
| 492 |
+
|| face
|
| 493 |
+
.families
|
| 494 |
+
.iter()
|
| 495 |
+
.any(|(family, _)| family.as_str() == candidate)
|
| 496 |
+
})
|
| 497 |
+
.map(|face| face.post_script_name.clone())
|
| 498 |
+
.filter(|post_script_name| !post_script_name.is_empty())
|
| 499 |
}
|
| 500 |
|
| 501 |
#[cfg(test)]
|
koharu-renderer/src/font.rs
CHANGED
|
@@ -1,100 +1,38 @@
|
|
| 1 |
use std::{collections::HashMap, sync::Arc};
|
| 2 |
|
| 3 |
use anyhow::Context;
|
| 4 |
-
use
|
| 5 |
-
|
| 6 |
-
FontWeight as Weight, FontWidth as Stretch, GenericFamily, QueryFamily, QueryStatus,
|
| 7 |
-
SourceCache, SourceCacheOptions,
|
| 8 |
-
};
|
| 9 |
use once_cell::sync::OnceCell;
|
| 10 |
|
| 11 |
-
/// Font family names for font lookup.
|
| 12 |
-
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
| 13 |
-
pub enum FamilyName {
|
| 14 |
-
/// A named font family.
|
| 15 |
-
Title(String),
|
| 16 |
-
/// Generic serif family.
|
| 17 |
-
Serif,
|
| 18 |
-
/// Generic sans-serif family.
|
| 19 |
-
SansSerif,
|
| 20 |
-
/// Generic cursive family.
|
| 21 |
-
Cursive,
|
| 22 |
-
/// Generic fantasy family.
|
| 23 |
-
Fantasy,
|
| 24 |
-
/// Generic monospace family.
|
| 25 |
-
Monospace,
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
impl FamilyName {
|
| 29 |
-
fn to_query_family(&self) -> QueryFamily<'_> {
|
| 30 |
-
match self {
|
| 31 |
-
FamilyName::Title(name) => QueryFamily::Named(name.as_str()),
|
| 32 |
-
FamilyName::Serif => QueryFamily::Generic(GenericFamily::Serif),
|
| 33 |
-
FamilyName::SansSerif => QueryFamily::Generic(GenericFamily::SansSerif),
|
| 34 |
-
FamilyName::Cursive => QueryFamily::Generic(GenericFamily::Cursive),
|
| 35 |
-
FamilyName::Fantasy => QueryFamily::Generic(GenericFamily::Fantasy),
|
| 36 |
-
FamilyName::Monospace => QueryFamily::Generic(GenericFamily::Monospace),
|
| 37 |
-
}
|
| 38 |
-
}
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
/// Font properties used to match against the font database.
|
| 42 |
-
#[derive(Debug, Clone)]
|
| 43 |
-
pub struct Properties {
|
| 44 |
-
pub weight: Weight,
|
| 45 |
-
pub stretch: Stretch,
|
| 46 |
-
pub style: Style,
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
impl Default for Properties {
|
| 50 |
-
fn default() -> Self {
|
| 51 |
-
Self {
|
| 52 |
-
weight: Weight::NORMAL,
|
| 53 |
-
stretch: Stretch::NORMAL,
|
| 54 |
-
style: Style::Normal,
|
| 55 |
-
}
|
| 56 |
-
}
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
impl Properties {
|
| 60 |
-
fn to_attributes(&self) -> Attributes {
|
| 61 |
-
Attributes::new(self.stretch, self.style, self.weight)
|
| 62 |
-
}
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
/// A loaded font ready for shaping and rendering.
|
| 66 |
-
///
|
| 67 |
-
/// The font data is reference-counted for cheap cloning.
|
| 68 |
#[derive(Clone, Debug)]
|
| 69 |
pub struct Font {
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
/// Index within font collection (0 for single-font files)
|
| 73 |
-
index: u32,
|
| 74 |
-
/// Cached fontdue font to avoid re-parsing font data on every render.
|
| 75 |
fontdue: Arc<OnceCell<Arc<fontdue::Font>>>,
|
| 76 |
}
|
| 77 |
|
| 78 |
impl Font {
|
| 79 |
/// Creates a skrifa FontRef for metric queries.
|
| 80 |
pub fn skrifa(&self) -> anyhow::Result<skrifa::FontRef<'_>> {
|
| 81 |
-
skrifa::FontRef::from_index(self.
|
| 82 |
.context("failed to create skrifa FontRef")
|
| 83 |
}
|
| 84 |
|
| 85 |
/// Creates a harfrust FontRef for text shaping.
|
| 86 |
pub fn harfrust(&self) -> anyhow::Result<harfrust::FontRef<'_>> {
|
| 87 |
-
harfrust::FontRef::from_index(self.
|
| 88 |
.context("failed to create harfrust FontRef")
|
| 89 |
}
|
| 90 |
|
| 91 |
pub fn fontdue(&self) -> anyhow::Result<Arc<fontdue::Font>> {
|
| 92 |
let font = self.fontdue.get_or_try_init(|| {
|
| 93 |
let settings = fontdue::FontSettings {
|
| 94 |
-
collection_index: self.index,
|
| 95 |
..Default::default()
|
| 96 |
};
|
| 97 |
-
let font = fontdue::Font::from_bytes(self.
|
| 98 |
.map_err(|err| anyhow::anyhow!(err))
|
| 99 |
.context("failed to create fontdue Font")?;
|
| 100 |
Ok::<_, anyhow::Error>(Arc::new(font))
|
|
@@ -108,6 +46,14 @@ impl Font {
|
|
| 108 |
.map(|font| font.has_glyph(character))
|
| 109 |
.unwrap_or(false)
|
| 110 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
pub(crate) fn font_key(font: &Font) -> usize {
|
|
@@ -141,83 +87,64 @@ pub(crate) fn select_font(cluster: &str, fonts: &[&Font]) -> usize {
|
|
| 141 |
0
|
| 142 |
}
|
| 143 |
|
| 144 |
-
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
| 145 |
-
struct CacheKey {
|
| 146 |
-
family_id: FamilyId,
|
| 147 |
-
family_index: usize,
|
| 148 |
-
index: u32,
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
/// A collection of font sources for font discovery and loading.
|
| 152 |
-
///
|
| 153 |
-
/// Combines system fonts with optional custom font directories.
|
| 154 |
pub struct FontBook {
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
cache: HashMap<CacheKey, Font>,
|
| 158 |
}
|
| 159 |
|
| 160 |
impl FontBook {
|
| 161 |
-
/// Creates a FontBook with
|
| 162 |
pub fn new() -> Self {
|
| 163 |
-
let
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
});
|
| 167 |
-
let source_cache = SourceCache::new(SourceCacheOptions { shared: true });
|
| 168 |
Self {
|
| 169 |
-
|
| 170 |
-
source_cache,
|
| 171 |
cache: HashMap::new(),
|
| 172 |
}
|
| 173 |
}
|
| 174 |
|
| 175 |
-
/// Returns all available font
|
| 176 |
-
pub fn all_families(&
|
| 177 |
-
self.
|
| 178 |
-
.family_names()
|
| 179 |
-
.map(|name| name.to_string())
|
| 180 |
-
.collect()
|
| 181 |
}
|
| 182 |
|
| 183 |
-
///
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
query.set_attributes(properties.to_attributes());
|
| 194 |
-
|
| 195 |
-
let mut selected = None;
|
| 196 |
-
query.matches_with(|font| {
|
| 197 |
-
// Clone the necessary fields from font to avoid borrow issues
|
| 198 |
-
selected = Some((font.family.0, font.family.1, font.index, font.blob.clone()));
|
| 199 |
-
QueryStatus::Stop
|
| 200 |
-
});
|
| 201 |
-
|
| 202 |
-
let (family_id, family_index, index, blob) =
|
| 203 |
-
selected.with_context(|| format!("no font found for families: {families:?}"))?;
|
| 204 |
-
|
| 205 |
-
let cache_key = CacheKey {
|
| 206 |
-
family_id,
|
| 207 |
-
family_index,
|
| 208 |
-
index,
|
| 209 |
};
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
return Ok(font.clone());
|
| 212 |
}
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
let font = Font {
|
| 215 |
-
|
| 216 |
-
|
| 217 |
fontdue: Arc::new(OnceCell::new()),
|
| 218 |
};
|
| 219 |
-
|
| 220 |
-
self.cache.insert(cache_key, font.clone());
|
| 221 |
Ok(font)
|
| 222 |
}
|
| 223 |
}
|
|
|
|
| 1 |
use std::{collections::HashMap, sync::Arc};
|
| 2 |
|
| 3 |
use anyhow::Context;
|
| 4 |
+
pub use fontdb::FaceInfo;
|
| 5 |
+
use fontdb::{Database, ID};
|
|
|
|
|
|
|
|
|
|
| 6 |
use once_cell::sync::OnceCell;
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
/// A loaded font ready for shaping and rendering.
|
|
|
|
|
|
|
| 9 |
#[derive(Clone, Debug)]
|
| 10 |
pub struct Font {
|
| 11 |
+
data: Arc<[u8]>,
|
| 12 |
+
face: FaceInfo,
|
|
|
|
|
|
|
|
|
|
| 13 |
fontdue: Arc<OnceCell<Arc<fontdue::Font>>>,
|
| 14 |
}
|
| 15 |
|
| 16 |
impl Font {
|
| 17 |
/// Creates a skrifa FontRef for metric queries.
|
| 18 |
pub fn skrifa(&self) -> anyhow::Result<skrifa::FontRef<'_>> {
|
| 19 |
+
skrifa::FontRef::from_index(self.data.as_ref(), self.face.index)
|
| 20 |
.context("failed to create skrifa FontRef")
|
| 21 |
}
|
| 22 |
|
| 23 |
/// Creates a harfrust FontRef for text shaping.
|
| 24 |
pub fn harfrust(&self) -> anyhow::Result<harfrust::FontRef<'_>> {
|
| 25 |
+
harfrust::FontRef::from_index(self.data.as_ref(), self.face.index)
|
| 26 |
.context("failed to create harfrust FontRef")
|
| 27 |
}
|
| 28 |
|
| 29 |
pub fn fontdue(&self) -> anyhow::Result<Arc<fontdue::Font>> {
|
| 30 |
let font = self.fontdue.get_or_try_init(|| {
|
| 31 |
let settings = fontdue::FontSettings {
|
| 32 |
+
collection_index: self.face.index,
|
| 33 |
..Default::default()
|
| 34 |
};
|
| 35 |
+
let font = fontdue::Font::from_bytes(self.data.as_ref(), settings)
|
| 36 |
.map_err(|err| anyhow::anyhow!(err))
|
| 37 |
.context("failed to create fontdue Font")?;
|
| 38 |
Ok::<_, anyhow::Error>(Arc::new(font))
|
|
|
|
| 46 |
.map(|font| font.has_glyph(character))
|
| 47 |
.unwrap_or(false)
|
| 48 |
}
|
| 49 |
+
|
| 50 |
+
pub fn post_script_name(&self) -> &str {
|
| 51 |
+
&self.face.post_script_name
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
pub fn face_info(&self) -> &FaceInfo {
|
| 55 |
+
&self.face
|
| 56 |
+
}
|
| 57 |
}
|
| 58 |
|
| 59 |
pub(crate) fn font_key(font: &Font) -> usize {
|
|
|
|
| 87 |
0
|
| 88 |
}
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
/// A collection of font sources for font discovery and loading.
|
|
|
|
|
|
|
| 91 |
pub struct FontBook {
|
| 92 |
+
database: Database,
|
| 93 |
+
cache: HashMap<ID, Font>,
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
impl FontBook {
|
| 97 |
+
/// Creates a FontBook with system fonts.
|
| 98 |
pub fn new() -> Self {
|
| 99 |
+
let mut database = Database::new();
|
| 100 |
+
database.load_system_fonts();
|
| 101 |
+
|
|
|
|
|
|
|
| 102 |
Self {
|
| 103 |
+
database,
|
|
|
|
| 104 |
cache: HashMap::new(),
|
| 105 |
}
|
| 106 |
}
|
| 107 |
|
| 108 |
+
/// Returns all available font faces.
|
| 109 |
+
pub fn all_families(&self) -> Vec<FaceInfo> {
|
| 110 |
+
self.database.faces().cloned().collect()
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
+
/// Loads a font by PostScript name.
|
| 114 |
+
pub fn query(&mut self, post_script_name: &str) -> anyhow::Result<Font> {
|
| 115 |
+
let Some(id) = self
|
| 116 |
+
.database
|
| 117 |
+
.faces()
|
| 118 |
+
.find_map(|face| (face.post_script_name == post_script_name).then_some(face.id))
|
| 119 |
+
else {
|
| 120 |
+
return Err(anyhow::anyhow!(
|
| 121 |
+
"no font found for PostScript name: {post_script_name}"
|
| 122 |
+
));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
};
|
| 124 |
+
self.load_font(id)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
fn load_font(&mut self, id: ID) -> anyhow::Result<Font> {
|
| 128 |
+
if let Some(font) = self.cache.get(&id) {
|
| 129 |
return Ok(font.clone());
|
| 130 |
}
|
| 131 |
|
| 132 |
+
let face = self
|
| 133 |
+
.database
|
| 134 |
+
.face(id)
|
| 135 |
+
.cloned()
|
| 136 |
+
.with_context(|| format!("missing font face for id {:?}", id))?;
|
| 137 |
+
let data = self
|
| 138 |
+
.database
|
| 139 |
+
.with_face_data(id, |data, _| Arc::<[u8]>::from(data.to_vec()))
|
| 140 |
+
.with_context(|| format!("failed to load font data for {:?}", id))?;
|
| 141 |
+
|
| 142 |
let font = Font {
|
| 143 |
+
data,
|
| 144 |
+
face,
|
| 145 |
fontdue: Arc::new(OnceCell::new()),
|
| 146 |
};
|
| 147 |
+
self.cache.insert(id, font.clone());
|
|
|
|
| 148 |
Ok(font)
|
| 149 |
}
|
| 150 |
}
|
koharu-renderer/src/layout.rs
CHANGED
|
@@ -526,7 +526,7 @@ fn is_fullwidth_punctuation(ch: char) -> bool {
|
|
| 526 |
#[cfg(test)]
|
| 527 |
mod tests {
|
| 528 |
use super::*;
|
| 529 |
-
use crate::font::{
|
| 530 |
use skrifa::{
|
| 531 |
MetadataProvider,
|
| 532 |
instance::{LocationRef, Size},
|
|
@@ -534,7 +534,6 @@ mod tests {
|
|
| 534 |
|
| 535 |
fn any_system_font() -> Font {
|
| 536 |
let mut book = FontBook::new();
|
| 537 |
-
let props = Properties::default();
|
| 538 |
|
| 539 |
// Prefer fonts that are commonly available depending on OS/environment.
|
| 540 |
// This is only used to construct a `TextLayout` for calling `compute_bounds`.
|
|
@@ -549,11 +548,34 @@ mod tests {
|
|
| 549 |
];
|
| 550 |
|
| 551 |
for name in preferred {
|
| 552 |
-
if let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
return font;
|
| 554 |
}
|
| 555 |
}
|
| 556 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
panic!("no system font available for tests");
|
| 558 |
}
|
| 559 |
|
|
|
|
| 526 |
#[cfg(test)]
|
| 527 |
mod tests {
|
| 528 |
use super::*;
|
| 529 |
+
use crate::font::{Font, FontBook};
|
| 530 |
use skrifa::{
|
| 531 |
MetadataProvider,
|
| 532 |
instance::{LocationRef, Size},
|
|
|
|
| 534 |
|
| 535 |
fn any_system_font() -> Font {
|
| 536 |
let mut book = FontBook::new();
|
|
|
|
| 537 |
|
| 538 |
// Prefer fonts that are commonly available depending on OS/environment.
|
| 539 |
// This is only used to construct a `TextLayout` for calling `compute_bounds`.
|
|
|
|
| 548 |
];
|
| 549 |
|
| 550 |
for name in preferred {
|
| 551 |
+
if let Some(post_script_name) = book
|
| 552 |
+
.all_families()
|
| 553 |
+
.into_iter()
|
| 554 |
+
.find(|face| {
|
| 555 |
+
face.post_script_name == name
|
| 556 |
+
|| face
|
| 557 |
+
.families
|
| 558 |
+
.iter()
|
| 559 |
+
.any(|(family, _)| family.as_str() == name)
|
| 560 |
+
})
|
| 561 |
+
.map(|face| face.post_script_name)
|
| 562 |
+
.filter(|post_script_name| !post_script_name.is_empty())
|
| 563 |
+
&& let Ok(font) = book.query(&post_script_name)
|
| 564 |
+
{
|
| 565 |
return font;
|
| 566 |
}
|
| 567 |
}
|
| 568 |
|
| 569 |
+
if let Some(face) = book
|
| 570 |
+
.all_families()
|
| 571 |
+
.into_iter()
|
| 572 |
+
.find(|face| !face.post_script_name.is_empty())
|
| 573 |
+
{
|
| 574 |
+
return book
|
| 575 |
+
.query(&face.post_script_name)
|
| 576 |
+
.expect("failed to load first system font");
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
panic!("no system font available for tests");
|
| 580 |
}
|
| 581 |
|
koharu-renderer/src/shape.rs
CHANGED
|
@@ -182,7 +182,7 @@ pub(crate) fn shape_segment_with_fallbacks<'a>(
|
|
| 182 |
#[cfg(test)]
|
| 183 |
mod tests {
|
| 184 |
use super::*;
|
| 185 |
-
use crate::font::
|
| 186 |
|
| 187 |
const PRIMARY_FAMILIES: &[&str] = &[
|
| 188 |
"Arial",
|
|
@@ -218,11 +218,19 @@ mod tests {
|
|
| 218 |
];
|
| 219 |
|
| 220 |
fn query_font(book: &mut FontBook, name: &str) -> Option<Font> {
|
| 221 |
-
book
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
#[test]
|
|
|
|
| 182 |
#[cfg(test)]
|
| 183 |
mod tests {
|
| 184 |
use super::*;
|
| 185 |
+
use crate::font::FontBook;
|
| 186 |
|
| 187 |
const PRIMARY_FAMILIES: &[&str] = &[
|
| 188 |
"Arial",
|
|
|
|
| 218 |
];
|
| 219 |
|
| 220 |
fn query_font(book: &mut FontBook, name: &str) -> Option<Font> {
|
| 221 |
+
let post_script_name = book
|
| 222 |
+
.all_families()
|
| 223 |
+
.into_iter()
|
| 224 |
+
.find(|face| {
|
| 225 |
+
face.post_script_name == name
|
| 226 |
+
|| face
|
| 227 |
+
.families
|
| 228 |
+
.iter()
|
| 229 |
+
.any(|(family, _)| family.as_str() == name)
|
| 230 |
+
})
|
| 231 |
+
.map(|face| face.post_script_name)
|
| 232 |
+
.filter(|post_script_name| !post_script_name.is_empty())?;
|
| 233 |
+
book.query(&post_script_name).ok()
|
| 234 |
}
|
| 235 |
|
| 236 |
#[test]
|
koharu-renderer/src/text/script.rs
CHANGED
|
@@ -126,7 +126,14 @@ fn is_cjk_text(text: &str) -> bool {
|
|
| 126 |
|
| 127 |
#[cfg(test)]
|
| 128 |
mod tests {
|
| 129 |
-
use
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
#[test]
|
| 132 |
fn latin_detection_is_reasonable() {
|
|
@@ -145,4 +152,30 @@ mod tests {
|
|
| 145 |
assert!(!font_families_for_text("hello").is_empty());
|
| 146 |
assert!(!font_families_for_text("你好").is_empty());
|
| 147 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
|
|
|
| 126 |
|
| 127 |
#[cfg(test)]
|
| 128 |
mod tests {
|
| 129 |
+
use koharu_types::{TextBlock, TextDirection};
|
| 130 |
+
|
| 131 |
+
use crate::layout::WritingMode;
|
| 132 |
+
|
| 133 |
+
use super::{
|
| 134 |
+
font_families_for_text, is_latin_only, normalize_translation_for_layout,
|
| 135 |
+
writing_mode_for_block,
|
| 136 |
+
};
|
| 137 |
|
| 138 |
#[test]
|
| 139 |
fn latin_detection_is_reasonable() {
|
|
|
|
| 152 |
assert!(!font_families_for_text("hello").is_empty());
|
| 153 |
assert!(!font_families_for_text("你好").is_empty());
|
| 154 |
}
|
| 155 |
+
|
| 156 |
+
#[test]
|
| 157 |
+
fn writing_mode_uses_cjk_tall_box_heuristic() {
|
| 158 |
+
let block = TextBlock {
|
| 159 |
+
width: 40.0,
|
| 160 |
+
height: 120.0,
|
| 161 |
+
translation: Some("縦書き".to_string()),
|
| 162 |
+
..Default::default()
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
assert_eq!(writing_mode_for_block(&block), WritingMode::VerticalRl);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
#[test]
|
| 169 |
+
fn writing_mode_ignores_stale_rendered_direction() {
|
| 170 |
+
let block = TextBlock {
|
| 171 |
+
width: 40.0,
|
| 172 |
+
height: 120.0,
|
| 173 |
+
translation: Some("HELLO".to_string()),
|
| 174 |
+
source_direction: Some(TextDirection::Horizontal),
|
| 175 |
+
rendered_direction: Some(TextDirection::Vertical),
|
| 176 |
+
..Default::default()
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
assert_eq!(writing_mode_for_block(&block), WritingMode::Horizontal);
|
| 180 |
+
}
|
| 181 |
}
|
koharu-renderer/tests/rendering.rs
CHANGED
|
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|
| 2 |
|
| 3 |
use anyhow::Result;
|
| 4 |
use koharu_renderer::{
|
| 5 |
-
font::{
|
| 6 |
layout::{TextLayout, WritingMode},
|
| 7 |
renderer::{RenderOptions, TinySkiaRenderer},
|
| 8 |
};
|
|
@@ -21,10 +21,20 @@ fn output_dir() -> PathBuf {
|
|
| 21 |
|
| 22 |
fn font(family_name: &str) -> Result<Font> {
|
| 23 |
let mut book = FontBook::new();
|
| 24 |
-
let
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// preload fontdue font
|
| 29 |
let _ = font.fontdue()?;
|
| 30 |
|
|
|
|
| 2 |
|
| 3 |
use anyhow::Result;
|
| 4 |
use koharu_renderer::{
|
| 5 |
+
font::{Font, FontBook},
|
| 6 |
layout::{TextLayout, WritingMode},
|
| 7 |
renderer::{RenderOptions, TinySkiaRenderer},
|
| 8 |
};
|
|
|
|
| 21 |
|
| 22 |
fn font(family_name: &str) -> Result<Font> {
|
| 23 |
let mut book = FontBook::new();
|
| 24 |
+
let post_script_name = book
|
| 25 |
+
.all_families()
|
| 26 |
+
.into_iter()
|
| 27 |
+
.find(|face| {
|
| 28 |
+
face.post_script_name == family_name
|
| 29 |
+
|| face
|
| 30 |
+
.families
|
| 31 |
+
.iter()
|
| 32 |
+
.any(|(family, _)| family.as_str() == family_name)
|
| 33 |
+
})
|
| 34 |
+
.map(|face| face.post_script_name)
|
| 35 |
+
.filter(|post_script_name| !post_script_name.is_empty())
|
| 36 |
+
.ok_or_else(|| anyhow::anyhow!("font not found: {family_name}"))?;
|
| 37 |
+
let font = book.query(&post_script_name)?;
|
| 38 |
// preload fontdue font
|
| 39 |
let _ = font.fontdue()?;
|
| 40 |
|
koharu-rpc/Cargo.toml
CHANGED
|
@@ -13,6 +13,7 @@ publish.workspace = true
|
|
| 13 |
|
| 14 |
[dependencies]
|
| 15 |
koharu-http = { workspace = true }
|
|
|
|
| 16 |
koharu-pipeline = { workspace = true }
|
| 17 |
koharu-types = { workspace = true }
|
| 18 |
anyhow = { workspace = true }
|
|
|
|
| 13 |
|
| 14 |
[dependencies]
|
| 15 |
koharu-http = { workspace = true }
|
| 16 |
+
koharu-psd = { workspace = true }
|
| 17 |
koharu-pipeline = { workspace = true }
|
| 18 |
koharu-types = { workspace = true }
|
| 19 |
anyhow = { workspace = true }
|
koharu-rpc/src/api.rs
CHANGED
|
@@ -21,13 +21,15 @@ use koharu_pipeline::{
|
|
| 21 |
AppResources, operations,
|
| 22 |
state_tx::{self, ChangedField},
|
| 23 |
};
|
|
|
|
| 24 |
use koharu_types::{
|
| 25 |
ApiKeyGetPayload, ApiKeyResponse, ApiKeySetPayload, ApiKeyValue, CreateTextBlock, Document,
|
| 26 |
-
DocumentDetail, DocumentSummary, ExportLayer, ExportResult, FileEntry,
|
| 27 |
-
InpaintPartialPayload, InpaintRegion, JobState, JobStatus, LlmLoadPayload,
|
| 28 |
-
LlmModelInfo, MaskRegionRequest, MetaInfo, OpenDocumentsPayload,
|
| 29 |
-
RenderPayload, RenderRequest, SerializableDynamicImage, TextBlock,
|
| 30 |
-
TextBlockPatch, TranslateRequest, UpdateBrushLayerPayload,
|
|
|
|
| 31 |
};
|
| 32 |
use serde::Deserialize;
|
| 33 |
|
|
@@ -93,6 +95,10 @@ pub fn router(resources: SharedResources, events: EventHub) -> Router {
|
|
| 93 |
patch(patch_text_block).delete(delete_text_block),
|
| 94 |
)
|
| 95 |
.route("/documents/{document_id}/export", get(export_document))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
.route("/llm/models", get(list_llm_models))
|
| 97 |
.route("/llm/state", get(get_llm_state))
|
| 98 |
.route("/llm/load", post(load_llm))
|
|
@@ -187,7 +193,7 @@ async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
|
|
| 187 |
}))
|
| 188 |
}
|
| 189 |
|
| 190 |
-
async fn get_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<
|
| 191 |
let resources = state.resources()?;
|
| 192 |
let fonts = operations::list_font_families(resources)
|
| 193 |
.await
|
|
@@ -684,6 +690,21 @@ async fn export_document(
|
|
| 684 |
Ok(binary_response(data, content_type, Some(filename)))
|
| 685 |
}
|
| 686 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
async fn export_all(
|
| 688 |
State(state): State<ApiState>,
|
| 689 |
Query(query): Query<LayerQuery>,
|
|
@@ -852,6 +873,17 @@ fn export_target(
|
|
| 852 |
}
|
| 853 |
}
|
| 854 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
fn to_inpaint_region(region: Region) -> InpaintRegion {
|
| 856 |
InpaintRegion {
|
| 857 |
x: region.x,
|
|
@@ -918,19 +950,22 @@ fn apply_text_block_patch(block: &mut TextBlock, patch: TextBlockPatch) {
|
|
| 918 |
}
|
| 919 |
if invalidate_render {
|
| 920 |
block.rendered = None;
|
|
|
|
| 921 |
}
|
| 922 |
}
|
| 923 |
|
| 924 |
#[cfg(test)]
|
| 925 |
mod tests {
|
| 926 |
-
use super::apply_text_block_patch;
|
| 927 |
-
use
|
|
|
|
| 928 |
|
| 929 |
#[test]
|
| 930 |
fn text_block_patch_updates_geometry_and_clears_rendered() {
|
| 931 |
let mut block = TextBlock {
|
| 932 |
width: 100.0,
|
| 933 |
height: 50.0,
|
|
|
|
| 934 |
rendered: Some(image::DynamicImage::new_rgba8(1, 1).into()),
|
| 935 |
..Default::default()
|
| 936 |
};
|
|
@@ -962,5 +997,24 @@ mod tests {
|
|
| 962 |
assert_eq!(block.height, 40.0);
|
| 963 |
assert!(block.lock_layout_box);
|
| 964 |
assert!(block.rendered.is_none());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 965 |
}
|
| 966 |
}
|
|
|
|
| 21 |
AppResources, operations,
|
| 22 |
state_tx::{self, ChangedField},
|
| 23 |
};
|
| 24 |
+
use koharu_psd::{PsdExportOptions, TextLayerMode};
|
| 25 |
use koharu_types::{
|
| 26 |
ApiKeyGetPayload, ApiKeyResponse, ApiKeySetPayload, ApiKeyValue, CreateTextBlock, Document,
|
| 27 |
+
DocumentDetail, DocumentSummary, ExportLayer, ExportResult, FileEntry, FontFaceInfo,
|
| 28 |
+
IndexPayload, InpaintPartialPayload, InpaintRegion, JobState, JobStatus, LlmLoadPayload,
|
| 29 |
+
LlmLoadRequest, LlmModelInfo, MaskRegionRequest, MetaInfo, OpenDocumentsPayload,
|
| 30 |
+
PipelineJobRequest, Region, RenderPayload, RenderRequest, SerializableDynamicImage, TextBlock,
|
| 31 |
+
TextBlockDetail, TextBlockPatch, TranslateRequest, UpdateBrushLayerPayload,
|
| 32 |
+
UpdateInpaintMaskPayload,
|
| 33 |
};
|
| 34 |
use serde::Deserialize;
|
| 35 |
|
|
|
|
| 95 |
patch(patch_text_block).delete(delete_text_block),
|
| 96 |
)
|
| 97 |
.route("/documents/{document_id}/export", get(export_document))
|
| 98 |
+
.route(
|
| 99 |
+
"/documents/{document_id}/export/psd",
|
| 100 |
+
get(export_document_psd),
|
| 101 |
+
)
|
| 102 |
.route("/llm/models", get(list_llm_models))
|
| 103 |
.route("/llm/state", get(get_llm_state))
|
| 104 |
.route("/llm/load", post(load_llm))
|
|
|
|
| 193 |
}))
|
| 194 |
}
|
| 195 |
|
| 196 |
+
async fn get_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFaceInfo>>> {
|
| 197 |
let resources = state.resources()?;
|
| 198 |
let fonts = operations::list_font_families(resources)
|
| 199 |
.await
|
|
|
|
| 690 |
Ok(binary_response(data, content_type, Some(filename)))
|
| 691 |
}
|
| 692 |
|
| 693 |
+
async fn export_document_psd(
|
| 694 |
+
State(state): State<ApiState>,
|
| 695 |
+
Path(document_id): Path<String>,
|
| 696 |
+
) -> ApiResult<Response> {
|
| 697 |
+
let resources = state.resources()?;
|
| 698 |
+
let (_, document) = find_document(&resources, &document_id).await?;
|
| 699 |
+
let data = koharu_psd::export_document(&document, &app_psd_export_options())
|
| 700 |
+
.map_err(|error| ApiError::bad_request(error.to_string()))?;
|
| 701 |
+
Ok(binary_response(
|
| 702 |
+
data,
|
| 703 |
+
"image/vnd.adobe.photoshop",
|
| 704 |
+
Some(psd_export_filename(&document)),
|
| 705 |
+
))
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
async fn export_all(
|
| 709 |
State(state): State<ApiState>,
|
| 710 |
Query(query): Query<LayerQuery>,
|
|
|
|
| 873 |
}
|
| 874 |
}
|
| 875 |
|
| 876 |
+
fn psd_export_filename(document: &Document) -> String {
|
| 877 |
+
format!("{}_koharu.psd", document.name)
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
fn app_psd_export_options() -> PsdExportOptions {
|
| 881 |
+
PsdExportOptions {
|
| 882 |
+
text_layer_mode: TextLayerMode::Editable,
|
| 883 |
+
..PsdExportOptions::default()
|
| 884 |
+
}
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
fn to_inpaint_region(region: Region) -> InpaintRegion {
|
| 888 |
InpaintRegion {
|
| 889 |
x: region.x,
|
|
|
|
| 950 |
}
|
| 951 |
if invalidate_render {
|
| 952 |
block.rendered = None;
|
| 953 |
+
block.rendered_direction = None;
|
| 954 |
}
|
| 955 |
}
|
| 956 |
|
| 957 |
#[cfg(test)]
|
| 958 |
mod tests {
|
| 959 |
+
use super::{app_psd_export_options, apply_text_block_patch, psd_export_filename};
|
| 960 |
+
use koharu_psd::TextLayerMode;
|
| 961 |
+
use koharu_types::{Document, TextAlign, TextBlock, TextBlockPatch, TextDirection, TextStyle};
|
| 962 |
|
| 963 |
#[test]
|
| 964 |
fn text_block_patch_updates_geometry_and_clears_rendered() {
|
| 965 |
let mut block = TextBlock {
|
| 966 |
width: 100.0,
|
| 967 |
height: 50.0,
|
| 968 |
+
rendered_direction: Some(TextDirection::Vertical),
|
| 969 |
rendered: Some(image::DynamicImage::new_rgba8(1, 1).into()),
|
| 970 |
..Default::default()
|
| 971 |
};
|
|
|
|
| 997 |
assert_eq!(block.height, 40.0);
|
| 998 |
assert!(block.lock_layout_box);
|
| 999 |
assert!(block.rendered.is_none());
|
| 1000 |
+
assert!(block.rendered_direction.is_none());
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
#[test]
|
| 1004 |
+
fn psd_export_filename_uses_koharu_suffix() {
|
| 1005 |
+
let document = Document {
|
| 1006 |
+
name: "chapter-01".to_string(),
|
| 1007 |
+
..Default::default()
|
| 1008 |
+
};
|
| 1009 |
+
|
| 1010 |
+
assert_eq!(psd_export_filename(&document), "chapter-01_koharu.psd");
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
#[test]
|
| 1014 |
+
fn app_psd_export_uses_editable_text_layers() {
|
| 1015 |
+
assert_eq!(
|
| 1016 |
+
app_psd_export_options().text_layer_mode,
|
| 1017 |
+
TextLayerMode::Editable
|
| 1018 |
+
);
|
| 1019 |
}
|
| 1020 |
}
|
koharu-rpc/src/mcp/mod.rs
CHANGED
|
@@ -95,7 +95,11 @@ impl KoharuMcp {
|
|
| 95 |
let fonts = operations::list_font_families(res)
|
| 96 |
.await
|
| 97 |
.map_err(|e| e.to_string())?;
|
| 98 |
-
Ok(fonts
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
#[tool(description = "List available LLM translation models with supported languages")]
|
|
|
|
| 95 |
let fonts = operations::list_font_families(res)
|
| 96 |
.await
|
| 97 |
.map_err(|e| e.to_string())?;
|
| 98 |
+
Ok(fonts
|
| 99 |
+
.into_iter()
|
| 100 |
+
.map(|font| format!("{} ({})", font.family_name, font.post_script_name))
|
| 101 |
+
.collect::<Vec<_>>()
|
| 102 |
+
.join(", "))
|
| 103 |
}
|
| 104 |
|
| 105 |
#[tool(description = "List available LLM translation models with supported languages")]
|
koharu-types/src/lib.rs
CHANGED
|
@@ -40,6 +40,7 @@ pub struct TextBlock {
|
|
| 40 |
pub confidence: f32,
|
| 41 |
pub line_polygons: Option<Vec<[[f32; 2]; 4]>>,
|
| 42 |
pub source_direction: Option<TextDirection>,
|
|
|
|
| 43 |
pub source_language: Option<String>,
|
| 44 |
pub rotation_deg: Option<f32>,
|
| 45 |
pub detected_font_size_px: Option<f32>,
|
|
|
|
| 40 |
pub confidence: f32,
|
| 41 |
pub line_polygons: Option<Vec<[[f32; 2]; 4]>>,
|
| 42 |
pub source_direction: Option<TextDirection>,
|
| 43 |
+
pub rendered_direction: Option<TextDirection>,
|
| 44 |
pub source_language: Option<String>,
|
| 45 |
pub rotation_deg: Option<f32>,
|
| 46 |
pub detected_font_size_px: Option<f32>,
|
koharu-types/src/protocol.rs
CHANGED
|
@@ -4,6 +4,14 @@ use ts_rs::TS;
|
|
| 4 |
|
| 5 |
use crate::{Document, FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle};
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
|
| 8 |
#[serde(rename_all = "camelCase")]
|
| 9 |
#[ts(export)]
|
|
@@ -57,6 +65,7 @@ pub struct TextBlockDetail {
|
|
| 57 |
pub confidence: f32,
|
| 58 |
pub line_polygons: Option<Vec<[[f32; 2]; 4]>>,
|
| 59 |
pub source_direction: Option<crate::TextDirection>,
|
|
|
|
| 60 |
pub source_language: Option<String>,
|
| 61 |
pub rotation_deg: Option<f32>,
|
| 62 |
pub detected_font_size_px: Option<f32>,
|
|
@@ -78,6 +87,7 @@ impl From<&TextBlock> for TextBlockDetail {
|
|
| 78 |
confidence: block.confidence,
|
| 79 |
line_polygons: block.line_polygons.clone(),
|
| 80 |
source_direction: block.source_direction,
|
|
|
|
| 81 |
source_language: block.source_language.clone(),
|
| 82 |
rotation_deg: block.rotation_deg,
|
| 83 |
detected_font_size_px: block.detected_font_size_px,
|
|
|
|
| 4 |
|
| 5 |
use crate::{Document, FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle};
|
| 6 |
|
| 7 |
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, TS)]
|
| 8 |
+
#[serde(rename_all = "camelCase")]
|
| 9 |
+
#[ts(export)]
|
| 10 |
+
pub struct FontFaceInfo {
|
| 11 |
+
pub family_name: String,
|
| 12 |
+
pub post_script_name: String,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
|
| 16 |
#[serde(rename_all = "camelCase")]
|
| 17 |
#[ts(export)]
|
|
|
|
| 65 |
pub confidence: f32,
|
| 66 |
pub line_polygons: Option<Vec<[[f32; 2]; 4]>>,
|
| 67 |
pub source_direction: Option<crate::TextDirection>,
|
| 68 |
+
pub rendered_direction: Option<crate::TextDirection>,
|
| 69 |
pub source_language: Option<String>,
|
| 70 |
pub rotation_deg: Option<f32>,
|
| 71 |
pub detected_font_size_px: Option<f32>,
|
|
|
|
| 87 |
confidence: block.confidence,
|
| 88 |
line_polygons: block.line_polygons.clone(),
|
| 89 |
source_direction: block.source_direction,
|
| 90 |
+
rendered_direction: block.rendered_direction,
|
| 91 |
source_language: block.source_language.clone(),
|
| 92 |
rotation_deg: block.rotation_deg,
|
| 93 |
detected_font_size_px: block.detected_font_size_px,
|
ui/components/MenuBar.tsx
CHANGED
|
@@ -40,6 +40,7 @@ export function MenuBar() {
|
|
| 40 |
inpaintAndRenderImage,
|
| 41 |
processAllImages,
|
| 42 |
exportDocument,
|
|
|
|
| 43 |
exportAllInpainted,
|
| 44 |
exportAllRendered,
|
| 45 |
} = useDocumentMutations()
|
|
@@ -60,6 +61,13 @@ export function MenuBar() {
|
|
| 60 |
onSelect: exportDocument,
|
| 61 |
testId: 'menu-file-export',
|
| 62 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
{
|
| 64 |
label: t('menu.exportAllInpainted'),
|
| 65 |
onSelect: exportAllInpainted,
|
|
|
|
| 40 |
inpaintAndRenderImage,
|
| 41 |
processAllImages,
|
| 42 |
exportDocument,
|
| 43 |
+
exportPsdDocument,
|
| 44 |
exportAllInpainted,
|
| 45 |
exportAllRendered,
|
| 46 |
} = useDocumentMutations()
|
|
|
|
| 61 |
onSelect: exportDocument,
|
| 62 |
testId: 'menu-file-export',
|
| 63 |
},
|
| 64 |
+
{
|
| 65 |
+
label: t('menu.exportPsd', {
|
| 66 |
+
defaultValue: 'Export PSD...',
|
| 67 |
+
}),
|
| 68 |
+
onSelect: exportPsdDocument,
|
| 69 |
+
testId: 'menu-file-export-psd',
|
| 70 |
+
},
|
| 71 |
{
|
| 72 |
label: t('menu.exportAllInpainted'),
|
| 73 |
onSelect: exportAllInpainted,
|
ui/components/panels/RenderControlsPanel.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
| 20 |
TextAlign,
|
| 21 |
TextStyle,
|
| 22 |
} from '@/types'
|
|
|
|
| 23 |
import { Button } from '@/components/ui/button'
|
| 24 |
import { ColorPicker } from '@/components/ui/color-picker'
|
| 25 |
import { Input } from '@/components/ui/input'
|
|
@@ -42,7 +43,12 @@ import { useTextBlockMutations } from '@/lib/query/mutations'
|
|
| 42 |
import { cn } from '@/lib/utils'
|
| 43 |
|
| 44 |
const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
|
| 45 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const DEFAULT_EFFECT: RenderEffect = {
|
| 47 |
italic: false,
|
| 48 |
bold: false,
|
|
@@ -90,15 +96,37 @@ const hexToColor = (value: string, alpha: number): RgbaColor => {
|
|
| 90 |
return [r, g, b, clampByte(alpha)]
|
| 91 |
}
|
| 92 |
|
| 93 |
-
const
|
| 94 |
const seen = new Set<string>()
|
| 95 |
return values.filter((value) => {
|
| 96 |
-
if (!value || seen.has(value)) return false
|
| 97 |
-
seen.add(value)
|
| 98 |
return true
|
| 99 |
})
|
| 100 |
}
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
const normalizeEffect = (effect?: Partial<RenderEffect>): RenderEffect => ({
|
| 103 |
italic: effect?.italic ?? false,
|
| 104 |
bold: effect?.bold ?? false,
|
|
@@ -167,23 +195,32 @@ export function RenderControlsPanel() {
|
|
| 167 |
: undefined
|
| 168 |
const firstBlock = textBlocks[0]
|
| 169 |
const hasBlocks = textBlocks.length > 0
|
| 170 |
-
const
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
|
| 173 |
-
const
|
| 174 |
-
|
| 175 |
-
? availableFonts
|
| 176 |
-
: [
|
| 177 |
-
...(fontFamily ? [fontFamily] : []),
|
| 178 |
-
...(selectedBlock?.style?.fontFamilies?.slice(0, 1) ?? []),
|
| 179 |
-
...DEFAULT_FONT_FAMILIES,
|
| 180 |
-
]
|
| 181 |
-
const fontOptions = uniqueStrings(fontCandidates)
|
| 182 |
-
const currentFont =
|
| 183 |
selectedBlock?.style?.fontFamilies?.[0] ??
|
| 184 |
fontFamily ??
|
| 185 |
firstBlock?.style?.fontFamilies?.[0] ??
|
| 186 |
-
(hasBlocks ?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
const currentEffect = normalizeEffect(
|
| 188 |
selectedBlock?.style?.effect ?? renderEffect,
|
| 189 |
)
|
|
@@ -264,7 +301,11 @@ export function RenderControlsPanel() {
|
|
| 264 |
nextFont: string,
|
| 265 |
current: string[] | undefined,
|
| 266 |
) => {
|
| 267 |
-
const base =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
return [nextFont, ...base.filter((family) => family !== nextFont)]
|
| 269 |
}
|
| 270 |
|
|
@@ -360,19 +401,23 @@ export function RenderControlsPanel() {
|
|
| 360 |
data-testid='render-font-select'
|
| 361 |
size='sm'
|
| 362 |
className='h-7 w-full min-w-0 text-xs'
|
| 363 |
-
style={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
>
|
| 365 |
<SelectValue placeholder={t('render.fontPlaceholder')} />
|
| 366 |
</SelectTrigger>
|
| 367 |
<SelectContent position='popper'>
|
| 368 |
{fontOptions.map((font, index) => (
|
| 369 |
<SelectItem
|
| 370 |
-
key={font}
|
| 371 |
-
value={font}
|
| 372 |
-
style={{ fontFamily: font }}
|
| 373 |
data-testid={`render-font-option-${index}`}
|
| 374 |
>
|
| 375 |
-
{font}
|
| 376 |
</SelectItem>
|
| 377 |
))}
|
| 378 |
</SelectContent>
|
|
|
|
| 20 |
TextAlign,
|
| 21 |
TextStyle,
|
| 22 |
} from '@/types'
|
| 23 |
+
import type { FontFaceInfo } from '@/lib/protocol'
|
| 24 |
import { Button } from '@/components/ui/button'
|
| 25 |
import { ColorPicker } from '@/components/ui/color-picker'
|
| 26 |
import { Input } from '@/components/ui/input'
|
|
|
|
| 43 |
import { cn } from '@/lib/utils'
|
| 44 |
|
| 45 |
const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
|
| 46 |
+
const DEFAULT_FONT_FACES: FontFaceInfo[] = [
|
| 47 |
+
{
|
| 48 |
+
familyName: 'Arial',
|
| 49 |
+
postScriptName: 'ArialMT',
|
| 50 |
+
},
|
| 51 |
+
]
|
| 52 |
const DEFAULT_EFFECT: RenderEffect = {
|
| 53 |
italic: false,
|
| 54 |
bold: false,
|
|
|
|
| 96 |
return [r, g, b, clampByte(alpha)]
|
| 97 |
}
|
| 98 |
|
| 99 |
+
const uniqueFontFaces = (values: FontFaceInfo[]) => {
|
| 100 |
const seen = new Set<string>()
|
| 101 |
return values.filter((value) => {
|
| 102 |
+
if (!value.postScriptName || seen.has(value.postScriptName)) return false
|
| 103 |
+
seen.add(value.postScriptName)
|
| 104 |
return true
|
| 105 |
})
|
| 106 |
}
|
| 107 |
|
| 108 |
+
const findFontFace = (fonts: FontFaceInfo[], value?: string) => {
|
| 109 |
+
if (!value) return undefined
|
| 110 |
+
return fonts.find(
|
| 111 |
+
(font) =>
|
| 112 |
+
font.postScriptName === value ||
|
| 113 |
+
font.familyName === value ||
|
| 114 |
+
font.familyName.trim() === value.trim(),
|
| 115 |
+
)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const normalizeFontValue = (fonts: FontFaceInfo[], value?: string) =>
|
| 119 |
+
findFontFace(fonts, value)?.postScriptName ?? value
|
| 120 |
+
|
| 121 |
+
const fallbackFontFace = (value?: string): FontFaceInfo | undefined => {
|
| 122 |
+
const normalized = value?.trim()
|
| 123 |
+
if (!normalized) return undefined
|
| 124 |
+
return {
|
| 125 |
+
familyName: normalized,
|
| 126 |
+
postScriptName: normalized,
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
const normalizeEffect = (effect?: Partial<RenderEffect>): RenderEffect => ({
|
| 131 |
italic: effect?.italic ?? false,
|
| 132 |
bold: effect?.bold ?? false,
|
|
|
|
| 195 |
: undefined
|
| 196 |
const firstBlock = textBlocks[0]
|
| 197 |
const hasBlocks = textBlocks.length > 0
|
| 198 |
+
const fontCandidates = uniqueFontFaces(
|
| 199 |
+
[
|
| 200 |
+
...availableFonts,
|
| 201 |
+
...(fontFamily ? [fallbackFontFace(fontFamily)] : []),
|
| 202 |
+
...(selectedBlock?.style?.fontFamilies
|
| 203 |
+
?.slice(0, 1)
|
| 204 |
+
?.map(fallbackFontFace) ?? []),
|
| 205 |
+
...(firstBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ??
|
| 206 |
+
[]),
|
| 207 |
+
...DEFAULT_FONT_FACES,
|
| 208 |
+
].filter((value): value is FontFaceInfo => !!value),
|
| 209 |
+
)
|
| 210 |
+
const fallbackFontFaces =
|
| 211 |
+
fontCandidates.length > 0 ? fontCandidates : DEFAULT_FONT_FACES
|
| 212 |
const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
|
| 213 |
+
const fontOptions = fontCandidates
|
| 214 |
+
const currentFontCandidate =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
selectedBlock?.style?.fontFamilies?.[0] ??
|
| 216 |
fontFamily ??
|
| 217 |
firstBlock?.style?.fontFamilies?.[0] ??
|
| 218 |
+
(hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
|
| 219 |
+
const currentFontFace =
|
| 220 |
+
findFontFace(fontOptions, currentFontCandidate) ??
|
| 221 |
+
fallbackFontFace(currentFontCandidate)
|
| 222 |
+
const currentFont = currentFontFace?.postScriptName ?? ''
|
| 223 |
+
const currentFontFamilyName = currentFontFace?.familyName
|
| 224 |
const currentEffect = normalizeEffect(
|
| 225 |
selectedBlock?.style?.effect ?? renderEffect,
|
| 226 |
)
|
|
|
|
| 301 |
nextFont: string,
|
| 302 |
current: string[] | undefined,
|
| 303 |
) => {
|
| 304 |
+
const base = (
|
| 305 |
+
current?.length
|
| 306 |
+
? current
|
| 307 |
+
: fallbackFontFaces.map((font) => font.postScriptName)
|
| 308 |
+
).map((family) => normalizeFontValue(fontOptions, family) ?? family)
|
| 309 |
return [nextFont, ...base.filter((family) => family !== nextFont)]
|
| 310 |
}
|
| 311 |
|
|
|
|
| 401 |
data-testid='render-font-select'
|
| 402 |
size='sm'
|
| 403 |
className='h-7 w-full min-w-0 text-xs'
|
| 404 |
+
style={
|
| 405 |
+
currentFontFamilyName
|
| 406 |
+
? { fontFamily: currentFontFamilyName }
|
| 407 |
+
: undefined
|
| 408 |
+
}
|
| 409 |
>
|
| 410 |
<SelectValue placeholder={t('render.fontPlaceholder')} />
|
| 411 |
</SelectTrigger>
|
| 412 |
<SelectContent position='popper'>
|
| 413 |
{fontOptions.map((font, index) => (
|
| 414 |
<SelectItem
|
| 415 |
+
key={font.postScriptName}
|
| 416 |
+
value={font.postScriptName}
|
| 417 |
+
style={{ fontFamily: font.familyName }}
|
| 418 |
data-testid={`render-font-option-${index}`}
|
| 419 |
>
|
| 420 |
+
{font.familyName}
|
| 421 |
</SelectItem>
|
| 422 |
))}
|
| 423 |
</SelectContent>
|
ui/lib/api.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
| 13 |
DocumentDetail,
|
| 14 |
DocumentSummary,
|
| 15 |
ExportResult,
|
|
|
|
| 16 |
JobState,
|
| 17 |
LlmModelInfo,
|
| 18 |
LlmState,
|
|
@@ -374,6 +375,21 @@ export const api = {
|
|
| 374 |
})
|
| 375 |
},
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
async exportAllInpainted(): Promise<number> {
|
| 378 |
return withRpcError('export_all_inpainted', async () => {
|
| 379 |
const result = await fetchJson<ExportResult>('/exports?layer=inpainted', {
|
|
@@ -568,8 +584,8 @@ export const api = {
|
|
| 568 |
})
|
| 569 |
},
|
| 570 |
|
| 571 |
-
async
|
| 572 |
-
return fetchJson<
|
| 573 |
},
|
| 574 |
|
| 575 |
async getApiKey(provider: string): Promise<string | null> {
|
|
|
|
| 13 |
DocumentDetail,
|
| 14 |
DocumentSummary,
|
| 15 |
ExportResult,
|
| 16 |
+
FontFaceInfo,
|
| 17 |
JobState,
|
| 18 |
LlmModelInfo,
|
| 19 |
LlmState,
|
|
|
|
| 375 |
})
|
| 376 |
},
|
| 377 |
|
| 378 |
+
async exportPsdDocument(index: number): Promise<void> {
|
| 379 |
+
return withRpcError('export_psd_document', async () => {
|
| 380 |
+
const summary = await getDocumentSummaryAtIndex(index)
|
| 381 |
+
const file = await fetchBinary(`/documents/${summary.id}/export/psd`)
|
| 382 |
+
const blob = new Blob([toArrayBuffer(file.data)], {
|
| 383 |
+
type: file.contentType,
|
| 384 |
+
})
|
| 385 |
+
try {
|
| 386 |
+
await fileSave(blob, {
|
| 387 |
+
fileName: file.filename ?? `${summary.name}_koharu.psd`,
|
| 388 |
+
})
|
| 389 |
+
} catch {}
|
| 390 |
+
})
|
| 391 |
+
},
|
| 392 |
+
|
| 393 |
async exportAllInpainted(): Promise<number> {
|
| 394 |
return withRpcError('export_all_inpainted', async () => {
|
| 395 |
const result = await fetchJson<ExportResult>('/exports?layer=inpainted', {
|
|
|
|
| 584 |
})
|
| 585 |
},
|
| 586 |
|
| 587 |
+
async listFonts(): Promise<FontFaceInfo[]> {
|
| 588 |
+
return fetchJson<FontFaceInfo[]>('/fonts')
|
| 589 |
},
|
| 590 |
|
| 591 |
async getApiKey(provider: string): Promise<string | null> {
|
ui/lib/errors.ts
CHANGED
|
@@ -8,6 +8,7 @@ const SURFACED_RPC_METHODS = new Set([
|
|
| 8 |
'open_documents',
|
| 9 |
'add_documents',
|
| 10 |
'export_document',
|
|
|
|
| 11 |
'export_all_inpainted',
|
| 12 |
'export_all_rendered',
|
| 13 |
'detect',
|
|
|
|
| 8 |
'open_documents',
|
| 9 |
'add_documents',
|
| 10 |
'export_document',
|
| 11 |
+
'export_psd_document',
|
| 12 |
'export_all_inpainted',
|
| 13 |
'export_all_rendered',
|
| 14 |
'detect',
|
ui/lib/generated/protocol/DocumentChangedEvent.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
| 2 |
|
| 3 |
export type DocumentChangedEvent = {
|
| 4 |
documentId: string
|
| 5 |
-
revision:
|
| 6 |
changed: Array<string>
|
| 7 |
}
|
|
|
|
| 2 |
|
| 3 |
export type DocumentChangedEvent = {
|
| 4 |
documentId: string
|
| 5 |
+
revision: number
|
| 6 |
changed: Array<string>
|
| 7 |
}
|
ui/lib/generated/protocol/DocumentDetail.ts
CHANGED
|
@@ -7,6 +7,6 @@ export type DocumentDetail = {
|
|
| 7 |
name: string
|
| 8 |
width: number
|
| 9 |
height: number
|
| 10 |
-
revision:
|
| 11 |
textBlocks: Array<TextBlockDetail>
|
| 12 |
}
|
|
|
|
| 7 |
name: string
|
| 8 |
width: number
|
| 9 |
height: number
|
| 10 |
+
revision: number
|
| 11 |
textBlocks: Array<TextBlockDetail>
|
| 12 |
}
|
ui/lib/generated/protocol/DocumentSummary.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type DocumentSummary = {
|
|
| 5 |
name: string
|
| 6 |
width: number
|
| 7 |
height: number
|
| 8 |
-
revision:
|
| 9 |
hasSegment: boolean
|
| 10 |
hasInpainted: boolean
|
| 11 |
hasBrushLayer: boolean
|
|
|
|
| 5 |
name: string
|
| 6 |
width: number
|
| 7 |
height: number
|
| 8 |
+
revision: number
|
| 9 |
hasSegment: boolean
|
| 10 |
hasInpainted: boolean
|
| 11 |
hasBrushLayer: boolean
|
ui/lib/generated/protocol/DownloadState.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { TransferStatus } from './TransferStatus'
|
|
| 4 |
export type DownloadState = {
|
| 5 |
id: string
|
| 6 |
filename: string
|
| 7 |
-
downloaded:
|
| 8 |
-
total:
|
| 9 |
status: TransferStatus
|
| 10 |
error: string | null
|
| 11 |
}
|
|
|
|
| 4 |
export type DownloadState = {
|
| 5 |
id: string
|
| 6 |
filename: string
|
| 7 |
+
downloaded: number
|
| 8 |
+
total: number | null
|
| 9 |
status: TransferStatus
|
| 10 |
error: string | null
|
| 11 |
}
|
ui/lib/generated/protocol/FontFaceInfo.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
| 2 |
+
|
| 3 |
+
export type FontFaceInfo = { familyName: string; postScriptName: string }
|
ui/lib/generated/protocol/TextBlockDetail.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type TextBlockDetail = {
|
|
| 14 |
[[number, number], [number, number], [number, number], [number, number]]
|
| 15 |
> | null
|
| 16 |
sourceDirection: TextDirection | null
|
|
|
|
| 17 |
sourceLanguage: string | null
|
| 18 |
rotationDeg: number | null
|
| 19 |
detectedFontSizePx: number | null
|
|
|
|
| 14 |
[[number, number], [number, number], [number, number], [number, number]]
|
| 15 |
> | null
|
| 16 |
sourceDirection: TextDirection | null
|
| 17 |
+
renderedDirection: TextDirection | null
|
| 18 |
sourceLanguage: string | null
|
| 19 |
rotationDeg: number | null
|
| 20 |
detectedFontSizePx: number | null
|
ui/lib/protocol.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { DocumentsChangedEvent as GeneratedDocumentsChangedEvent } from '@/
|
|
| 19 |
import type { DocumentSummary as GeneratedDocumentSummary } from '@/lib/generated/protocol/DocumentSummary'
|
| 20 |
import type { ExportLayer } from '@/lib/generated/protocol/ExportLayer'
|
| 21 |
import type { ExportResult } from '@/lib/generated/protocol/ExportResult'
|
|
|
|
| 22 |
import type { ImportMode } from '@/lib/generated/protocol/ImportMode'
|
| 23 |
import type { ImportResult as GeneratedImportResult } from '@/lib/generated/protocol/ImportResult'
|
| 24 |
import type { InpaintRegionRequest } from '@/lib/generated/protocol/InpaintRegionRequest'
|
|
@@ -46,6 +47,7 @@ export type {
|
|
| 46 |
CreateTextBlock,
|
| 47 |
ExportLayer,
|
| 48 |
ExportResult,
|
|
|
|
| 49 |
ImportMode,
|
| 50 |
InpaintRegionRequest,
|
| 51 |
JobState,
|
|
|
|
| 19 |
import type { DocumentSummary as GeneratedDocumentSummary } from '@/lib/generated/protocol/DocumentSummary'
|
| 20 |
import type { ExportLayer } from '@/lib/generated/protocol/ExportLayer'
|
| 21 |
import type { ExportResult } from '@/lib/generated/protocol/ExportResult'
|
| 22 |
+
import type { FontFaceInfo } from '@/lib/generated/protocol/FontFaceInfo'
|
| 23 |
import type { ImportMode } from '@/lib/generated/protocol/ImportMode'
|
| 24 |
import type { ImportResult as GeneratedImportResult } from '@/lib/generated/protocol/ImportResult'
|
| 25 |
import type { InpaintRegionRequest } from '@/lib/generated/protocol/InpaintRegionRequest'
|
|
|
|
| 47 |
CreateTextBlock,
|
| 48 |
ExportLayer,
|
| 49 |
ExportResult,
|
| 50 |
+
FontFaceInfo,
|
| 51 |
ImportMode,
|
| 52 |
InpaintRegionRequest,
|
| 53 |
JobState,
|
ui/lib/query/hooks.ts
CHANGED
|
@@ -56,7 +56,7 @@ export const useThumbnailQuery = (index: number, documentsVersion: number) =>
|
|
| 56 |
export const useFontsQuery = () =>
|
| 57 |
useQuery({
|
| 58 |
queryKey: queryKeys.fonts,
|
| 59 |
-
queryFn: () => api.
|
| 60 |
staleTime: 10 * 60 * 1000,
|
| 61 |
})
|
| 62 |
|
|
|
|
| 56 |
export const useFontsQuery = () =>
|
| 57 |
useQuery({
|
| 58 |
queryKey: queryKeys.fonts,
|
| 59 |
+
queryFn: () => api.listFonts(),
|
| 60 |
staleTime: 10 * 60 * 1000,
|
| 61 |
})
|
| 62 |
|
ui/lib/query/mutations.ts
CHANGED
|
@@ -519,6 +519,11 @@ export const useDocumentMutations = () => {
|
|
| 519 |
await api.exportDocument(currentDocumentIndex)
|
| 520 |
}, [])
|
| 521 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
const exportAllInpainted = useCallback(async () => {
|
| 523 |
await api.exportAllInpainted()
|
| 524 |
}, [])
|
|
@@ -545,6 +550,7 @@ export const useDocumentMutations = () => {
|
|
| 545 |
processAllImages,
|
| 546 |
inpaintAndRenderImage,
|
| 547 |
exportDocument,
|
|
|
|
| 548 |
exportAllInpainted,
|
| 549 |
exportAllRendered,
|
| 550 |
cancelOperation,
|
|
|
|
| 519 |
await api.exportDocument(currentDocumentIndex)
|
| 520 |
}, [])
|
| 521 |
|
| 522 |
+
const exportPsdDocument = useCallback(async () => {
|
| 523 |
+
const { currentDocumentIndex } = useEditorUiStore.getState()
|
| 524 |
+
await api.exportPsdDocument(currentDocumentIndex)
|
| 525 |
+
}, [])
|
| 526 |
+
|
| 527 |
const exportAllInpainted = useCallback(async () => {
|
| 528 |
await api.exportAllInpainted()
|
| 529 |
}, [])
|
|
|
|
| 550 |
processAllImages,
|
| 551 |
inpaintAndRenderImage,
|
| 552 |
exportDocument,
|
| 553 |
+
exportPsdDocument,
|
| 554 |
exportAllInpainted,
|
| 555 |
exportAllRendered,
|
| 556 |
cancelOperation,
|