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.
- {psdparse-0.1.0 → psdparse-0.2.0}/PKG-INFO +3 -14
- {psdparse-0.1.0 → psdparse-0.2.0}/README.md +2 -13
- {psdparse-0.1.0 → psdparse-0.2.0}/docs/PYTHON_API.md +47 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/docs/ROADMAP.md +7 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/CMakeLists.txt +1 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/bmp.cpp +1 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psddata.h +35 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psddesc.cpp +16 -5
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psddesc.h +8 -4
- psdparse-0.2.0/psdparse/psdengine.cpp +261 -0
- psdparse-0.2.0/psdparse/psdengine.h +34 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdimage.cpp +1 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdlayer.cpp +46 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdlayer.h +1 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdparse.cpp +4 -2
- {psdparse-0.1.0 → psdparse-0.2.0}/pyproject.toml +1 -1
- {psdparse-0.1.0 → psdparse-0.2.0}/python/psdparse_module.cpp +33 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/tests/conftest.py +12 -0
- psdparse-0.2.0/tests/test_text.py +111 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/.gitignore +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/CMakeLists.txt +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/CMakePresets.json +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/LICENSE +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/Makefile +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdbase.h +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdfile.cpp +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdfile.h +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdparse.h +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdresource.h +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdwrite.cpp +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/psdparse/psdwrite.h +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/python/CMakeLists.txt +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_header.py +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_images.py +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_layers.py +0 -0
- {psdparse-0.1.0 → psdparse-0.2.0}/tests/test_save.py +0 -0
- {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.
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
@@ -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(
|
|
367
|
-
virtual bool load(IteratorBase *data)
|
|
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__
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|