psdparse 0.2.2__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.2 → psdparse-0.3.0}/PKG-INFO +1 -1
- {psdparse-0.2.2 → psdparse-0.3.0}/docs/PYTHON_API.md +76 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/docs/ROADMAP.md +2 -1
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psddata.h +5 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdparse.cpp +8 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/pyproject.toml +1 -1
- {psdparse-0.2.2 → psdparse-0.3.0}/python/psdparse_module.cpp +159 -1
- {psdparse-0.2.2 → psdparse-0.3.0}/tests/conftest.py +26 -0
- psdparse-0.3.0/tests/test_metadata.py +104 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/.gitignore +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/CMakeLists.txt +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/CMakePresets.json +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/LICENSE +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/Makefile +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/README.md +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/CMakeLists.txt +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/bmp.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdbase.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psddesc.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psddesc.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdengine.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdengine.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdfile.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdfile.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdimage.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdlayer.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdlayer.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdparse.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdresource.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdwrite.cpp +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdwrite.h +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/python/CMakeLists.txt +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_header.py +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_images.py +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_layers.py +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_save.py +0 -0
- {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_text.py +0 -0
- {psdparse-0.2.2 → 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;
|
|
@@ -477,6 +480,8 @@ namespace psd {
|
|
|
477
480
|
|
|
478
481
|
// 親フォルダレイヤ
|
|
479
482
|
LayerInfo *parent;
|
|
483
|
+
// 親フォルダの layerList インデックス (-1 = トップレベル)。processParsed で設定。
|
|
484
|
+
int parentIndex = -1;
|
|
480
485
|
|
|
481
486
|
bool isTransparencyProtected() const { return (flag & (1 << 0)) != 0; }
|
|
482
487
|
bool isVisible() const { return (flag & (1 << 1)) == 0; }
|
|
@@ -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) {
|
|
@@ -74,6 +74,144 @@ py::object layerText(const psd::LayerInfo &l) {
|
|
|
74
74
|
return std::move(d);
|
|
75
75
|
}
|
|
76
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;
|
|
212
|
+
return std::move(d);
|
|
213
|
+
}
|
|
214
|
+
|
|
77
215
|
py::bytes layerImage(psd::PSDFile &self, int index, const std::string &mode) {
|
|
78
216
|
if (!self.isLoaded) throw std::runtime_error("PSD not loaded");
|
|
79
217
|
if (index < 0 || index >= (int)self.layerList.size())
|
|
@@ -172,9 +310,17 @@ PYBIND11_MODULE(psdparse, m) {
|
|
|
172
310
|
.def_property_readonly("name_unicode", [](const psd::LayerInfo &l) {
|
|
173
311
|
return u16ToStr(l.layerNameUnicode);
|
|
174
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.")
|
|
175
316
|
.def_property_readonly("text", &layerText,
|
|
176
317
|
"Text-layer content/style as a dict (keys: text, orientation, "
|
|
177
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.")
|
|
178
324
|
.def_property_readonly("visible", [](const psd::LayerInfo &l){ return l.isVisible(); })
|
|
179
325
|
.def_property_readonly("transparency_protected", [](const psd::LayerInfo &l){ return l.isTransparencyProtected(); })
|
|
180
326
|
.def_property_readonly("obsolete", [](const psd::LayerInfo &l){ return l.isObsolete(); })
|
|
@@ -232,7 +378,19 @@ PYBIND11_MODULE(psdparse, m) {
|
|
|
232
378
|
.def("layer_image", &layerImage,
|
|
233
379
|
py::arg("index"), py::arg("mode") = "masked",
|
|
234
380
|
"Extract pixels for layer `index` as BGRA bytes. "
|
|
235
|
-
"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.");
|
|
236
394
|
|
|
237
395
|
// Enum-like ints exposed as module attributes for convenience
|
|
238
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
|
|
File without changes
|