psdparse 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {psdparse-0.1.0 → psdparse-0.2.0}/PKG-INFO +3 -14
  2. {psdparse-0.1.0 → psdparse-0.2.0}/README.md +2 -13
  3. {psdparse-0.1.0 → psdparse-0.2.0}/docs/PYTHON_API.md +47 -0
  4. {psdparse-0.1.0 → psdparse-0.2.0}/docs/ROADMAP.md +7 -0
  5. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/CMakeLists.txt +1 -0
  6. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/bmp.cpp +1 -0
  7. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psddata.h +35 -0
  8. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psddesc.cpp +16 -5
  9. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psddesc.h +8 -4
  10. psdparse-0.2.0/psdparse/psdengine.cpp +261 -0
  11. psdparse-0.2.0/psdparse/psdengine.h +34 -0
  12. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdimage.cpp +1 -0
  13. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdlayer.cpp +46 -0
  14. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdlayer.h +1 -0
  15. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdparse.cpp +4 -2
  16. {psdparse-0.1.0 → psdparse-0.2.0}/pyproject.toml +1 -1
  17. {psdparse-0.1.0 → psdparse-0.2.0}/python/psdparse_module.cpp +33 -0
  18. {psdparse-0.1.0 → psdparse-0.2.0}/tests/conftest.py +12 -0
  19. psdparse-0.2.0/tests/test_text.py +111 -0
  20. {psdparse-0.1.0 → psdparse-0.2.0}/.gitignore +0 -0
  21. {psdparse-0.1.0 → psdparse-0.2.0}/CMakeLists.txt +0 -0
  22. {psdparse-0.1.0 → psdparse-0.2.0}/CMakePresets.json +0 -0
  23. {psdparse-0.1.0 → psdparse-0.2.0}/LICENSE +0 -0
  24. {psdparse-0.1.0 → psdparse-0.2.0}/Makefile +0 -0
  25. {psdparse-0.1.0 → psdparse-0.2.0}/docs/ARCHITECTURE.md +0 -0
  26. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psd_cli.cpp +0 -0
  27. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdbase.h +0 -0
  28. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdfile.cpp +0 -0
  29. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdfile.h +0 -0
  30. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdparse.h +0 -0
  31. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdresource.cpp +0 -0
  32. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdresource.h +0 -0
  33. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdwrite.cpp +0 -0
  34. {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdwrite.h +0 -0
  35. {psdparse-0.1.0 → psdparse-0.2.0}/python/CMakeLists.txt +0 -0
  36. {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_header.py +0 -0
  37. {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_images.py +0 -0
  38. {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_layers.py +0 -0
  39. {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_save.py +0 -0
  40. {psdparse-0.1.0 → psdparse-0.2.0}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Fast PSD (Photoshop) reader/writer — C++17 core with pybind11 bindings
5
5
  Keywords: psd,photoshop,parser,image,graphics
6
6
  Author-Email: wamsoft <wtnbgo@gmail.com>
@@ -135,24 +135,13 @@ Full API reference: [docs/PYTHON_API.md](docs/PYTHON_API.md).
135
135
 
136
136
  ## Tests
137
137
 
138
- Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need two sample PSDs at the repo root (not in git see below).
138
+ Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need sample PSDs placed at the repo root or `tests/data/` (not committed — listed in `.gitignore`); without them the tests skip rather than fail.
139
139
 
140
140
  ```powershell
141
- # After building with x64-windows-python preset:
141
+ # after building the Python module (pip install . / preset x64-windows-python):
142
142
  python -m pytest -v
143
143
  ```
144
144
 
145
- ### Sample PSDs
146
-
147
- The 27 pytest tests use these files:
148
-
149
- | File | Size | Description |
150
- |---|---|---|
151
- | `UI-PSDサンプル.psd` | 800×600, 50 layers, ~2 MB | UI button mock-up. Folder groups, transparent overlays. |
152
- | `園部由夏_a.psd` | 2500×3500, 28 layers, ~21 MB | Illustration. PASS_THROUGH groups, Unicode layer names (luni records). |
153
-
154
- Place them at the repo root (the conftest.py also checks `tests/data/` first). They are listed in `.gitignore`. If you can't source them, the tests will skip rather than fail.
155
-
156
145
  ## tools/psd_export.py
157
146
 
158
147
  ```
@@ -94,24 +94,13 @@ Full API reference: [docs/PYTHON_API.md](docs/PYTHON_API.md).
94
94
 
95
95
  ## Tests
96
96
 
97
- Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need two sample PSDs at the repo root (not in git see below).
97
+ Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need sample PSDs placed at the repo root or `tests/data/` (not committed — listed in `.gitignore`); without them the tests skip rather than fail.
98
98
 
99
99
  ```powershell
100
- # After building with x64-windows-python preset:
100
+ # after building the Python module (pip install . / preset x64-windows-python):
101
101
  python -m pytest -v
102
102
  ```
103
103
 
104
- ### Sample PSDs
105
-
106
- The 27 pytest tests use these files:
107
-
108
- | File | Size | Description |
109
- |---|---|---|
110
- | `UI-PSDサンプル.psd` | 800×600, 50 layers, ~2 MB | UI button mock-up. Folder groups, transparent overlays. |
111
- | `園部由夏_a.psd` | 2500×3500, 28 layers, ~21 MB | Illustration. PASS_THROUGH groups, Unicode layer names (luni records). |
112
-
113
- Place them at the repo root (the conftest.py also checks `tests/data/` first). They are listed in `.gitignore`. If you can't source them, the tests will skip rather than fail.
114
-
115
104
  ## tools/psd_export.py
116
105
 
117
106
  ```
@@ -101,6 +101,7 @@ Read-only view of one layer.
101
101
  | `channels` | `list[ChannelInfo]` | per-channel id+length |
102
102
  | `name` | `str` | raw Pascal-string name (CP932 etc on Japanese PSDs — pybind11 may raise UnicodeDecodeError when read) |
103
103
  | `name_unicode` | `str` | UTF-16 Unicode name from `luni` record (preferred) |
104
+ | `text` | `dict` \| `None` | text-layer content & style (`None` for non-text layers) — see below |
104
105
  | `visible` | `bool` | flag bit 1 inverted |
105
106
  | `transparency_protected` | `bool` | flag bit 0 |
106
107
  | `obsolete` | `bool` | flag bit 2 |
@@ -108,6 +109,52 @@ Read-only view of one layer.
108
109
 
109
110
  **Tip:** For Japanese PSDs prefer `name_unicode` and fall back to `name` only inside a `try / except UnicodeDecodeError`.
110
111
 
112
+ ### `layer.text` — text-layer content & style
113
+
114
+ For text layers (`layer_type == LayerType.TEXT`) this returns a dict parsed from
115
+ the `TySh` type-tool block and its embedded Adobe *EngineData*. For every other
116
+ layer it returns `None`.
117
+
118
+ ```python
119
+ {
120
+ "text": "普通のテキスト\r二行目\r三行目", # full string; line breaks are CR ('\r')
121
+ "orientation": "horizontal", # or "vertical"
122
+ "justification": 0, # first paragraph: 0=left 1=right 2=center
123
+ "transform": [xx, xy, yx, yy, tx, ty], # affine placement transform (tx,ty = translation)
124
+ "runs": [ # per-run character styling, in text order
125
+ {
126
+ "length": 8, # run length in UTF-16 code units (see note)
127
+ "font": "NotoSansJP-Thin", # resolved font-set family name
128
+ "size_px": 75.0, # font size (pt)
129
+ "color": (1.0, 0.0, 0.0, 1.0), # RGBA, each 0..1 (None if unspecified)
130
+ "tracking": -100, # letter spacing, 1/1000 em
131
+ "kerning": 0, # manual kerning
132
+ "auto_kerning": False, # metrics/optical kerning enabled
133
+ },
134
+ ...
135
+ ],
136
+ }
137
+ ```
138
+
139
+ Notes:
140
+ - **`length` is in UTF-16 code units**, matching Photoshop's EngineData
141
+ `RunLengthArray`. Astral characters (e.g. emoji) count as 2. To slice the
142
+ text by runs, index into `text.encode("utf-16-le")` (2 bytes per unit) rather
143
+ than the Python `str` (which is code-point indexed).
144
+ - Adjacent runs may share identical styling — Photoshop stores runs as authored,
145
+ so the run split does not always coincide with a style change.
146
+ - `color` is decoded from EngineData's `FillColor /Type 1` (RGB) and reordered
147
+ from its on-disk `[A R G B]` to `(R, G, B, A)`. Non-RGB fill types are not yet
148
+ decoded (`color` is `None`).
149
+
150
+ ```python
151
+ for layer in p.layers:
152
+ t = layer.text
153
+ if t is None:
154
+ continue
155
+ print(t["text"], "→", {r["font"] for r in t["runs"]})
156
+ ```
157
+
111
158
  ## Enums
112
159
 
113
160
  ```python
@@ -71,6 +71,13 @@ This is mostly a constructor that fills `Data` with a minimal-but-valid skeleton
71
71
 
72
72
  ## Other future work
73
73
 
74
+ - ✅ **Text layer content extraction (`TySh` type-tool additional info).** *Done 2026-07-27.* `lay.text` returns `{"text", "orientation", "justification", "transform", "runs":[{"length","font","size_px","color","tracking","kerning","auto_kerning"}]}` (or `None` for non-text layers). Implementation: `psddesc` now reads `tdta` raw data (length-prefixed), `loadLayerTypeTool` (`psdlayer.cpp`) parses the `TySh` header + text descriptor, and `psdengine.cpp` parses the embedded Adobe *EngineData* mini-language (FontSet / StyleRun / ParagraphRun) into per-run styling. Validated against `tests/data/fontsample.psd` (multi-font/size/color, **vertical**, emoji, tracking) — see `tests/test_text.py`.
75
+
76
+ **Deferred (need targeted sample PSDs, next turn):**
77
+ - **Non-RGB `FillColor`** — only `/Type 1` (RGB) decoded today; grayscale-mode / CMYK-mode text needs a sample to confirm `/Type` + `/Values` layout.
78
+ - **Warp text** — lives in the `TySh` *warp* descriptor (currently skipped, not in EngineData); needs samples with each warp style + non-zero bend/distortion.
79
+ - **Area (paragraph) vs point text / text box bounds** and **text-on-path** — need samples.
80
+ - **Leading / faux bold-italic / underline / strikethrough / paragraph indent+spacing** — keys exist in EngineData but are default-valued in the current sample, so per-run extraction can't be verified yet; needs a sample authored with non-default values.
74
81
  - 16-bit (`Lr16`) and 32-bit-float (`Lr32`) layer data: currently captured in `layerAndMaskTrailing` for round-trip but not exposed as decoded pixels.
75
82
  - Layer mask: parse + re-emission for masks > 20 bytes (real mask, vector mask flag, density / feather).
76
83
  - Image resources: most are currently passed through as raw bytes. Higher-level accessors for ICC profile, EXIF, thumbnail, version info, etc. would be nice for tools.
@@ -37,6 +37,7 @@ endif()
37
37
  add_library(${PROJECT_NAME} STATIC
38
38
  bmp.cpp
39
39
  psddesc.cpp
40
+ psdengine.cpp
40
41
  psdfile.cpp
41
42
  psdimage.cpp
42
43
  psdlayer.cpp
@@ -3,6 +3,7 @@
3
3
  #include <cstring>
4
4
  #include <fstream>
5
5
  #include <iostream>
6
+ #include <cstring>
6
7
 
7
8
  namespace psd {
8
9
 
@@ -5,6 +5,7 @@
5
5
  #include "psddesc.h"
6
6
 
7
7
  #include <vector>
8
+ #include <cstring>
8
9
 
9
10
  namespace psd {
10
11
  // レイヤタイプ
@@ -391,6 +392,37 @@ namespace psd {
391
392
  bool isMaskChannel() const { return (id == -3 || id == -2); }
392
393
  };
393
394
 
395
+ // テキストレイヤの文字スタイルラン。EngineData の StyleRun/RunArray の
396
+ // 1 エントリに対応し、length は本文の何文字分に適用されるか (RunLengthArray)。
397
+ struct TextStyleRun {
398
+ int length; // 適用文字数 (UTF-16 コードユニット)
399
+ u16str font; // 解決済みフォント名 (FontSet を index で引いたもの)
400
+ float fontSize; // pt
401
+ float color[4]; // RGBA 0..1 (EngineData の ARGB を並べ替えて格納)
402
+ bool hasColor; // FillColor が指定されていたか
403
+ int tracking; // トラッキング (字送り, 1/1000 em)
404
+ int kerning; // 手動カーニング
405
+ bool autoKerning; // 自動カーニング (メトリクス/オプティカル) 有効
406
+
407
+ TextStyleRun()
408
+ : length(0), fontSize(0.0f), color{0,0,0,1}, hasColor(false),
409
+ tracking(0), kerning(0), autoKerning(false) {}
410
+ };
411
+
412
+ // テキストレイヤ情報 (追加レイヤ情報 'TySh' 由来)。
413
+ struct TextLayerData {
414
+ bool present; // テキストレイヤとしてパースできたか
415
+ u16str text; // 本文全体 (改行は \r)
416
+ double transform[6]; // アフィン変換 xx,xy,yx,yy,tx,ty
417
+ std::string orientation; // "horizontal" / "vertical"
418
+ int justification; // 段落の行揃え 0=左 1=右 2=中央 (先頭段落)
419
+ std::vector<TextStyleRun> runs;
420
+
421
+ TextLayerData()
422
+ : present(false), transform{1,0,0,1,0,0},
423
+ justification(0) {}
424
+ };
425
+
394
426
  // レイヤ情報
395
427
  class Data;
396
428
  struct LayerInfo {
@@ -426,6 +458,9 @@ namespace psd {
426
458
  std::map<int, LayerCompInfo> layerComps;
427
459
  Descriptor layerCompDesc; // ディスクリプタ形式で全メタデータを格納
428
460
 
461
+ // テキストレイヤ情報 ('TySh' 由来)。layerType==TEXT のとき present=true。
462
+ TextLayerData textData;
463
+
429
464
  // 親フォルダレイヤ
430
465
  LayerInfo *parent;
431
466
 
@@ -39,11 +39,7 @@ namespace psd {
39
39
  case 'type':
40
40
  case 'GlbC': item = new DescriptorClass(type); break;
41
41
  case 'alis': item = new DescriptorAlias(); break;
42
- case 'tdta':
43
- // undocumented なうえ、サイズ情報もなくスキップできないので
44
- // これが出てきたらこれ以上はパースできない
45
- // item = new DescriptorRawData(data);
46
- break;
42
+ case 'tdta': item = new DescriptorRawData(); break;
47
43
  default:
48
44
  break;
49
45
  }
@@ -187,6 +183,21 @@ namespace psd {
187
183
  return true;
188
184
  }
189
185
 
186
+ bool
187
+ DescriptorRawData::load(IteratorBase *data)
188
+ {
189
+ int size = data->getInt32();
190
+ if (size < 0 || size > data->rest()) {
191
+ isValid = false;
192
+ return false;
193
+ }
194
+ bytes.resize((size_t)size);
195
+ if (size > 0) {
196
+ data->getData(&bytes[0], size);
197
+ }
198
+ return true;
199
+ }
200
+
190
201
  bool
191
202
  DescriptorAlias::load(IteratorBase *data)
192
203
  {
@@ -361,13 +361,17 @@ namespace psd {
361
361
  std::string alias;
362
362
  };
363
363
 
364
- // Raw は値の解釈がデータ次第でサイズがわからないので失敗扱いにしている
364
+ // Raw data ('tdta'): 4-byte length prefix followed by that many bytes.
365
+ // Used by the type-tool 'TySh' descriptor to carry the EngineData blob
366
+ // (Adobe's text-engine mini-language, parsed separately by psdengine).
365
367
  struct DescriptorRawData : DescriptorItem {
366
- DescriptorRawData(IteratorBase *data) : DescriptorItem(TYPE_RAW_DATA) {}
367
- virtual bool load(IteratorBase *data) { return false; }
368
+ DescriptorRawData() : DescriptorItem(TYPE_RAW_DATA) {}
369
+ virtual bool load(IteratorBase *data);
368
370
  virtual void dump(int indent) {
369
- dprint("%s\n", typeName());
371
+ dprint("%s (size:%zd)\n", typeName(), bytes.size());
370
372
  }
373
+
374
+ std::string bytes;
371
375
  };
372
376
 
373
377
  // --------------------------------------------------------------------------
@@ -0,0 +1,261 @@
1
+ #include "psdengine.h"
2
+
3
+ #include <cstdlib>
4
+ #include <map>
5
+ #include <string>
6
+ #include <vector>
7
+
8
+ namespace psd {
9
+ namespace {
10
+
11
+ // --------------------------------------------------------------------------
12
+ // パース済みツリー
13
+ // --------------------------------------------------------------------------
14
+ struct Node {
15
+ enum Kind { DICT, ARRAY, STRING, NUMBER, BOOL } kind;
16
+ std::map<std::string, Node*> dict;
17
+ std::vector<Node*> arr;
18
+ std::string str; // STRING: 生バイト (UTF-16BE, BOM 含む)
19
+ double num;
20
+ bool bl;
21
+
22
+ Node(Kind k) : kind(k), num(0), bl(false) {}
23
+ ~Node() {
24
+ for (std::map<std::string, Node*>::iterator it = dict.begin(); it != dict.end(); ++it)
25
+ delete it->second;
26
+ for (size_t i = 0; i < arr.size(); i++)
27
+ delete arr[i];
28
+ }
29
+ };
30
+
31
+ // --------------------------------------------------------------------------
32
+ // トークナイザ兼再帰下降パーサ
33
+ // --------------------------------------------------------------------------
34
+ struct Parser {
35
+ const unsigned char *p;
36
+ const unsigned char *end;
37
+ int depth; // 異常データでの無限再帰を防ぐガード
38
+
39
+ Parser(const char *d, size_t n)
40
+ : p((const unsigned char*)d), end((const unsigned char*)d + n), depth(0) {}
41
+
42
+ void skipWs() {
43
+ while (p < end) {
44
+ unsigned char c = *p;
45
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n') p++;
46
+ else break;
47
+ }
48
+ }
49
+
50
+ static bool isDelim(unsigned char c) {
51
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n' ||
52
+ c == '/' || c == '<' || c == '>' || c == '[' ||
53
+ c == ']' || c == '(' || c == ')';
54
+ }
55
+
56
+ Node *parseValue() {
57
+ skipWs();
58
+ if (p >= end) return 0;
59
+ unsigned char c = *p;
60
+ if (c == '<' && p + 1 < end && p[1] == '<') return parseDict();
61
+ if (c == '[') return parseArray();
62
+ if (c == '(') return parseString();
63
+ return parseToken();
64
+ }
65
+
66
+ Node *parseDict() {
67
+ p += 2; // "<<"
68
+ Node *n = new Node(Node::DICT);
69
+ if (++depth > 200) { --depth; return n; }
70
+ while (p < end) {
71
+ skipWs();
72
+ if (p >= end) break;
73
+ if (*p == '>' && p + 1 < end && p[1] == '>') { p += 2; break; }
74
+ if (*p == '/') {
75
+ std::string key = parseName();
76
+ Node *v = parseValue();
77
+ if (v) {
78
+ std::map<std::string, Node*>::iterator it = n->dict.find(key);
79
+ if (it != n->dict.end()) delete it->second;
80
+ n->dict[key] = v;
81
+ }
82
+ } else {
83
+ p++; // 想定外バイト: 前進して無限ループ回避
84
+ }
85
+ }
86
+ --depth;
87
+ return n;
88
+ }
89
+
90
+ Node *parseArray() {
91
+ p++; // '['
92
+ Node *n = new Node(Node::ARRAY);
93
+ if (++depth > 200) { --depth; return n; }
94
+ while (p < end) {
95
+ skipWs();
96
+ if (p >= end) break;
97
+ if (*p == ']') { p++; break; }
98
+ Node *v = parseValue();
99
+ if (v) n->arr.push_back(v);
100
+ else p++;
101
+ }
102
+ --depth;
103
+ return n;
104
+ }
105
+
106
+ std::string parseName() {
107
+ p++; // '/'
108
+ std::string s;
109
+ while (p < end && !isDelim(*p)) { s.push_back((char)*p); p++; }
110
+ return s;
111
+ }
112
+
113
+ // PDF 風の文字列。( ) は入れ子でバランスし、\ は次の 1 バイトをリテラル化。
114
+ // 中身は UTF-16BE の生バイト列としてそのまま保持する。
115
+ Node *parseString() {
116
+ p++; // '('
117
+ Node *n = new Node(Node::STRING);
118
+ int d = 1;
119
+ while (p < end) {
120
+ unsigned char c = *p++;
121
+ if (c == '\\') {
122
+ if (p < end) { n->str.push_back((char)*p); p++; }
123
+ continue;
124
+ }
125
+ if (c == '(') { d++; n->str.push_back('('); continue; }
126
+ if (c == ')') { if (--d == 0) break; n->str.push_back(')'); continue; }
127
+ n->str.push_back((char)c);
128
+ }
129
+ return n;
130
+ }
131
+
132
+ Node *parseToken() {
133
+ std::string s;
134
+ while (p < end && !isDelim(*p)) { s.push_back((char)*p); p++; }
135
+ if (s.empty()) { if (p < end) p++; return 0; }
136
+ if (s == "true" || s == "false") {
137
+ Node *n = new Node(Node::BOOL);
138
+ n->bl = (s == "true");
139
+ return n;
140
+ }
141
+ Node *n = new Node(Node::NUMBER);
142
+ n->num = atof(s.c_str());
143
+ return n;
144
+ }
145
+ };
146
+
147
+ // --------------------------------------------------------------------------
148
+ // 抽出ヘルパ
149
+ // --------------------------------------------------------------------------
150
+ Node *dget(Node *n, const char *key) {
151
+ if (!n || n->kind != Node::DICT) return 0;
152
+ std::map<std::string, Node*>::iterator it = n->dict.find(key);
153
+ return it == n->dict.end() ? 0 : it->second;
154
+ }
155
+
156
+ // STRING ノードの生バイト (UTF-16BE / BOM 付き) を u16str へ。
157
+ u16str toU16(Node *n) {
158
+ u16str out;
159
+ if (!n || n->kind != Node::STRING) return out;
160
+ const std::string &raw = n->str;
161
+ size_t i = 0;
162
+ if (raw.size() >= 2 &&
163
+ (unsigned char)raw[0] == 0xFE && (unsigned char)raw[1] == 0xFF) {
164
+ i = 2; // BOM
165
+ }
166
+ for (; i + 1 < raw.size(); i += 2) {
167
+ unsigned short hi = (unsigned char)raw[i];
168
+ unsigned short lo = (unsigned char)raw[i + 1];
169
+ out.push_back((char16_t)((hi << 8) | lo));
170
+ }
171
+ return out;
172
+ }
173
+
174
+ } // anonymous namespace
175
+
176
+ bool parseEngineData(const char *data, size_t len, TextLayerData &out)
177
+ {
178
+ Parser ps(data, len);
179
+ Node *root = ps.parseValue();
180
+ if (!root || root->kind != Node::DICT) { delete root; return false; }
181
+
182
+ bool haveText = false;
183
+ Node *engine = dget(root, "EngineDict");
184
+
185
+ // 本文
186
+ if (engine) {
187
+ Node *editor = dget(engine, "Editor");
188
+ Node *text = dget(editor, "Text");
189
+ if (text && text->kind == Node::STRING) {
190
+ out.text = toU16(text);
191
+ haveText = true;
192
+ }
193
+ }
194
+
195
+ // フォント名テーブル (ResourceDict/FontSet[]/Name)
196
+ std::vector<u16str> fonts;
197
+ {
198
+ Node *rd = dget(root, "ResourceDict");
199
+ Node *fs = dget(rd, "FontSet");
200
+ if (fs && fs->kind == Node::ARRAY) {
201
+ for (size_t i = 0; i < fs->arr.size(); i++) {
202
+ fonts.push_back(toU16(dget(fs->arr[i], "Name")));
203
+ }
204
+ }
205
+ }
206
+
207
+ // ラン単位スタイル (EngineDict/StyleRun)
208
+ if (engine) {
209
+ Node *styleRun = dget(engine, "StyleRun");
210
+ Node *runArray = dget(styleRun, "RunArray");
211
+ Node *runLen = dget(styleRun, "RunLengthArray");
212
+ if (runArray && runArray->kind == Node::ARRAY) {
213
+ for (size_t i = 0; i < runArray->arr.size(); i++) {
214
+ Node *ssd = dget(dget(runArray->arr[i], "StyleSheet"), "StyleSheetData");
215
+ TextStyleRun r;
216
+ if (runLen && runLen->kind == Node::ARRAY && i < runLen->arr.size() &&
217
+ runLen->arr[i]->kind == Node::NUMBER) {
218
+ r.length = (int)runLen->arr[i]->num;
219
+ }
220
+ if (ssd) {
221
+ Node *fidx = dget(ssd, "Font");
222
+ if (fidx && fidx->kind == Node::NUMBER) {
223
+ int idx = (int)fidx->num;
224
+ if (idx >= 0 && idx < (int)fonts.size()) r.font = fonts[idx];
225
+ }
226
+ Node *fsz = dget(ssd, "FontSize");
227
+ if (fsz && fsz->kind == Node::NUMBER) r.fontSize = (float)fsz->num;
228
+ Node *trk = dget(ssd, "Tracking");
229
+ if (trk && trk->kind == Node::NUMBER) r.tracking = (int)trk->num;
230
+ Node *krn = dget(ssd, "Kerning");
231
+ if (krn && krn->kind == Node::NUMBER) r.kerning = (int)krn->num;
232
+ Node *akn = dget(ssd, "AutoKerning");
233
+ if (akn && akn->kind == Node::BOOL) r.autoKerning = akn->bl;
234
+ Node *vals = dget(dget(ssd, "FillColor"), "Values");
235
+ if (vals && vals->kind == Node::ARRAY && vals->arr.size() >= 4) {
236
+ // EngineData の FillColor/Values は [A R G B]。RGBA へ並べ替える。
237
+ r.color[3] = (float)vals->arr[0]->num; // A
238
+ r.color[0] = (float)vals->arr[1]->num; // R
239
+ r.color[1] = (float)vals->arr[2]->num; // G
240
+ r.color[2] = (float)vals->arr[3]->num; // B
241
+ r.hasColor = true;
242
+ }
243
+ }
244
+ out.runs.push_back(r);
245
+ }
246
+ }
247
+
248
+ // 行揃え (先頭段落の Justification)
249
+ Node *paraArr = dget(dget(engine, "ParagraphRun"), "RunArray");
250
+ if (paraArr && paraArr->kind == Node::ARRAY && !paraArr->arr.empty()) {
251
+ Node *props = dget(dget(paraArr->arr[0], "ParagraphSheet"), "Properties");
252
+ Node *just = dget(props, "Justification");
253
+ if (just && just->kind == Node::NUMBER) out.justification = (int)just->num;
254
+ }
255
+ }
256
+
257
+ delete root;
258
+ return haveText;
259
+ }
260
+
261
+ } // namespace psd
@@ -0,0 +1,34 @@
1
+ #ifndef __psdengine_h__
2
+ #define __psdengine_h__
3
+
4
+ #include "psddata.h"
5
+
6
+ #include <cstddef>
7
+
8
+ namespace psd {
9
+
10
+ // Adobe "EngineData" (テキストエンジンのシリアライズ表現) を解析し、
11
+ // TextLayerData の text / runs / justification / orientation を埋める。
12
+ //
13
+ // EngineData は Descriptor とは別物の独自ミニ言語で、次の要素からなる:
14
+ // << >> 辞書 ( /key value の並び )
15
+ // [ ] 配列
16
+ // ( ... ) 文字列 (UTF-16BE, BOM 付き。( ) \ は \ でエスケープ)
17
+ // /name キー
18
+ // 123 / -1.5 数値
19
+ // true / false 真偽
20
+ //
21
+ // 抽出するのは:
22
+ // EngineDict/Editor/Text … 本文
23
+ // ResourceDict/FontSet[]/Name … フォント名テーブル
24
+ // EngineDict/StyleRun/RunArray[] … ラン単位スタイル
25
+ // StyleSheet/StyleSheetData/{Font,FontSize,FillColor}
26
+ // EngineDict/StyleRun/RunLengthArray … 各ランの文字数
27
+ // EngineDict/ParagraphRun/RunArray[0]/…/Justification … 行揃え
28
+ //
29
+ // 本文 (Editor/Text) が取得できたら true を返す。
30
+ bool parseEngineData(const char *data, size_t len, TextLayerData &out);
31
+
32
+ } // namespace psd
33
+
34
+ #endif // __psdengine_h__
@@ -11,6 +11,7 @@
11
11
  #define NOMINMAX
12
12
  #include <zlib.h>
13
13
  #endif
14
+ #include <cstring>
14
15
 
15
16
  // #define ENABLE_BMP_OUTPUT
16
17
  #ifdef ENABLE_BMP_OUTPUT
@@ -1,6 +1,7 @@
1
1
 
2
2
  #include "psddata.h"
3
3
  #include "psddesc.h"
4
+ #include "psdengine.h"
4
5
  #include <cstring>
5
6
 
6
7
  namespace psd {
@@ -113,4 +114,49 @@ namespace psd {
113
114
  return true;
114
115
  }
115
116
 
117
+ // 'TySh' Type tool object setting (Photoshop 6.0+)。
118
+ // version(2) transform(double*6) textVer(2) descVer(4) <text descriptor>
119
+ // warpVer(2) descVer(4) <warp descriptor> left top right bottom
120
+ // text descriptor の 'Txt ' に本文、'EngineData'(tdta) にラン単位スタイルが入る。
121
+ bool loadLayerTypeTool(LayerInfo &layer, AdditionalLayerInfo &additional)
122
+ {
123
+ IteratorBase *r = additional.data;
124
+ TextLayerData &td = layer.textData;
125
+
126
+ int version = r->getInt16(); // = 1
127
+ (void)version;
128
+ for (int i = 0; i < 6; i++) {
129
+ pun64 v;
130
+ v.i = r->getInt64();
131
+ td.transform[i] = v.f;
132
+ }
133
+ int textVer = r->getInt16(); // = 50
134
+ (void)textVer;
135
+ int descVer = r->getInt32(); // = 16
136
+ (void)descVer;
137
+
138
+ Descriptor text;
139
+ if (!text.load(r)) {
140
+ // 途中まで読めていれば itemMap には有効な項目が入っている
141
+ }
142
+
143
+ // 本文 ('Txt ' — キー末尾に空白)。EngineData が取れれば後で上書きされる。
144
+ DescriptorString *txt = text.item("Txt ");
145
+ if (txt) td.text = txt->val;
146
+
147
+ // 縦横 ('Ornt' enum: Hrzn / Vrtc)
148
+ DescriptorEnumerated *ornt = text.item("Ornt");
149
+ td.orientation = (ornt && ornt->enumId == "Vrtc") ? "vertical" : "horizontal";
150
+
151
+ // ラン単位スタイル (EngineData)
152
+ DescriptorRawData *eng = text.item("EngineData");
153
+ if (eng && !eng->bytes.empty()) {
154
+ parseEngineData(eng->bytes.data(), eng->bytes.size(), td);
155
+ }
156
+
157
+ td.present = true;
158
+ layer.layerType = LAYER_TYPE_TEXT;
159
+ return true;
160
+ }
161
+
116
162
  } // namespace psd
@@ -9,5 +9,6 @@ namespace psd {
9
9
  bool loadLayerId(LayerInfo &layer, AdditionalLayerInfo &additional);
10
10
  bool loadLayerMetadata(LayerInfo &layer, AdditionalLayerInfo &additional);
11
11
  bool loadLayerFillOpacity(LayerInfo &layer, AdditionalLayerInfo &additional);
12
+ bool loadLayerTypeTool(LayerInfo &layer, AdditionalLayerInfo &additional);
12
13
  }
13
14
  #endif // __psdlayer_h__
@@ -224,7 +224,10 @@ Data::processParsed()
224
224
  case 'iOpa': // fill opacity
225
225
  success = loadLayerFillOpacity(layer, additional);
226
226
  break;
227
-
227
+ case 'TySh': // Type tool object setting (Photoshop 6.0) — テキストレイヤ
228
+ success = loadLayerTypeTool(layer, additional);
229
+ break;
230
+
228
231
  // --- 未対応 ---
229
232
  case 'lrFX': // Effects Layer (Photoshop 5.0)
230
233
  case 'tySh': // Type Tool Info (Photoshop 5.0 and 5.5 only)
@@ -242,7 +245,6 @@ Data::processParsed()
242
245
  case 'brst': // Channel blending restrictions setting (Photoshop 6.0)
243
246
  case 'vmsk': // Vector mask setting (Photoshop 6.0)
244
247
  case 'vsms':
245
- case 'TySh': // Type tool object setting (Photoshop 6.0)
246
248
  case 'ffxi': // Foreign effect ID (Photoshop 6.0)
247
249
  case 'lnsr': // Layer name source setting (Photoshop 6.0)
248
250
  case 'shpa': // Pattern data (Photoshop 6.0)
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "psdparse"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Fast PSD (Photoshop) reader/writer — C++17 core with pybind11 bindings"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -36,6 +36,36 @@ py::bytes mergedImage(psd::PSDFile &self) {
36
36
  return py::bytes(buf);
37
37
  }
38
38
 
39
+ // Text-layer info ('TySh') as a dict, or None for non-text layers.
40
+ py::object layerText(const psd::LayerInfo &l) {
41
+ if (!l.textData.present) return py::none();
42
+ const psd::TextLayerData &t = l.textData;
43
+ py::dict d;
44
+ d["text"] = py::cast(t.text); // str (\r line breaks, as authored)
45
+ d["orientation"] = t.orientation; // "horizontal" / "vertical"
46
+ d["justification"] = t.justification; // 0=left 1=right 2=center (first paragraph)
47
+ py::list tf;
48
+ for (int i = 0; i < 6; i++) tf.append(t.transform[i]);
49
+ d["transform"] = tf; // affine xx,xy,yx,yy,tx,ty
50
+ py::list runs;
51
+ for (const auto &r : t.runs) {
52
+ py::dict rd;
53
+ rd["length"] = r.length; // UTF-16 code units covered by this run
54
+ rd["font"] = py::cast(r.font); // resolved font-set name
55
+ rd["size_px"] = r.fontSize; // pt
56
+ rd["tracking"] = r.tracking; // 1/1000 em
57
+ rd["kerning"] = r.kerning; // manual kerning
58
+ rd["auto_kerning"] = r.autoKerning; // metrics/optical kerning on
59
+ if (r.hasColor)
60
+ rd["color"] = py::make_tuple(r.color[0], r.color[1], r.color[2], r.color[3]); // RGBA 0..1
61
+ else
62
+ rd["color"] = py::none();
63
+ runs.append(rd);
64
+ }
65
+ d["runs"] = runs;
66
+ return std::move(d);
67
+ }
68
+
39
69
  py::bytes layerImage(psd::PSDFile &self, int index, const std::string &mode) {
40
70
  if (!self.isLoaded) throw std::runtime_error("PSD not loaded");
41
71
  if (index < 0 || index >= (int)self.layerList.size())
@@ -132,6 +162,9 @@ PYBIND11_MODULE(psdparse, m) {
132
162
  .def_property_readonly("name_unicode", [](const psd::LayerInfo &l) {
133
163
  return u16ToStr(l.layerNameUnicode);
134
164
  })
165
+ .def_property_readonly("text", &layerText,
166
+ "Text-layer content/style as a dict (keys: text, orientation, "
167
+ "justification, transform, runs[]), or None for non-text layers.")
135
168
  .def_property_readonly("visible", [](const psd::LayerInfo &l){ return l.isVisible(); })
136
169
  .def_property_readonly("transparency_protected", [](const psd::LayerInfo &l){ return l.isTransparencyProtected(); })
137
170
  .def_property_readonly("obsolete", [](const psd::LayerInfo &l){ return l.isObsolete(); })
@@ -53,6 +53,11 @@ def sample_large_psd():
53
53
  return _find_sample("園部由夏_a.psd", "large.psd")
54
54
 
55
55
 
56
+ @pytest.fixture(scope="session")
57
+ def sample_text_psd():
58
+ return _find_sample("fontsample.psd")
59
+
60
+
56
61
  @pytest.fixture
57
62
  def psd_ui(sample_ui_psd):
58
63
  p = psdparse.PSDFile()
@@ -60,6 +65,13 @@ def psd_ui(sample_ui_psd):
60
65
  return p
61
66
 
62
67
 
68
+ @pytest.fixture
69
+ def psd_text(sample_text_psd):
70
+ p = psdparse.PSDFile()
71
+ assert p.load(str(sample_text_psd))
72
+ return p
73
+
74
+
63
75
  @pytest.fixture
64
76
  def psd_large(sample_large_psd):
65
77
  p = psdparse.PSDFile()
@@ -0,0 +1,111 @@
1
+ """Text-layer extraction ('TySh' / EngineData).
2
+
3
+ Validated against tests/data/fontsample.psd, a purpose-built PSD with four
4
+ text layers exercising line breaks, emoji (surrogate pairs), vertical text,
5
+ and mid-string font/size/color changes.
6
+ """
7
+ import psdparse
8
+
9
+
10
+ def _text_layers(p):
11
+ return [l for l in p.layers if l.text is not None]
12
+
13
+
14
+ def test_non_text_layer_returns_none(psd_text):
15
+ # The '背景' raster layer must not be reported as a text layer.
16
+ bg = psd_text.layers[0]
17
+ assert bg.text is None
18
+ assert bg.layer_type != psdparse.LayerType.TEXT
19
+
20
+
21
+ def test_text_layers_detected(psd_text):
22
+ txt = _text_layers(psd_text)
23
+ # fontsample.psd has 4 text layers.
24
+ assert len(txt) == 4
25
+ for l in txt:
26
+ assert l.layer_type == psdparse.LayerType.TEXT
27
+
28
+
29
+ def test_text_dict_shape(psd_text):
30
+ t = _text_layers(psd_text)[0].text
31
+ assert set(t) >= {"text", "orientation", "justification", "transform", "runs"}
32
+ assert isinstance(t["text"], str)
33
+ assert len(t["transform"]) == 6
34
+ assert t["orientation"] in ("horizontal", "vertical")
35
+
36
+
37
+ def test_multiline_content(psd_text):
38
+ # First text layer: three lines separated by CR.
39
+ layer = _text_layers(psd_text)[0]
40
+ assert layer.text["text"] == "普通のテキスト\r二行目\r三行目\r\r"
41
+
42
+
43
+ def test_vertical_orientation(psd_text):
44
+ # The '縦書き' layer is authored as vertical (tate-gaki) text.
45
+ layer = next(l for l in _text_layers(psd_text)
46
+ if "縦書き" in l.text["text"])
47
+ assert layer.text["orientation"] == "vertical"
48
+ # ...and the horizontal ones report horizontal.
49
+ horiz = [l for l in _text_layers(psd_text)
50
+ if l.text["orientation"] == "horizontal"]
51
+ assert len(horiz) == 3
52
+
53
+
54
+ def test_emoji_surrogate_pairs(psd_text):
55
+ layer = next(l for l in _text_layers(psd_text)
56
+ if l.text["text"].startswith("🍥"))
57
+ assert layer.text["text"] == "🍥🍑🍒\r"
58
+
59
+
60
+ def test_run_lengths_sum_to_text_length(psd_text):
61
+ # Run lengths are counted in UTF-16 code units (astral chars such as emoji
62
+ # count as 2), matching Photoshop's EngineData RunLengthArray.
63
+ for l in _text_layers(psd_text):
64
+ t = l.text
65
+ assert t["runs"], "expected at least one style run"
66
+ total = sum(r["length"] for r in t["runs"])
67
+ utf16_units = len(t["text"].encode("utf-16-le")) // 2
68
+ assert total == utf16_units
69
+
70
+
71
+ def test_font_names_resolved(psd_text):
72
+ fonts = set()
73
+ for l in _text_layers(psd_text):
74
+ for r in l.text["runs"]:
75
+ fonts.add(r["font"])
76
+ # FontSet index -> name resolution should surface the real families used.
77
+ assert "NotoSansJP-Thin" in fonts
78
+ assert "SourceHanSansJP-Normal" in fonts
79
+
80
+
81
+ def test_mid_string_style_changes(psd_text):
82
+ # The layer whose name advertises font/size/color changes mid-string.
83
+ layer = next(l for l in _text_layers(psd_text)
84
+ if l.text["text"].startswith("フォントを途中でかえる"))
85
+ runs = layer.text["runs"]
86
+ # more than one distinct font, size, and color across runs
87
+ assert len({r["font"] for r in runs}) >= 2
88
+ assert len({r["size_px"] for r in runs}) >= 2
89
+ # a blue run (B channel dominant) exists among otherwise-red text
90
+ assert any(r["color"] and r["color"][2] > 0.5 for r in runs)
91
+ assert any(r["color"] and r["color"][0] > 0.5 for r in runs)
92
+
93
+
94
+ def test_run_has_tracking_kerning(psd_text):
95
+ for l in _text_layers(psd_text):
96
+ for r in l.text["runs"]:
97
+ assert isinstance(r["tracking"], int)
98
+ assert isinstance(r["kerning"], int)
99
+ assert isinstance(r["auto_kerning"], bool)
100
+ # fontsample.psd was authored with -100 tracking across its runs.
101
+ all_runs = [r for l in _text_layers(psd_text) for r in l.text["runs"]]
102
+ assert any(r["tracking"] == -100 for r in all_runs)
103
+
104
+
105
+ def test_color_is_rgba_alpha_last(psd_text):
106
+ for l in _text_layers(psd_text):
107
+ for r in l.text["runs"]:
108
+ if r["color"] is not None:
109
+ assert len(r["color"]) == 4
110
+ # opaque text -> alpha (last component) == 1.0
111
+ assert r["color"][3] == 1.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes