psdparse 0.2.1__tar.gz → 0.3.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.2.1 → psdparse-0.3.0}/PKG-INFO +1 -1
- {psdparse-0.2.1 → psdparse-0.3.0}/docs/PYTHON_API.md +76 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/docs/ROADMAP.md +2 -1
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psddata.h +15 -1
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdengine.cpp +16 -5
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdparse.cpp +8 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/pyproject.toml +1 -1
- {psdparse-0.2.1 → psdparse-0.3.0}/python/psdparse_module.cpp +167 -1
- {psdparse-0.2.1 → psdparse-0.3.0}/tests/conftest.py +26 -0
- psdparse-0.3.0/tests/test_metadata.py +104 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/.gitignore +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/CMakeLists.txt +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/CMakePresets.json +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/LICENSE +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/Makefile +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/README.md +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/CMakeLists.txt +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/bmp.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdbase.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psddesc.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psddesc.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdengine.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdfile.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdfile.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdimage.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdlayer.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdlayer.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdparse.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdresource.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdwrite.cpp +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/psdparse/psdwrite.h +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/python/CMakeLists.txt +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/tests/test_header.py +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/tests/test_images.py +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/tests/test_layers.py +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/tests/test_save.py +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/tests/test_text.py +0 -0
- {psdparse-0.2.1 → psdparse-0.3.0}/tools/psd_export.py +0 -0
|
@@ -101,7 +101,10 @@ 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
|
+
| `parent_index` | `int` | index into `PSDFile.layers` of the enclosing folder, or `-1` for top level — see [Layer hierarchy](#layer-hierarchy) |
|
|
104
105
|
| `text` | `dict` \| `None` | text-layer content & style (`None` for non-text layers) — see below |
|
|
106
|
+
| `mask` | `dict` \| `None` | layer mask geometry & flags (`None` when the layer has no mask) — see below |
|
|
107
|
+
| `blending_ranges` | `dict` \| `None` | "Blend If" ranges (`None` when absent) — see below |
|
|
105
108
|
| `visible` | `bool` | flag bit 1 inverted |
|
|
106
109
|
| `transparency_protected` | `bool` | flag bit 0 |
|
|
107
110
|
| `obsolete` | `bool` | flag bit 2 |
|
|
@@ -155,6 +158,79 @@ for layer in p.layers:
|
|
|
155
158
|
print(t["text"], "→", {r["font"] for r in t["runs"]})
|
|
156
159
|
```
|
|
157
160
|
|
|
161
|
+
### `layer.mask` — layer mask geometry & flags
|
|
162
|
+
|
|
163
|
+
`None` for layers without a mask. When present:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
{
|
|
167
|
+
"top": 8, "left": 10, "bottom": 40, "right": 50, # mask bbox on canvas
|
|
168
|
+
"width": 40, "height": 32,
|
|
169
|
+
"default_color": 0, # 0..255, area outside the stored mask rect
|
|
170
|
+
"flags": 0, # raw flag byte
|
|
171
|
+
"relative": False, # bit0: position relative to layer
|
|
172
|
+
"disabled": False, # bit1: mask disabled
|
|
173
|
+
"inverted": False, # bit2: invert (obsolete)
|
|
174
|
+
"from_render": False, # bit3: mask from rendering other data
|
|
175
|
+
"has_parameters": False, # bit4: density/feather present (not decoded yet)
|
|
176
|
+
"real": None, # or a nested dict (below) for a real/user mask
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
When the record carries a *real* (user + vector combined) mask, `real` is a dict
|
|
181
|
+
with `flags`, `background`, and the enclosing `top/left/bottom/right`. The mask
|
|
182
|
+
**pixels** are unchanged from before — fetch them with
|
|
183
|
+
`p.layer_image(i, "mask")`.
|
|
184
|
+
|
|
185
|
+
### `layer.blending_ranges` — "Blend If" ranges
|
|
186
|
+
|
|
187
|
+
`None` when absent. `gray` is the composite range; `channels` has one entry per
|
|
188
|
+
channel. Each value is the raw 32-bit packed range (two 16-bit black/white
|
|
189
|
+
sub-ranges — split yourself if you need the individual sliders):
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
{
|
|
193
|
+
"gray": (65535, 65535), # (source, dest)
|
|
194
|
+
"channels": [(0, 65535), (0, 65535), ...],
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Layer hierarchy
|
|
199
|
+
|
|
200
|
+
Layers are a **flat list in file order**; folder structure is recovered from
|
|
201
|
+
`layer.parent_index` (`-1` = top level, otherwise the index of the enclosing
|
|
202
|
+
`FOLDER` layer). To build a tree:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
children = {i: [] for i in range(len(p.layers))}
|
|
206
|
+
roots = []
|
|
207
|
+
for i, l in enumerate(p.layers):
|
|
208
|
+
(roots if l.parent_index == -1 else children[l.parent_index]).append(i)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`FOLDER` marks a group's start and the matching `HIDDEN` layer marks its end
|
|
212
|
+
(these are Photoshop's `lsct` section dividers).
|
|
213
|
+
|
|
214
|
+
## Document resources
|
|
215
|
+
|
|
216
|
+
Read-only accessors on `PSDFile` for whole-document metadata. Each returns
|
|
217
|
+
`None` (or an empty list) when the PSD lacks that resource.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
p.guides # dict|None : {"horizontal_grid", "vertical_grid", "guides":[{"location","direction"}]}
|
|
221
|
+
p.slices # dict|None : {"group_name", "bounding":{...}, "slices":[{...}]}
|
|
222
|
+
p.layer_comps # list[dict]: [{"id","name","comment","record_visibility","record_position","record_appearance"}]
|
|
223
|
+
p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparency_index"} for indexed-color PSDs
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- **`guides`** — grid spacing (in 1/32 px) and each guide's `location` (1/32 px
|
|
227
|
+
from origin) and `direction` (`"vertical"` / `"horizontal"`).
|
|
228
|
+
- **`slices`** — Photoshop slices (v6). Each slice has `id`, `name`, bbox
|
|
229
|
+
(`left/top/right/bottom`), `url`, `target`, `message`, `alt_tag`, `cell_text`,
|
|
230
|
+
alignment, and an `(r, g, b, a)` `color` tuple.
|
|
231
|
+
- **`color_table`** — only present for `COLOR_MODE_INDEXED` PSDs; `colors` is the
|
|
232
|
+
palette and `transparency_index` is `-1` when there is no transparent entry.
|
|
233
|
+
|
|
158
234
|
## Enums
|
|
159
235
|
|
|
160
236
|
```python
|
|
@@ -78,8 +78,9 @@ This is mostly a constructor that fills `Data` with a minimal-but-valid skeleton
|
|
|
78
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
79
|
- **Area (paragraph) vs point text / text box bounds** and **text-on-path** — need samples.
|
|
80
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.
|
|
81
|
+
- ✅ **Tier-1 reference metadata exposed to Python.** *Done 2026-08-02 (0.3.0).* Data the C++ core already parsed but Python couldn't reach is now bound: `layer.parent_index` (folder hierarchy), `layer.mask` (bbox / flags / real-mask dict), `layer.blending_ranges`, and document-level `PSDFile.guides` / `.slices` / `.layer_comps` / `.color_table`. All dict-shaped, matching the existing `layer.text` style. Validated against `config.psd` / `system.psd` (hierarchy, blend ranges, guides, slices) and a psd-tools-synthesized `masktest.psd` (mask bbox/flags cross-checked) — see `tests/test_metadata.py`. **Next (Tier 2, highest leverage):** a generic `Descriptor → dict` bridge that routes the currently-skipped `lfx2` (layer effects), `SoCo`/`GdFl`/`PtFl` (fill layers) and `SoLd` (smart-object transform) raw bytes through the existing complete descriptor parser — unlocks effects/fill/smart-object metadata cheaply without per-feature decoders.
|
|
81
82
|
- 16-bit (`Lr16`) and 32-bit-float (`Lr32`) layer data: currently captured in `layerAndMaskTrailing` for round-trip but not exposed as decoded pixels.
|
|
82
|
-
- Layer mask:
|
|
83
|
+
- Layer mask: density / feather decoding (the `has_parameters` flag is exposed but the `MaskParameters` values are not read yet), plus re-emission for masks > 20 bytes.
|
|
83
84
|
- 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.
|
|
84
85
|
- CMYK / Lab / Indexed color extraction in `getLayerImage` — currently optimized for RGB.
|
|
85
86
|
- Linux / macOS testing. mmap path uses POSIX `mmap` but hasn't been built / tested there.
|
|
@@ -287,6 +287,8 @@ namespace psd {
|
|
|
287
287
|
};
|
|
288
288
|
|
|
289
289
|
struct LayerMask {
|
|
290
|
+
bool present = false; // マスクブロック (size>0) が存在したか
|
|
291
|
+
bool hasReal = false; // real/user mask (size>20) を含むか
|
|
290
292
|
int width;
|
|
291
293
|
int height;
|
|
292
294
|
int top;
|
|
@@ -309,6 +311,7 @@ namespace psd {
|
|
|
309
311
|
};
|
|
310
312
|
|
|
311
313
|
struct LayerBlendingRange {
|
|
314
|
+
bool present = false; // blending range ブロック (size>0) が存在したか
|
|
312
315
|
int grayBlendSource;
|
|
313
316
|
int grayBlendDest;
|
|
314
317
|
std::vector<LayerBlendingChannel> channels;
|
|
@@ -414,14 +417,23 @@ namespace psd {
|
|
|
414
417
|
tracking(0), kerning(0), autoKerning(false), sizeInherited(false) {}
|
|
415
418
|
};
|
|
416
419
|
|
|
420
|
+
// 段落単位の情報 (EngineDict/ParagraphRun)。 段落は本文中の改行 (\r) 区切り。
|
|
421
|
+
struct TextParagraph {
|
|
422
|
+
int length; // 段落の文字数 (UTF-16 コードユニット, RunLengthArray)
|
|
423
|
+
int justification; // 行揃え 0=左 1=右 2=中央 (3..=両端揃え系)
|
|
424
|
+
|
|
425
|
+
TextParagraph() : length(0), justification(0) {}
|
|
426
|
+
};
|
|
427
|
+
|
|
417
428
|
// テキストレイヤ情報 (追加レイヤ情報 'TySh' 由来)。
|
|
418
429
|
struct TextLayerData {
|
|
419
430
|
bool present; // テキストレイヤとしてパースできたか
|
|
420
431
|
u16str text; // 本文全体 (改行は \r)
|
|
421
432
|
double transform[6]; // アフィン変換 xx,xy,yx,yy,tx,ty
|
|
422
433
|
std::string orientation; // "horizontal" / "vertical"
|
|
423
|
-
int justification; // 段落の行揃え 0=左 1=右 2=中央 (
|
|
434
|
+
int justification; // 段落の行揃え 0=左 1=右 2=中央 (先頭段落; 後方互換)
|
|
424
435
|
std::vector<TextStyleRun> runs;
|
|
436
|
+
std::vector<TextParagraph> paragraphs; // 段落別 (行揃えが段落で変わる box text 用)
|
|
425
437
|
|
|
426
438
|
TextLayerData()
|
|
427
439
|
: present(false), transform{1,0,0,1,0,0},
|
|
@@ -468,6 +480,8 @@ namespace psd {
|
|
|
468
480
|
|
|
469
481
|
// 親フォルダレイヤ
|
|
470
482
|
LayerInfo *parent;
|
|
483
|
+
// 親フォルダの layerList インデックス (-1 = トップレベル)。processParsed で設定。
|
|
484
|
+
int parentIndex = -1;
|
|
471
485
|
|
|
472
486
|
bool isTransparencyProtected() const { return (flag & (1 << 0)) != 0; }
|
|
473
487
|
bool isVisible() const { return (flag & (1 << 1)) == 0; }
|
|
@@ -289,12 +289,23 @@ namespace {
|
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
//
|
|
293
|
-
|
|
292
|
+
// 段落別の行揃え (ParagraphRun)。 out.justification は先頭段落 (後方互換)、
|
|
293
|
+
// out.paragraphs に全段落分 (length + justification) を格納する。
|
|
294
|
+
Node *paraRun = dget(engine, "ParagraphRun");
|
|
295
|
+
Node *paraArr = dget(paraRun, "RunArray");
|
|
296
|
+
Node *paraLen = dget(paraRun, "RunLengthArray");
|
|
294
297
|
if (paraArr && paraArr->kind == Node::ARRAY && !paraArr->arr.empty()) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
for (size_t i = 0; i < paraArr->arr.size(); i++) {
|
|
299
|
+
TextParagraph p;
|
|
300
|
+
if (paraLen && paraLen->kind == Node::ARRAY && i < paraLen->arr.size() &&
|
|
301
|
+
paraLen->arr[i]->kind == Node::NUMBER)
|
|
302
|
+
p.length = (int)paraLen->arr[i]->num;
|
|
303
|
+
Node *props = dget(dget(paraArr->arr[i], "ParagraphSheet"), "Properties");
|
|
304
|
+
Node *just = dget(props, "Justification");
|
|
305
|
+
if (just && just->kind == Node::NUMBER) p.justification = (int)just->num;
|
|
306
|
+
out.paragraphs.push_back(p);
|
|
307
|
+
}
|
|
308
|
+
out.justification = out.paragraphs[0].justification; // 後方互換
|
|
298
309
|
}
|
|
299
310
|
}
|
|
300
311
|
|
|
@@ -290,16 +290,21 @@ Data::processParsed()
|
|
|
290
290
|
|
|
291
291
|
// グループ情報をリンク
|
|
292
292
|
std::stack<LayerInfo*> parent;
|
|
293
|
+
std::stack<int> parentIdx;
|
|
293
294
|
parent.push(0);
|
|
295
|
+
parentIdx.push(-1);
|
|
294
296
|
for (int i = (int)layerList.size() - 1; i >= 0; i--) {
|
|
295
297
|
LayerInfo *layer = &layerList[i];
|
|
296
298
|
layer->parent = parent.top();
|
|
299
|
+
layer->parentIndex = parentIdx.top();
|
|
297
300
|
switch (layer->layerType) {
|
|
298
301
|
case LAYER_TYPE_FOLDER:
|
|
299
302
|
parent.push(layer);
|
|
303
|
+
parentIdx.push(i);
|
|
300
304
|
break;
|
|
301
305
|
case LAYER_TYPE_HIDDEN:
|
|
302
306
|
parent.pop();
|
|
307
|
+
if (parentIdx.size() > 1) parentIdx.pop();
|
|
303
308
|
break;
|
|
304
309
|
default:
|
|
305
310
|
break;
|
|
@@ -449,6 +454,7 @@ void parseLayerMask(IteratorBase &r, LayerMask &m, int size) {
|
|
|
449
454
|
// is realFlags; the original grammar consumed a byte before realFlags too.
|
|
450
455
|
(void)r.getCh();
|
|
451
456
|
if (size > 20) {
|
|
457
|
+
m.hasReal = true;
|
|
452
458
|
m.realFlags = r.getCh();
|
|
453
459
|
m.realUserMaskBackground = r.getCh();
|
|
454
460
|
m.enclosingTop = r.getInt32(true);
|
|
@@ -458,9 +464,11 @@ void parseLayerMask(IteratorBase &r, LayerMask &m, int size) {
|
|
|
458
464
|
}
|
|
459
465
|
m.width = m.right - m.left;
|
|
460
466
|
m.height = m.bottom - m.top;
|
|
467
|
+
m.present = true;
|
|
461
468
|
}
|
|
462
469
|
|
|
463
470
|
void parseLayerBlendingRange(IteratorBase &r, LayerBlendingRange &b) {
|
|
471
|
+
b.present = true;
|
|
464
472
|
b.grayBlendSource = r.getInt32(true);
|
|
465
473
|
b.grayBlendDest = r.getInt32(true);
|
|
466
474
|
while (r.rest() >= 8) {
|
|
@@ -63,6 +63,152 @@ py::object layerText(const psd::LayerInfo &l) {
|
|
|
63
63
|
runs.append(rd);
|
|
64
64
|
}
|
|
65
65
|
d["runs"] = runs;
|
|
66
|
+
py::list paras;
|
|
67
|
+
for (const auto &p : t.paragraphs) {
|
|
68
|
+
py::dict pd;
|
|
69
|
+
pd["length"] = p.length; // UTF-16 code units
|
|
70
|
+
pd["justification"] = p.justification; // 0=left 1=right 2=center
|
|
71
|
+
paras.append(pd);
|
|
72
|
+
}
|
|
73
|
+
d["paragraphs"] = paras; // 段落別行揃え (段落=改行区切り)
|
|
74
|
+
return std::move(d);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Layer mask ('layer mask / adjustment layer data') as a dict, or None when
|
|
78
|
+
// the layer carries no mask.
|
|
79
|
+
py::object layerMask(const psd::LayerInfo &l) {
|
|
80
|
+
const psd::LayerMask &m = l.extraData.layerMask;
|
|
81
|
+
if (!m.present) return py::none();
|
|
82
|
+
py::dict d;
|
|
83
|
+
d["top"] = m.top;
|
|
84
|
+
d["left"] = m.left;
|
|
85
|
+
d["bottom"] = m.bottom;
|
|
86
|
+
d["right"] = m.right;
|
|
87
|
+
d["width"] = m.width;
|
|
88
|
+
d["height"] = m.height;
|
|
89
|
+
d["default_color"] = m.defaultColor; // 0..255
|
|
90
|
+
d["flags"] = m.flags; // raw flag byte
|
|
91
|
+
d["relative"] = (bool)(m.flags & 1); // position relative to layer
|
|
92
|
+
d["disabled"] = (bool)(m.flags & 2); // mask disabled
|
|
93
|
+
d["inverted"] = (bool)(m.flags & 4); // invert (obsolete)
|
|
94
|
+
d["from_render"] = (bool)(m.flags & 8); // mask from rendering other data
|
|
95
|
+
d["has_parameters"] = (bool)(m.flags & 16); // density/feather present (not decoded yet)
|
|
96
|
+
if (m.hasReal) { // real/user mask (block size > 20)
|
|
97
|
+
py::dict rd;
|
|
98
|
+
rd["flags"] = m.realFlags;
|
|
99
|
+
rd["background"] = m.realUserMaskBackground;
|
|
100
|
+
rd["top"] = m.enclosingTop;
|
|
101
|
+
rd["left"] = m.enclosingLeft;
|
|
102
|
+
rd["bottom"] = m.enclosingBottom;
|
|
103
|
+
rd["right"] = m.enclosingRight;
|
|
104
|
+
d["real"] = rd;
|
|
105
|
+
} else {
|
|
106
|
+
d["real"] = py::none();
|
|
107
|
+
}
|
|
108
|
+
return std::move(d);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Layer blending ranges as a dict, or None when absent. source/dest are the
|
|
112
|
+
// raw 32-bit packed values (each holds two 16-bit black/white sub-ranges).
|
|
113
|
+
py::object layerBlendingRanges(const psd::LayerInfo &l) {
|
|
114
|
+
const psd::LayerBlendingRange &b = l.extraData.layerBlendingRange;
|
|
115
|
+
if (!b.present) return py::none();
|
|
116
|
+
py::dict d;
|
|
117
|
+
d["gray"] = py::make_tuple(b.grayBlendSource, b.grayBlendDest);
|
|
118
|
+
py::list ch;
|
|
119
|
+
for (const auto &c : b.channels)
|
|
120
|
+
ch.append(py::make_tuple(c.source, c.dest));
|
|
121
|
+
d["channels"] = ch;
|
|
122
|
+
return std::move(d);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Grid & guides image resource (1032) as a dict, or None when the PSD has none.
|
|
126
|
+
py::object psdGuides(psd::PSDFile &self) {
|
|
127
|
+
const psd::GridGuideResource &g = self.gridGuide;
|
|
128
|
+
if (!g.isEnabled) return py::none();
|
|
129
|
+
py::dict d;
|
|
130
|
+
d["horizontal_grid"] = g.horizontalGrid;
|
|
131
|
+
d["vertical_grid"] = g.verticalGrid;
|
|
132
|
+
py::list gl;
|
|
133
|
+
for (const auto &gi : g.guides) {
|
|
134
|
+
py::dict gd;
|
|
135
|
+
gd["location"] = gi.location; // 1/32 px from origin
|
|
136
|
+
gd["direction"] = (gi.direction == psd::GUIDE_DIR_VERTICAL) ? "vertical"
|
|
137
|
+
: "horizontal";
|
|
138
|
+
gl.append(gd);
|
|
139
|
+
}
|
|
140
|
+
d["guides"] = gl;
|
|
141
|
+
return std::move(d);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Slices image resource (1050, v6) as a dict, or None when absent.
|
|
145
|
+
py::object psdSlices(psd::PSDFile &self) {
|
|
146
|
+
const psd::SliceResource &s = self.slice;
|
|
147
|
+
if (!s.isEnabled) return py::none();
|
|
148
|
+
py::dict d;
|
|
149
|
+
d["group_name"] = py::cast(s.groupName);
|
|
150
|
+
py::dict bb;
|
|
151
|
+
bb["left"] = s.boundingLeft;
|
|
152
|
+
bb["top"] = s.boundingTop;
|
|
153
|
+
bb["right"] = s.boundingRight;
|
|
154
|
+
bb["bottom"] = s.boundingBottom;
|
|
155
|
+
d["bounding"] = bb;
|
|
156
|
+
py::list items;
|
|
157
|
+
for (const auto &it : s.slices) {
|
|
158
|
+
py::dict id;
|
|
159
|
+
id["id"] = it.id;
|
|
160
|
+
id["group_id"] = it.groupId;
|
|
161
|
+
id["origin"] = it.origin;
|
|
162
|
+
id["associated_layer_id"] = it.associatedLayerId;
|
|
163
|
+
id["name"] = py::cast(it.name);
|
|
164
|
+
id["type"] = it.type;
|
|
165
|
+
id["left"] = it.left;
|
|
166
|
+
id["top"] = it.top;
|
|
167
|
+
id["right"] = it.right;
|
|
168
|
+
id["bottom"] = it.bottom;
|
|
169
|
+
id["url"] = py::cast(it.url);
|
|
170
|
+
id["target"] = py::cast(it.target);
|
|
171
|
+
id["message"] = py::cast(it.message);
|
|
172
|
+
id["alt_tag"] = py::cast(it.altTag);
|
|
173
|
+
id["cell_text"] = py::cast(it.cellText);
|
|
174
|
+
id["is_cell_text_html"] = it.isCellTextHtml;
|
|
175
|
+
id["horizontal_align"] = it.horizontalAlign;
|
|
176
|
+
id["vertical_align"] = it.verticalAlign;
|
|
177
|
+
id["color"] = py::make_tuple(it.colorR, it.colorG, it.colorB, it.colorA);
|
|
178
|
+
items.append(id);
|
|
179
|
+
}
|
|
180
|
+
d["slices"] = items;
|
|
181
|
+
return std::move(d);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Layer comps image resource (1065) as a list of dicts (empty list if none).
|
|
185
|
+
py::list psdLayerComps(psd::PSDFile &self) {
|
|
186
|
+
py::list out;
|
|
187
|
+
for (const auto &c : self.layerComps) {
|
|
188
|
+
py::dict d;
|
|
189
|
+
d["id"] = c.id;
|
|
190
|
+
d["name"] = py::cast(c.name);
|
|
191
|
+
d["comment"] = py::cast(c.comment);
|
|
192
|
+
d["record_visibility"] = c.isRecordVisibility;
|
|
193
|
+
d["record_position"] = c.isRecordPosition;
|
|
194
|
+
d["record_appearance"] = c.isRecordAppearance;
|
|
195
|
+
out.append(d);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Indexed-color palette (from color mode data) as a dict, or None for
|
|
201
|
+
// non-indexed PSDs.
|
|
202
|
+
py::object psdColorTable(psd::PSDFile &self) {
|
|
203
|
+
const psd::ColorTable &t = self.colorTable;
|
|
204
|
+
if (t.colors.empty()) return py::none();
|
|
205
|
+
py::dict d;
|
|
206
|
+
d["valid_count"] = t.validCount;
|
|
207
|
+
d["transparency_index"] = t.transparencyIndex;
|
|
208
|
+
py::list cols;
|
|
209
|
+
for (const auto &c : t.colors)
|
|
210
|
+
cols.append(py::make_tuple(c.r, c.g, c.b, c.a));
|
|
211
|
+
d["colors"] = cols;
|
|
66
212
|
return std::move(d);
|
|
67
213
|
}
|
|
68
214
|
|
|
@@ -164,9 +310,17 @@ PYBIND11_MODULE(psdparse, m) {
|
|
|
164
310
|
.def_property_readonly("name_unicode", [](const psd::LayerInfo &l) {
|
|
165
311
|
return u16ToStr(l.layerNameUnicode);
|
|
166
312
|
})
|
|
313
|
+
.def_readonly("parent_index", &psd::LayerInfo::parentIndex,
|
|
314
|
+
"Index into PSDFile.layers of the enclosing folder layer, or -1 for "
|
|
315
|
+
"top-level layers. Build the layer tree from these.")
|
|
167
316
|
.def_property_readonly("text", &layerText,
|
|
168
317
|
"Text-layer content/style as a dict (keys: text, orientation, "
|
|
169
318
|
"justification, transform, runs[]), or None for non-text layers.")
|
|
319
|
+
.def_property_readonly("mask", &layerMask,
|
|
320
|
+
"Layer mask as a dict (bbox, default_color, flags, disabled, real{}), "
|
|
321
|
+
"or None when the layer has no mask.")
|
|
322
|
+
.def_property_readonly("blending_ranges", &layerBlendingRanges,
|
|
323
|
+
"Layer blending ranges as a dict (gray, channels[]), or None.")
|
|
170
324
|
.def_property_readonly("visible", [](const psd::LayerInfo &l){ return l.isVisible(); })
|
|
171
325
|
.def_property_readonly("transparency_protected", [](const psd::LayerInfo &l){ return l.isTransparencyProtected(); })
|
|
172
326
|
.def_property_readonly("obsolete", [](const psd::LayerInfo &l){ return l.isObsolete(); })
|
|
@@ -224,7 +378,19 @@ PYBIND11_MODULE(psdparse, m) {
|
|
|
224
378
|
.def("layer_image", &layerImage,
|
|
225
379
|
py::arg("index"), py::arg("mode") = "masked",
|
|
226
380
|
"Extract pixels for layer `index` as BGRA bytes. "
|
|
227
|
-
"mode: 'masked' (default), 'image' (no mask), 'mask' (mask only).")
|
|
381
|
+
"mode: 'masked' (default), 'image' (no mask), 'mask' (mask only).")
|
|
382
|
+
.def_property_readonly("guides", &psdGuides,
|
|
383
|
+
"Grid & guides (image resource 1032) as a dict "
|
|
384
|
+
"(horizontal_grid, vertical_grid, guides[]), or None.")
|
|
385
|
+
.def_property_readonly("slices", &psdSlices,
|
|
386
|
+
"Slices (image resource 1050 v6) as a dict "
|
|
387
|
+
"(group_name, bounding, slices[]), or None.")
|
|
388
|
+
.def_property_readonly("layer_comps", &psdLayerComps,
|
|
389
|
+
"Document layer comps (image resource 1065) as a list of dicts "
|
|
390
|
+
"(id, name, comment, record_*). Empty list when there are none.")
|
|
391
|
+
.def_property_readonly("color_table", &psdColorTable,
|
|
392
|
+
"Indexed-color palette as a dict (colors[], valid_count, "
|
|
393
|
+
"transparency_index), or None for non-indexed PSDs.");
|
|
228
394
|
|
|
229
395
|
// Enum-like ints exposed as module attributes for convenience
|
|
230
396
|
m.attr("LAYER_TYPE_NORMAL") = (int)psd::LAYER_TYPE_NORMAL;
|
|
@@ -58,6 +58,18 @@ def sample_text_psd():
|
|
|
58
58
|
return _find_sample("fontsample.psd")
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
@pytest.fixture(scope="session")
|
|
62
|
+
def sample_group_psd():
|
|
63
|
+
"""A PSD with nested folder groups (for hierarchy / metadata tests)."""
|
|
64
|
+
return _find_sample("config.psd", "system.psd")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture(scope="session")
|
|
68
|
+
def sample_mask_psd():
|
|
69
|
+
"""A small PSD with a layer mask (synthesized fixture)."""
|
|
70
|
+
return _find_sample("masktest.psd")
|
|
71
|
+
|
|
72
|
+
|
|
61
73
|
@pytest.fixture
|
|
62
74
|
def psd_ui(sample_ui_psd):
|
|
63
75
|
p = psdparse.PSDFile()
|
|
@@ -72,6 +84,20 @@ def psd_text(sample_text_psd):
|
|
|
72
84
|
return p
|
|
73
85
|
|
|
74
86
|
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def psd_group(sample_group_psd):
|
|
89
|
+
p = psdparse.PSDFile()
|
|
90
|
+
assert p.load(str(sample_group_psd))
|
|
91
|
+
return p
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
def psd_mask(sample_mask_psd):
|
|
96
|
+
p = psdparse.PSDFile()
|
|
97
|
+
assert p.load(str(sample_mask_psd))
|
|
98
|
+
return p
|
|
99
|
+
|
|
100
|
+
|
|
75
101
|
@pytest.fixture
|
|
76
102
|
def psd_large(sample_large_psd):
|
|
77
103
|
p = psdparse.PSDFile()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for the Tier-1 reference metadata exposed in 0.3.0:
|
|
2
|
+
|
|
3
|
+
layer hierarchy (parent_index), layer mask, blending ranges, and the
|
|
4
|
+
document-level guides / slices / layer comps / color table accessors.
|
|
5
|
+
"""
|
|
6
|
+
import psdparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# --- layer hierarchy -------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
def test_parent_index_valid(psd_group):
|
|
12
|
+
"""Every parent_index is either -1 (top level) or points at a FOLDER."""
|
|
13
|
+
layers = psd_group.layers
|
|
14
|
+
tops = children = 0
|
|
15
|
+
for i, l in enumerate(layers):
|
|
16
|
+
pi = l.parent_index
|
|
17
|
+
assert pi == -1 or 0 <= pi < len(layers), (i, pi)
|
|
18
|
+
if pi == -1:
|
|
19
|
+
tops += 1
|
|
20
|
+
else:
|
|
21
|
+
children += 1
|
|
22
|
+
assert layers[pi].layer_type == psdparse.LayerType.FOLDER, \
|
|
23
|
+
f"parent of layer {i} (index {pi}) is not a FOLDER"
|
|
24
|
+
assert tops > 0
|
|
25
|
+
assert children > 0 # the group sample is nested
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_tree_reconstruction(psd_group):
|
|
29
|
+
"""parent_index lets us rebuild children lists that partition the layers."""
|
|
30
|
+
layers = psd_group.layers
|
|
31
|
+
children = {i: [] for i in range(len(layers))}
|
|
32
|
+
roots = []
|
|
33
|
+
for i, l in enumerate(layers):
|
|
34
|
+
(roots if l.parent_index == -1 else children[l.parent_index]).append(i)
|
|
35
|
+
total = len(roots) + sum(len(v) for v in children.values())
|
|
36
|
+
assert total == len(layers)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- layer mask ------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def test_mask_none_when_absent(psd_group):
|
|
42
|
+
# Most layers in the sample have no mask -> None.
|
|
43
|
+
assert any(l.mask is None for l in psd_group.layers)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_mask_dict_shape(psd_mask):
|
|
47
|
+
masked = [l for l in psd_mask.layers if l.mask is not None]
|
|
48
|
+
assert len(masked) >= 1
|
|
49
|
+
m = masked[0].mask
|
|
50
|
+
# geometry is self-consistent
|
|
51
|
+
assert m["width"] == m["right"] - m["left"]
|
|
52
|
+
assert m["height"] == m["bottom"] - m["top"]
|
|
53
|
+
assert 0 <= m["default_color"] <= 255
|
|
54
|
+
for k in ("relative", "disabled", "inverted", "from_render", "has_parameters"):
|
|
55
|
+
assert isinstance(m[k], bool)
|
|
56
|
+
# synthesized fixture: 40x32 mask at (left=10, top=8), not disabled
|
|
57
|
+
assert (m["width"], m["height"]) == (40, 32)
|
|
58
|
+
assert m["disabled"] is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- blending ranges -------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def test_blending_ranges_shape(psd_group):
|
|
64
|
+
ranged = [l for l in psd_group.layers if l.blending_ranges is not None]
|
|
65
|
+
assert len(ranged) > 0
|
|
66
|
+
br = ranged[0].blending_ranges
|
|
67
|
+
assert len(br["gray"]) == 2
|
|
68
|
+
for pair in br["channels"]:
|
|
69
|
+
assert len(pair) == 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- document resources ----------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def test_guides_accessor(psd_group):
|
|
75
|
+
g = psd_group.guides
|
|
76
|
+
if g is None:
|
|
77
|
+
return # sample may lack a grid/guides resource
|
|
78
|
+
assert "horizontal_grid" in g and "vertical_grid" in g
|
|
79
|
+
for gd in g["guides"]:
|
|
80
|
+
assert gd["direction"] in ("vertical", "horizontal")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_slices_accessor(psd_group):
|
|
84
|
+
s = psd_group.slices
|
|
85
|
+
if s is None:
|
|
86
|
+
return
|
|
87
|
+
assert "bounding" in s and "slices" in s
|
|
88
|
+
for it in s["slices"]:
|
|
89
|
+
assert it["right"] >= it["left"]
|
|
90
|
+
assert it["bottom"] >= it["top"]
|
|
91
|
+
assert len(it["color"]) == 4
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_layer_comps_is_list(psd_group):
|
|
95
|
+
comps = psd_group.layer_comps
|
|
96
|
+
assert isinstance(comps, list)
|
|
97
|
+
for c in comps:
|
|
98
|
+
assert "id" in c and "name" in c
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_color_table_none_for_rgb(psd_group):
|
|
102
|
+
# The group samples are RGB, so there is no indexed palette.
|
|
103
|
+
assert psd_group.header.mode == psdparse.COLOR_MODE_RGB
|
|
104
|
+
assert psd_group.color_table is None
|
|
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
|
|
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
|