psdparse 0.1.1__tar.gz → 0.2.1__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.1 → psdparse-0.2.1}/PKG-INFO +1 -1
  2. {psdparse-0.1.1 → psdparse-0.2.1}/docs/PYTHON_API.md +47 -0
  3. {psdparse-0.1.1 → psdparse-0.2.1}/docs/ROADMAP.md +7 -0
  4. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/CMakeLists.txt +1 -0
  5. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/bmp.cpp +1 -0
  6. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psddata.h +40 -0
  7. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psddesc.cpp +16 -5
  8. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psddesc.h +8 -4
  9. psdparse-0.2.1/psdparse/psdengine.cpp +305 -0
  10. psdparse-0.2.1/psdparse/psdengine.h +34 -0
  11. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdimage.cpp +1 -0
  12. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdlayer.cpp +46 -0
  13. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdlayer.h +1 -0
  14. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdparse.cpp +33 -3
  15. {psdparse-0.1.1 → psdparse-0.2.1}/pyproject.toml +1 -1
  16. {psdparse-0.1.1 → psdparse-0.2.1}/python/psdparse_module.cpp +36 -1
  17. {psdparse-0.1.1 → psdparse-0.2.1}/tests/conftest.py +12 -0
  18. psdparse-0.2.1/tests/test_text.py +111 -0
  19. {psdparse-0.1.1 → psdparse-0.2.1}/.gitignore +0 -0
  20. {psdparse-0.1.1 → psdparse-0.2.1}/CMakeLists.txt +0 -0
  21. {psdparse-0.1.1 → psdparse-0.2.1}/CMakePresets.json +0 -0
  22. {psdparse-0.1.1 → psdparse-0.2.1}/LICENSE +0 -0
  23. {psdparse-0.1.1 → psdparse-0.2.1}/Makefile +0 -0
  24. {psdparse-0.1.1 → psdparse-0.2.1}/README.md +0 -0
  25. {psdparse-0.1.1 → psdparse-0.2.1}/docs/ARCHITECTURE.md +0 -0
  26. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psd_cli.cpp +0 -0
  27. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdbase.h +0 -0
  28. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdfile.cpp +0 -0
  29. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdfile.h +0 -0
  30. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdparse.h +0 -0
  31. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdresource.cpp +0 -0
  32. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdresource.h +0 -0
  33. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdwrite.cpp +0 -0
  34. {psdparse-0.1.1 → psdparse-0.2.1}/psdparse/psdwrite.h +0 -0
  35. {psdparse-0.1.1 → psdparse-0.2.1}/python/CMakeLists.txt +0 -0
  36. {psdparse-0.1.1 → psdparse-0.2.1}/tests/test_header.py +0 -0
  37. {psdparse-0.1.1 → psdparse-0.2.1}/tests/test_images.py +0 -0
  38. {psdparse-0.1.1 → psdparse-0.2.1}/tests/test_layers.py +0 -0
  39. {psdparse-0.1.1 → psdparse-0.2.1}/tests/test_save.py +0 -0
  40. {psdparse-0.1.1 → psdparse-0.2.1}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.1.1
3
+ Version: 0.2.1
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>
@@ -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
  // レイヤタイプ
@@ -156,6 +157,8 @@ namespace psd {
156
157
  int width;
157
158
  int depth;
158
159
  int mode;
160
+ double hres = 72.0; // 水平解像度 dpi (image resource 1005)。 既定 72。
161
+ double vres = 72.0; // 垂直解像度 dpi。
159
162
  };
160
163
 
161
164
  // RGBAカラー
@@ -391,6 +394,40 @@ namespace psd {
391
394
  bool isMaskChannel() const { return (id == -3 || id == -2); }
392
395
  };
393
396
 
397
+ // テキストレイヤの文字スタイルラン。EngineData の StyleRun/RunArray の
398
+ // 1 エントリに対応し、length は本文の何文字分に適用されるか (RunLengthArray)。
399
+ struct TextStyleRun {
400
+ int length; // 適用文字数 (UTF-16 コードユニット)
401
+ u16str font; // 解決済みフォント名 (FontSet を index で引いたもの)
402
+ float fontSize; // pt
403
+ float color[4]; // RGBA 0..1 (EngineData の ARGB を並べ替えて格納)
404
+ bool hasColor; // FillColor が指定されていたか
405
+ int tracking; // トラッキング (字送り, 1/1000 em)
406
+ int kerning; // 手動カーニング
407
+ bool autoKerning; // 自動カーニング (メトリクス/オプティカル) 有効
408
+ bool sizeInherited; // FontSize を既定 StyleSheet から継承したか。
409
+ // 継承分は nominal pt なので dpi/72 で px 化する
410
+ // (明示 run の FontSize は既に解決済み px)。内部用。
411
+
412
+ TextStyleRun()
413
+ : length(0), fontSize(0.0f), color{0,0,0,1}, hasColor(false),
414
+ tracking(0), kerning(0), autoKerning(false), sizeInherited(false) {}
415
+ };
416
+
417
+ // テキストレイヤ情報 (追加レイヤ情報 'TySh' 由来)。
418
+ struct TextLayerData {
419
+ bool present; // テキストレイヤとしてパースできたか
420
+ u16str text; // 本文全体 (改行は \r)
421
+ double transform[6]; // アフィン変換 xx,xy,yx,yy,tx,ty
422
+ std::string orientation; // "horizontal" / "vertical"
423
+ int justification; // 段落の行揃え 0=左 1=右 2=中央 (先頭段落)
424
+ std::vector<TextStyleRun> runs;
425
+
426
+ TextLayerData()
427
+ : present(false), transform{1,0,0,1,0,0},
428
+ justification(0) {}
429
+ };
430
+
394
431
  // レイヤ情報
395
432
  class Data;
396
433
  struct LayerInfo {
@@ -426,6 +463,9 @@ namespace psd {
426
463
  std::map<int, LayerCompInfo> layerComps;
427
464
  Descriptor layerCompDesc; // ディスクリプタ形式で全メタデータを格納
428
465
 
466
+ // テキストレイヤ情報 ('TySh' 由来)。layerType==TEXT のとき present=true。
467
+ TextLayerData textData;
468
+
429
469
  // 親フォルダレイヤ
430
470
  LayerInfo *parent;
431
471
 
@@ -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,305 @@
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
+ // 既定 StyleSheet (ResourceDict/StyleSheetSet[0]/StyleSheetData)。
208
+ // box text の基準 run は FontSize/Font 等の StyleSheetData を持たず、 この既定を
209
+ // 継承する。 これを読まないと FontSize=0 になる (実表示は既定サイズ)。
210
+ float defSize = 0.0f; int defFontIdx = -1; int defTracking = 0;
211
+ float defColor[4] = {0, 0, 0, 1}; bool defHasColor = false;
212
+ {
213
+ Node *rd2 = dget(root, "ResourceDict");
214
+ Node *sss = dget(rd2, "StyleSheetSet");
215
+ Node *dssd = 0;
216
+ if (sss && sss->kind == Node::ARRAY && !sss->arr.empty()) {
217
+ dssd = dget(sss->arr[0], "StyleSheetData");
218
+ if (!dssd) dssd = dget(dget(sss->arr[0], "StyleSheet"), "StyleSheetData");
219
+ }
220
+ if (dssd) {
221
+ Node *f = dget(dssd, "Font");
222
+ if (f && f->kind == Node::NUMBER) defFontIdx = (int)f->num;
223
+ Node *fs = dget(dssd, "FontSize");
224
+ if (fs && fs->kind == Node::NUMBER) defSize = (float)fs->num;
225
+ Node *tk = dget(dssd, "Tracking");
226
+ if (tk && tk->kind == Node::NUMBER) defTracking = (int)tk->num;
227
+ Node *vals = dget(dget(dssd, "FillColor"), "Values");
228
+ if (vals && vals->kind == Node::ARRAY && vals->arr.size() >= 4) {
229
+ defColor[3] = (float)vals->arr[0]->num; // A
230
+ defColor[0] = (float)vals->arr[1]->num; // R
231
+ defColor[1] = (float)vals->arr[2]->num; // G
232
+ defColor[2] = (float)vals->arr[3]->num; // B
233
+ defHasColor = true;
234
+ }
235
+ }
236
+ }
237
+
238
+ // ラン単位スタイル (EngineDict/StyleRun)
239
+ if (engine) {
240
+ Node *styleRun = dget(engine, "StyleRun");
241
+ Node *runArray = dget(styleRun, "RunArray");
242
+ Node *runLen = dget(styleRun, "RunLengthArray");
243
+ if (runArray && runArray->kind == Node::ARRAY) {
244
+ for (size_t i = 0; i < runArray->arr.size(); i++) {
245
+ Node *ssd = dget(dget(runArray->arr[i], "StyleSheet"), "StyleSheetData");
246
+ TextStyleRun r;
247
+ // 既定を先に適用 (未指定フィールドの継承)。
248
+ r.fontSize = defSize;
249
+ r.sizeInherited = true; // 既定継承 (下で run が上書きすれば false)
250
+ r.tracking = defTracking;
251
+ if (defFontIdx >= 0 && defFontIdx < (int)fonts.size()) r.font = fonts[defFontIdx];
252
+ if (defHasColor) {
253
+ r.color[0] = defColor[0]; r.color[1] = defColor[1];
254
+ r.color[2] = defColor[2]; r.color[3] = defColor[3];
255
+ r.hasColor = true;
256
+ }
257
+ if (runLen && runLen->kind == Node::ARRAY && i < runLen->arr.size() &&
258
+ runLen->arr[i]->kind == Node::NUMBER) {
259
+ r.length = (int)runLen->arr[i]->num;
260
+ }
261
+ if (ssd) {
262
+ Node *fidx = dget(ssd, "Font");
263
+ if (fidx && fidx->kind == Node::NUMBER) {
264
+ int idx = (int)fidx->num;
265
+ if (idx >= 0 && idx < (int)fonts.size()) r.font = fonts[idx];
266
+ }
267
+ Node *fsz = dget(ssd, "FontSize");
268
+ if (fsz && fsz->kind == Node::NUMBER) {
269
+ r.fontSize = (float)fsz->num; // 明示 = 解決済み px
270
+ r.sizeInherited = false;
271
+ }
272
+ Node *trk = dget(ssd, "Tracking");
273
+ if (trk && trk->kind == Node::NUMBER) r.tracking = (int)trk->num;
274
+ Node *krn = dget(ssd, "Kerning");
275
+ if (krn && krn->kind == Node::NUMBER) r.kerning = (int)krn->num;
276
+ Node *akn = dget(ssd, "AutoKerning");
277
+ if (akn && akn->kind == Node::BOOL) r.autoKerning = akn->bl;
278
+ Node *vals = dget(dget(ssd, "FillColor"), "Values");
279
+ if (vals && vals->kind == Node::ARRAY && vals->arr.size() >= 4) {
280
+ // EngineData の FillColor/Values は [A R G B]。RGBA へ並べ替える。
281
+ r.color[3] = (float)vals->arr[0]->num; // A
282
+ r.color[0] = (float)vals->arr[1]->num; // R
283
+ r.color[1] = (float)vals->arr[2]->num; // G
284
+ r.color[2] = (float)vals->arr[3]->num; // B
285
+ r.hasColor = true;
286
+ }
287
+ }
288
+ out.runs.push_back(r);
289
+ }
290
+ }
291
+
292
+ // 行揃え (先頭段落の Justification)
293
+ Node *paraArr = dget(dget(engine, "ParagraphRun"), "RunArray");
294
+ if (paraArr && paraArr->kind == Node::ARRAY && !paraArr->arr.empty()) {
295
+ Node *props = dget(dget(paraArr->arr[0], "ParagraphSheet"), "Properties");
296
+ Node *just = dget(props, "Justification");
297
+ if (just && just->kind == Node::NUMBER) out.justification = (int)just->num;
298
+ }
299
+ }
300
+
301
+ delete root;
302
+ return haveText;
303
+ }
304
+
305
+ } // 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__
@@ -35,11 +35,22 @@ Data::processParsed()
35
35
  success = loadResourceLayerComps(*this, res);
36
36
  break;
37
37
 
38
+ case 1005: { // 0x03ED -- ResolutionInfo。 hRes/vRes は Fixed 16.16 の dpi。
39
+ if (res.data) {
40
+ int32_t h = res.data->getInt32(); // hRes (Fixed 16.16)
41
+ (void)res.data->getInt16(); // hResUnit
42
+ (void)res.data->getInt16(); // widthUnit
43
+ int32_t v = res.data->getInt32(); // vRes (Fixed 16.16)
44
+ if (h > 0) header.hres = (double)(uint32_t)h / 65536.0;
45
+ if (v > 0) header.vres = (double)(uint32_t)v / 65536.0;
46
+ }
47
+ break;
48
+ }
49
+
38
50
  default: // 未実装はそのままスルー
39
51
  case 1000: // 0x03E8 -- (Obsolete--Photoshop 2.0 only ) Contains five 2-byte values: number of channels, rows, columns, depth, and mode
40
52
  case 1001: // 0x03E9 -- Macintosh print manager print info record
41
53
  case 1003: // 0x03EB -- (Obsolete--Photoshop 2.0 only ) Indexed color table
42
- case 1005: // 0x03ED -- ResolutionInfo structure. See Appendix A in Photoshop API Guide.pdf.
43
54
  case 1006: // 0x03EE -- Names of the alpha channels as a series of Pascal strings.
44
55
  case 1007: // 0x03EF -- (Obsolete) See ID 1077DisplayInfo structure. See Appendix A in Photoshop API Guide.pdf.
45
56
  case 1008: // 0x03F0 -- The caption as a Pascal string.
@@ -224,7 +235,10 @@ Data::processParsed()
224
235
  case 'iOpa': // fill opacity
225
236
  success = loadLayerFillOpacity(layer, additional);
226
237
  break;
227
-
238
+ case 'TySh': // Type tool object setting (Photoshop 6.0) — テキストレイヤ
239
+ success = loadLayerTypeTool(layer, additional);
240
+ break;
241
+
228
242
  // --- 未対応 ---
229
243
  case 'lrFX': // Effects Layer (Photoshop 5.0)
230
244
  case 'tySh': // Type Tool Info (Photoshop 5.0 and 5.5 only)
@@ -242,7 +256,6 @@ Data::processParsed()
242
256
  case 'brst': // Channel blending restrictions setting (Photoshop 6.0)
243
257
  case 'vmsk': // Vector mask setting (Photoshop 6.0)
244
258
  case 'vsms':
245
- case 'TySh': // Type tool object setting (Photoshop 6.0)
246
259
  case 'ffxi': // Foreign effect ID (Photoshop 6.0)
247
260
  case 'lnsr': // Layer name source setting (Photoshop 6.0)
248
261
  case 'shpa': // Pattern data (Photoshop 6.0)
@@ -293,6 +306,23 @@ Data::processParsed()
293
306
  }
294
307
  }
295
308
 
309
+ // テキスト run の FontSize を px に正規化する (レイヤ解析後・解像度確定後)。 明示
310
+ // run の FontSize は既に解決済み px だが、 既定 StyleSheet から継承した分は nominal
311
+ // pt なので dpi/72 を掛ける (300dpi なら 12pt → 50px)。
312
+ {
313
+ double sc = header.hres / 72.0;
314
+ if (sc != 1.0) {
315
+ for (uint32_t li = 0; li < layerList.size(); li++) {
316
+ TextLayerData &td = layerList[li].textData;
317
+ if (!td.present) continue;
318
+ for (size_t ri = 0; ri < td.runs.size(); ri++) {
319
+ if (td.runs[ri].sizeInherited)
320
+ td.runs[ri].fontSize *= (float)sc;
321
+ }
322
+ }
323
+ }
324
+ }
325
+
296
326
  return success;
297
327
  }
298
328
 
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "psdparse"
7
- version = "0.1.1"
7
+ version = "0.2.1"
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; // px (文書解像度で換算済み; 継承分は pt×dpi/72)
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())
@@ -104,7 +134,9 @@ PYBIND11_MODULE(psdparse, m) {
104
134
  .def_readonly("height", &psd::Header::height)
105
135
  .def_readonly("width", &psd::Header::width)
106
136
  .def_readonly("depth", &psd::Header::depth)
107
- .def_readonly("mode", &psd::Header::mode);
137
+ .def_readonly("mode", &psd::Header::mode)
138
+ .def_readonly("hres", &psd::Header::hres) // 水平解像度 dpi (既定 72)
139
+ .def_readonly("vres", &psd::Header::vres); // 垂直解像度 dpi
108
140
 
109
141
  py::class_<psd::ChannelInfo>(m, "ChannelInfo")
110
142
  .def_readonly("id", &psd::ChannelInfo::id)
@@ -132,6 +164,9 @@ PYBIND11_MODULE(psdparse, m) {
132
164
  .def_property_readonly("name_unicode", [](const psd::LayerInfo &l) {
133
165
  return u16ToStr(l.layerNameUnicode);
134
166
  })
167
+ .def_property_readonly("text", &layerText,
168
+ "Text-layer content/style as a dict (keys: text, orientation, "
169
+ "justification, transform, runs[]), or None for non-text layers.")
135
170
  .def_property_readonly("visible", [](const psd::LayerInfo &l){ return l.isVisible(); })
136
171
  .def_property_readonly("transparency_protected", [](const psd::LayerInfo &l){ return l.isTransparencyProtected(); })
137
172
  .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
File without changes