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.
Files changed (41) hide show
  1. {psdparse-0.2.2 → psdparse-0.3.0}/PKG-INFO +1 -1
  2. {psdparse-0.2.2 → psdparse-0.3.0}/docs/PYTHON_API.md +76 -0
  3. {psdparse-0.2.2 → psdparse-0.3.0}/docs/ROADMAP.md +2 -1
  4. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psddata.h +5 -0
  5. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdparse.cpp +8 -0
  6. {psdparse-0.2.2 → psdparse-0.3.0}/pyproject.toml +1 -1
  7. {psdparse-0.2.2 → psdparse-0.3.0}/python/psdparse_module.cpp +159 -1
  8. {psdparse-0.2.2 → psdparse-0.3.0}/tests/conftest.py +26 -0
  9. psdparse-0.3.0/tests/test_metadata.py +104 -0
  10. {psdparse-0.2.2 → psdparse-0.3.0}/.gitignore +0 -0
  11. {psdparse-0.2.2 → psdparse-0.3.0}/CMakeLists.txt +0 -0
  12. {psdparse-0.2.2 → psdparse-0.3.0}/CMakePresets.json +0 -0
  13. {psdparse-0.2.2 → psdparse-0.3.0}/LICENSE +0 -0
  14. {psdparse-0.2.2 → psdparse-0.3.0}/Makefile +0 -0
  15. {psdparse-0.2.2 → psdparse-0.3.0}/README.md +0 -0
  16. {psdparse-0.2.2 → psdparse-0.3.0}/docs/ARCHITECTURE.md +0 -0
  17. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/CMakeLists.txt +0 -0
  18. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/bmp.cpp +0 -0
  19. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psd_cli.cpp +0 -0
  20. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdbase.h +0 -0
  21. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psddesc.cpp +0 -0
  22. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psddesc.h +0 -0
  23. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdengine.cpp +0 -0
  24. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdengine.h +0 -0
  25. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdfile.cpp +0 -0
  26. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdfile.h +0 -0
  27. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdimage.cpp +0 -0
  28. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdlayer.cpp +0 -0
  29. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdlayer.h +0 -0
  30. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdparse.h +0 -0
  31. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdresource.cpp +0 -0
  32. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdresource.h +0 -0
  33. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdwrite.cpp +0 -0
  34. {psdparse-0.2.2 → psdparse-0.3.0}/psdparse/psdwrite.h +0 -0
  35. {psdparse-0.2.2 → psdparse-0.3.0}/python/CMakeLists.txt +0 -0
  36. {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_header.py +0 -0
  37. {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_images.py +0 -0
  38. {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_layers.py +0 -0
  39. {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_save.py +0 -0
  40. {psdparse-0.2.2 → psdparse-0.3.0}/tests/test_text.py +0 -0
  41. {psdparse-0.2.2 → psdparse-0.3.0}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.2.2
3
+ Version: 0.3.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>
@@ -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: parse + re-emission for masks > 20 bytes (real mask, vector mask flag, density / feather).
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) {
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "psdparse"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Fast PSD (Photoshop) reader/writer — C++17 core with pybind11 bindings"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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