Mayo commited on
Commit
0b969ed
·
unverified ·
1 Parent(s): 6d4091d

feat: export to psd

Browse files
Files changed (44) hide show
  1. Cargo.lock +65 -145
  2. Cargo.toml +3 -1
  3. README.md +20 -15
  4. docs/README.ja.md +5 -0
  5. docs/README.zh-CN.md +5 -0
  6. koharu-pipeline/src/ops/edit.rs +1 -0
  7. koharu-pipeline/src/ops/vision.rs +2 -1
  8. koharu-psd/Cargo.toml +21 -0
  9. koharu-psd/examples/from_image.rs +201 -0
  10. koharu-psd/examples/full_fixture.rs +211 -0
  11. koharu-psd/src/descriptor.rs +181 -0
  12. koharu-psd/src/engine_data.rs +852 -0
  13. koharu-psd/src/error.rs +25 -0
  14. koharu-psd/src/export.rs +827 -0
  15. koharu-psd/src/lib.rs +9 -0
  16. koharu-psd/src/packbits.rs +150 -0
  17. koharu-psd/src/writer.rs +143 -0
  18. koharu-psd/tests/export.rs +214 -0
  19. koharu-renderer/Cargo.toml +1 -1
  20. koharu-renderer/benches/rendering.rs +8 -2
  21. koharu-renderer/src/facade.rs +64 -26
  22. koharu-renderer/src/font.rs +54 -127
  23. koharu-renderer/src/layout.rs +25 -3
  24. koharu-renderer/src/shape.rs +14 -6
  25. koharu-renderer/src/text/script.rs +34 -1
  26. koharu-renderer/tests/rendering.rs +15 -5
  27. koharu-rpc/Cargo.toml +1 -0
  28. koharu-rpc/src/api.rs +62 -8
  29. koharu-rpc/src/mcp/mod.rs +5 -1
  30. koharu-types/src/lib.rs +1 -0
  31. koharu-types/src/protocol.rs +10 -0
  32. ui/components/MenuBar.tsx +8 -0
  33. ui/components/panels/RenderControlsPanel.tsx +68 -23
  34. ui/lib/api.ts +18 -2
  35. ui/lib/errors.ts +1 -0
  36. ui/lib/generated/protocol/DocumentChangedEvent.ts +1 -1
  37. ui/lib/generated/protocol/DocumentDetail.ts +1 -1
  38. ui/lib/generated/protocol/DocumentSummary.ts +1 -1
  39. ui/lib/generated/protocol/DownloadState.ts +2 -2
  40. ui/lib/generated/protocol/FontFaceInfo.ts +3 -0
  41. ui/lib/generated/protocol/TextBlockDetail.ts +1 -0
  42. ui/lib/protocol.ts +2 -0
  43. ui/lib/query/hooks.ts +1 -1
  44. 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.10.1"
1940
  source = "registry+https://github.com/rust-lang/crates.io-index"
1941
- checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
1942
  dependencies = [
1943
  "bytemuck",
1944
  ]
1945
 
1946
  [[package]]
1947
- name = "font-types"
1948
- version = "0.11.0"
1949
  source = "registry+https://github.com/rust-lang/crates.io-index"
1950
- checksum = "b1e4d2d0cf79d38430cc9dc9aadec84774bff2e1ba30ae2bf6c16cfce9385a23"
1951
  dependencies = [
1952
- "bytemuck",
1953
  ]
1954
 
1955
  [[package]]
1956
- name = "fontdue"
1957
- version = "0.9.3"
1958
  source = "registry+https://github.com/rust-lang/crates.io-index"
1959
- checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
1960
  dependencies = [
1961
- "hashbrown 0.15.5",
1962
- "ttf-parser 0.21.1",
 
 
 
 
1963
  ]
1964
 
1965
  [[package]]
1966
- name = "fontique"
1967
- version = "0.7.0"
1968
  source = "registry+https://github.com/rust-lang/crates.io-index"
1969
- checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b"
1970
  dependencies = [
1971
- "bytemuck",
1972
- "hashbrown 0.16.1",
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 0.37.0",
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 0.11.0",
6023
  ]
6024
 
6025
  [[package]]
@@ -6360,12 +6346,9 @@ dependencies = [
6360
 
6361
  [[package]]
6362
  name = "roxmltree"
6363
- version = "0.21.1"
6364
  source = "registry+https://github.com/rust-lang/crates.io-index"
6365
- checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
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 0.37.0",
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 0.60.2",
8943
- "windows-interface 0.59.3",
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 0.60.2",
9103
- "windows-interface 0.59.3",
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 0.60.2",
9116
- "windows-interface 0.59.3",
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
- fontique = "0.7"
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
- - MCP server for AI agents
 
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
- ### MCP Server
 
 
 
 
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<String>> {
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
- fontique = { workspace = true }
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::{FamilyName, FontBook, Properties},
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(&[FamilyName::SansSerif], &Properties::default())
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, TextStrokeStyle,
9
- TextStyle,
10
  };
11
 
12
  use crate::{
13
- font::{FamilyName, Font, FontBook, Properties},
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<String>> {
47
- let mut fontbook = self
48
  .fontbook
49
  .lock()
50
  .map_err(|_| anyhow::anyhow!("Failed to lock fontbook"))?;
51
- let mut families = fontbook.all_families();
52
- families.sort();
53
- Ok(families)
 
 
 
 
 
 
 
 
 
 
 
 
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 font = fontbook.query(
277
- style
278
- .font_families
279
- .iter()
280
- .map(|family| FamilyName::Title(family.to_string()))
281
- .collect::<Vec<_>>()
282
- .as_slice(),
283
- &Properties::default(),
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 mut fonts = Vec::new();
455
- for name in candidates {
456
- if let Ok(font) = fontbook.query(&[FamilyName::Title(name.to_string())], &props) {
457
- fonts.push(font);
458
- }
459
- }
460
- fonts
 
 
 
 
 
 
 
 
 
 
 
 
 
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 fontique::{
5
- Attributes, Blob, Collection, CollectionOptions, FamilyId, FontStyle as Style,
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
- /// Font data stored in a shared blob for cheap cloning.
71
- blob: Blob<u8>,
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.blob.as_ref(), self.index)
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.blob.as_ref(), self.index)
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.blob.as_ref(), settings)
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
- collection: Collection,
156
- source_cache: SourceCache,
157
- cache: HashMap<CacheKey, Font>,
158
  }
159
 
160
  impl FontBook {
161
- /// Creates a FontBook with only system fonts.
162
  pub fn new() -> Self {
163
- let collection = Collection::new(CollectionOptions {
164
- shared: false,
165
- system_fonts: true,
166
- });
167
- let source_cache = SourceCache::new(SourceCacheOptions { shared: true });
168
  Self {
169
- collection,
170
- source_cache,
171
  cache: HashMap::new(),
172
  }
173
  }
174
 
175
- /// Returns all available font family names.
176
- pub fn all_families(&mut self) -> Vec<String> {
177
- self.collection
178
- .family_names()
179
- .map(|name| name.to_string())
180
- .collect()
181
  }
182
 
183
- /// Queries for a font by family names (with fallbacks) and properties.
184
- ///
185
- /// The first matching font from the family list will be returned.
186
- pub fn query(
187
- &mut self,
188
- families: &[FamilyName],
189
- properties: &Properties,
190
- ) -> anyhow::Result<Font> {
191
- let mut query = self.collection.query(&mut self.source_cache);
192
- query.set_families(families.iter().map(|name| name.to_query_family()));
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
- if let Some(font) = self.cache.get(&cache_key) {
 
 
 
 
211
  return Ok(font.clone());
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
214
  let font = Font {
215
- blob,
216
- index,
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::{FamilyName, Font, FontBook, Properties};
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 Ok(font) = book.query(&[FamilyName::Title(name.to_string())], &props) {
 
 
 
 
 
 
 
 
 
 
 
 
 
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::{FamilyName, FontBook, Properties};
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.query(
222
- &[FamilyName::Title(name.to_string())],
223
- &Properties::default(),
224
- )
225
- .ok()
 
 
 
 
 
 
 
 
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 super::{font_families_for_text, is_latin_only, normalize_translation_for_layout};
 
 
 
 
 
 
 
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::{FamilyName, Font, FontBook, Properties},
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 font = book.query(
25
- &[FamilyName::Title(family_name.to_string())],
26
- &Properties::default(),
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, IndexPayload,
27
- InpaintPartialPayload, InpaintRegion, JobState, JobStatus, LlmLoadPayload, LlmLoadRequest,
28
- LlmModelInfo, MaskRegionRequest, MetaInfo, OpenDocumentsPayload, PipelineJobRequest, Region,
29
- RenderPayload, RenderRequest, SerializableDynamicImage, TextBlock, TextBlockDetail,
30
- TextBlockPatch, TranslateRequest, UpdateBrushLayerPayload, UpdateInpaintMaskPayload,
 
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<String>>> {
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 koharu_types::{TextAlign, TextBlock, TextBlockPatch, TextStyle};
 
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.join(", "))
 
 
 
 
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 DEFAULT_FONT_FAMILIES = ['Arial']
 
 
 
 
 
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 uniqueStrings = (values: string[]) => {
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 fallbackFontFamilies =
171
- availableFonts.length > 0 ? [availableFonts[0]] : DEFAULT_FONT_FAMILIES
 
 
 
 
 
 
 
 
 
 
 
 
172
  const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
173
- const fontCandidates =
174
- availableFonts.length > 0
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 ? fallbackFontFamilies[0] : '')
 
 
 
 
 
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 = current?.length ? current : fallbackFontFamilies
 
 
 
 
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={currentFont ? { fontFamily: currentFont } : undefined}
 
 
 
 
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 listFontFamilies(): Promise<string[]> {
572
- return fetchJson<string[]>('/fonts')
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: bigint
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: bigint
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: bigint
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: bigint
8
- total: bigint | null
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.listFontFamilies(),
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,