psdparse 0.5.0__tar.gz → 0.6.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 (46) hide show
  1. {psdparse-0.5.0 → psdparse-0.6.0}/PKG-INFO +1 -1
  2. {psdparse-0.5.0 → psdparse-0.6.0}/docs/PYTHON_API.md +26 -7
  3. {psdparse-0.5.0 → psdparse-0.6.0}/docs/ROADMAP.md +3 -3
  4. {psdparse-0.5.0 → psdparse-0.6.0}/docs/SUPPORT.md +19 -19
  5. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psddata.h +20 -8
  6. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdimage.cpp +111 -6
  7. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdparse.cpp +20 -5
  8. {psdparse-0.5.0 → psdparse-0.6.0}/pyproject.toml +1 -1
  9. {psdparse-0.5.0 → psdparse-0.6.0}/python/psdparse_module.cpp +61 -5
  10. {psdparse-0.5.0 → psdparse-0.6.0}/tests/conftest.py +53 -0
  11. psdparse-0.6.0/tests/test_colormodes.py +49 -0
  12. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_descriptors.py +8 -0
  13. psdparse-0.6.0/tests/test_mask_extras.py +61 -0
  14. {psdparse-0.5.0 → psdparse-0.6.0}/.gitignore +0 -0
  15. {psdparse-0.5.0 → psdparse-0.6.0}/CMakeLists.txt +0 -0
  16. {psdparse-0.5.0 → psdparse-0.6.0}/CMakePresets.json +0 -0
  17. {psdparse-0.5.0 → psdparse-0.6.0}/LICENSE +0 -0
  18. {psdparse-0.5.0 → psdparse-0.6.0}/Makefile +0 -0
  19. {psdparse-0.5.0 → psdparse-0.6.0}/README.md +0 -0
  20. {psdparse-0.5.0 → psdparse-0.6.0}/docs/ARCHITECTURE.md +0 -0
  21. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/CMakeLists.txt +0 -0
  22. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/bmp.cpp +0 -0
  23. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psd_cli.cpp +0 -0
  24. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdbase.h +0 -0
  25. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psddesc.cpp +0 -0
  26. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psddesc.h +0 -0
  27. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdengine.cpp +0 -0
  28. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdengine.h +0 -0
  29. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdfile.cpp +0 -0
  30. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdfile.h +0 -0
  31. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdlayer.cpp +0 -0
  32. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdlayer.h +0 -0
  33. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdparse.h +0 -0
  34. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdresource.cpp +0 -0
  35. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdresource.h +0 -0
  36. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdwrite.cpp +0 -0
  37. {psdparse-0.5.0 → psdparse-0.6.0}/psdparse/psdwrite.h +0 -0
  38. {psdparse-0.5.0 → psdparse-0.6.0}/python/CMakeLists.txt +0 -0
  39. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_header.py +0 -0
  40. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_images.py +0 -0
  41. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_layers.py +0 -0
  42. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_metadata.py +0 -0
  43. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_resources.py +0 -0
  44. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_save.py +0 -0
  45. {psdparse-0.5.0 → psdparse-0.6.0}/tests/test_text.py +0 -0
  46. {psdparse-0.5.0 → psdparse-0.6.0}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.5.0
3
+ Version: 0.6.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>
@@ -107,6 +107,7 @@ Read-only view of one layer.
107
107
  | `blending_ranges` | `dict` \| `None` | "Blend If" ranges (`None` when absent) — see below |
108
108
  | `effects` | `dict` \| `None` | layer effects (`lfx2`) as a descriptor dict — see [Descriptor blocks](#descriptor-blocks) |
109
109
  | `fill` | `dict` \| `None` | fill-layer content (solid/gradient/pattern) — see [Descriptor blocks](#descriptor-blocks) |
110
+ | `sheet_color` | `dict` \| `None` | layer-panel color label (`lclr`): `{"index", "name"}` — `None` when no `lclr` block |
110
111
  | `info_keys` | `list[str]` | 4cc keys of every additional-layer-info block on this layer |
111
112
  | `visible` | `bool` | flag bit 1 inverted |
112
113
  | `transparency_protected` | `bool` | flag bit 0 |
@@ -175,16 +176,24 @@ for layer in p.layers:
175
176
  "disabled": False, # bit1: mask disabled
176
177
  "inverted": False, # bit2: invert (obsolete)
177
178
  "from_render": False, # bit3: mask from rendering other data
178
- "has_parameters": False, # bit4: density/feather present (not decoded yet)
179
+ "has_parameters": True, # bit4: density/feather block present
180
+ "user_density": 128, # 0..255, or None if not stored
181
+ "user_feather": 2.5, # feather radius (px), or None
182
+ "vector_density": None, # 0..255, or None
183
+ "vector_feather": None, # feather radius (px), or None
179
184
  "real": None, # or a nested dict (below) for a real/user mask
180
185
  }
181
186
  ```
182
187
 
183
- When the record carries a *real* (user + vector combined) mask, `real` is a dict
184
- with `flags`, `background`, and the enclosing `top/left/bottom/right`. The mask
185
- **pixels** are unchanged from before — fetch them with
188
+ When the record carries a *real* (user + vector combined) mask (block size ≥ 36),
189
+ `real` is a dict with `flags`, `background`, and the enclosing
190
+ `top/left/bottom/right`. The mask **pixels** are unchanged — fetch them with
186
191
  `p.layer_image(i, "mask")`.
187
192
 
193
+ `user_density`/`user_feather`/`vector_density`/`vector_feather` are only non-`None`
194
+ when `has_parameters` is set and the corresponding value was stored. (Fixed in
195
+ 0.6.0: the real-mask section was previously decoded one byte off.)
196
+
188
197
  ### `layer.blending_ranges` — "Blend If" ranges
189
198
 
190
199
  `None` when absent. `gray` is the composite range; `channels` has one entry per
@@ -224,6 +233,7 @@ p.guides # dict|None : {"horizontal_grid", "vertical_grid", "guides":[{"l
224
233
  p.slices # dict|None : {"group_name", "bounding":{...}, "slices":[{...}]}
225
234
  p.layer_comps # list[dict]: [{"id","name","comment","record_visibility","record_position","record_appearance"}]
226
235
  p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparency_index"} for indexed-color PSDs
236
+ p.global_layer_mask # dict|None : {"overlay_color_space", "color":(c1,c2,c3,c4), "opacity", "kind"}
227
237
  ```
228
238
 
229
239
  ### Image resources (raw)
@@ -305,9 +315,12 @@ Notes:
305
315
  - `layer.effects` is `lfx2` (object-based, Photoshop 6+). The older binary
306
316
  `lrFX` block is **not** a descriptor and returns `None` via `descriptor()`.
307
317
  - `descriptor(key, skip)` is the generic escape hatch: `skip` is the number of
308
- version-prefix bytes before the descriptor (`-1` auto-detects for `lfx2` = 8
309
- and `SoCo`/`GdFl`/`PtFl` = 4, otherwise 0). Use `info_keys` to discover which
310
- blocks a layer carries.
318
+ version-prefix bytes before the descriptor (`-1` auto-detects for the known
319
+ descriptor keys: `lfx2` = 8, `SoCo`/`GdFl`/`PtFl`/`vstk`/`CgEd` = 4,
320
+ `vscg`/`vogk` = 8, `SoLd` = 12, otherwise 0). Use `info_keys` to discover which
321
+ blocks a layer carries. The smart-object/vector defaults (`SoLd`/`vstk`/`vscg`/
322
+ `vogk`) are set from the psd-tools layouts but not yet verified against a real
323
+ smart-object sample — override `skip` if a parse looks wrong.
311
324
  - Decoding is **lazy** — the descriptor is parsed from the block's raw bytes on
312
325
  each access, so cache the result if you read it repeatedly.
313
326
 
@@ -342,6 +355,12 @@ psdparse.LAYER_TYPE_NORMAL, LAYER_TYPE_HIDDEN, LAYER_TYPE_FOLDER,
342
355
  LAYER_TYPE_ADJUST, LAYER_TYPE_FILL
343
356
  ```
344
357
 
358
+ Pixel extraction (`merged_image()` / `layer_image()`) supports Bitmap, Grayscale,
359
+ RGB, Indexed, CMYK (→RGB), **Duotone** (rendered as grayscale) and **Lab**
360
+ (standard D65 CIELAB→sRGB approximation — Photoshop uses D50, so highly saturated
361
+ colors differ slightly). **Multichannel** has no canonical RGB mapping and is not
362
+ rendered.
363
+
345
364
  ## Pixel format
346
365
 
347
366
  All `*_image()` methods return interleaved BGRA in little-endian byte order:
@@ -84,8 +84,8 @@ This is mostly a constructor that fills `Data` with a minimal-but-valid skeleton
84
84
  - ✅ **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`.
85
85
  - ✅ **Tier-2 generic Descriptor → dict bridge.** *Done 2026-08-02 (0.4.0).* `layer.effects` (`lfx2`), `layer.fill` (`SoCo`/`GdFl`/`PtFl`), `layer.info_keys`, and a generic `layer.descriptor(key, skip)` escape hatch route previously-skipped tagged blocks through the existing complete descriptor parser (`psddesc.*`). A `Descriptor → py::dict` converter in the binding (dynamic_cast dispatch; UnitFloat→`{value,unit}`, Enumerated→`{type,value}`, nested Descriptor→dict, List→list, tdta→bytes) means no per-feature decoders were needed. Decoding is lazy: each access clones the block's reader (`clone()`+`init()`) and re-parses, so `save()` round-trips remain byte-identical. Cross-checked value-for-value against psd-tools 1.17 on `config.psd`'s PatternOverlay (opacity/scale/angle/blend/pattern-name all matched) — see `tests/test_descriptors.py`. **Deferred:** typed high-level effect/fill objects, smart-object `SoLd`/`lnkD` (embedded-file extraction), and binary adjustment layers (`levl`/`curv`) all still need work — the raw descriptor dict is the current interface.
86
86
  - ✅ **Image-resource raw bytes exposed.** *Done 2026-08-02 (0.5.0).* `PSDFile.image_resource(id)` / `.image_resource_ids` plus typed shortcuts `.icc_profile` (1039), `.exif` (1058), `.xmp` (1060, UTF-8 str) and `.thumbnail` (1036/1033 → dict with JPEG bytes + dimensions). Raw bytes only — decoding EXIF tags / rendering the thumbnail is left to the caller. EXIF and ICC bytes cross-checked byte-identical against psd-tools 1.17; thumbnail JPEG verified decodable with Pillow — see `tests/test_resources.py`. **Still raw-only / unexposed:** higher-level decode of these (parsed EXIF, ICC transform) and the structured `GlobalLayerMaskInfo`.
87
+ - ✅ **Mask parameters + color label + global mask + Lab/Duotone pixels.** *Done 2026-08-02 (0.6.0).* A batch of the remaining reference gaps: `layer.mask` now decodes the density/feather `MaskParameters` (`user_density`/`user_feather`/`vector_density`/`vector_feather`) and the real/user-mask section is byte-correct again (the old parser had an off-by-one that shifted the enclosing rect — no filler byte follows `flags`, and the real section is gated on size≥36, matching psd-tools). `layer.sheet_color` exposes the `lclr` layer-panel color label (index+name). `PSDFile.global_layer_mask` exposes the document overlay color/opacity/kind. `getLayerImage`/`getMergedImage` now render **Lab** (standard D65 CIELAB→sRGB approximation; Photoshop uses D50 so saturated colors differ slightly) and **Duotone** (as grayscale per the Adobe spec). The generic `descriptor()` escape hatch gained known default skips for `SoLd`/`vstk`/`vscg`/`vogk`/`CgEd`. Validated against psd-tools-authored fixtures (`maskparams.psd`: real+params mask & lclr; `labsample.psd`: neutral/red swatches; `duosample.psd` vs `graysample.psd`) — see `tests/test_mask_extras.py` and `tests/test_colormodes.py`. **Still open:** Multichannel pixels (no canonical RGB), smart-object embedded-file extraction (`lnkD`), and the SoLd/vector default skips remain unverified against a real smart-object sample.
87
88
  - 16-bit (`Lr16`) and 32-bit-float (`Lr32`) layer data: currently captured in `layerAndMaskTrailing` for round-trip but not exposed as decoded pixels.
88
- - 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.
89
- - 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.
90
- - CMYK / Lab / Indexed color extraction in `getLayerImage` — currently optimized for RGB.
89
+ - Layer mask re-emission for masks > 20 bytes (the field-based save path; round-trip already preserves them via raw bytes).
90
+ - Multichannel color extraction in `getLayerImage` (no canonical RGB representation); smart-object embedded file (`lnkD`) extraction.
91
91
  - Linux / macOS testing. mmap path uses POSIX `mmap` but hasn't been built / tested there.
@@ -13,7 +13,7 @@ psdparse が PSD のどの機能を、どの水準で扱えるかの一覧です
13
13
  | 📦 | 生バイトのみ保持 (ラウンドトリップ用。Python へは未公開) |
14
14
  | ❌ | 未対応 — パースしていない |
15
15
 
16
- 最終更新: 2026-08-02 (v0.4.0)。詳細な今後の計画は [ROADMAP.md](ROADMAP.md)、
16
+ 最終更新: 2026-08-02 (v0.6.0)。詳細な今後の計画は [ROADMAP.md](ROADMAP.md)、
17
17
  Python API の使い方は [PYTHON_API.md](PYTHON_API.md) を参照。
18
18
 
19
19
  ---
@@ -44,9 +44,9 @@ Python API の使い方は [PYTHON_API.md](PYTHON_API.md) を参照。
44
44
  | RGB (8/16/32) | ✅ | |
45
45
  | Indexed (8bit) | ✅ | パレットは `PSDFile.color_table` |
46
46
  | CMYK (8/16/32) | ✅ | RGB へ変換して出力 |
47
- | Multichannel | ❌ | `getLayerImage` 未対応 |
48
- | Duotone | ❌ | 同上 |
49
- | Lab | ❌ | 同上 |
47
+ | Multichannel | ❌ | RGB への正準的変換が無く未対応 |
48
+ | Duotone (8/16/32) | ✅ | grayscale として展開 (Adobe 仕様: duotone データは gray) |
49
+ | Lab (8/16) | ✅ | 標準 D65 CIELAB→sRGB 近似で変換 (Photoshop は D50 のため彩度の高い色は差あり)。32bit Lab は非存在 |
50
50
 
51
51
  出力は常に **BGRA インターリーブ** (4 byte/px)。ICC を用いた色変換は行いません。
52
52
 
@@ -74,15 +74,15 @@ Python API の使い方は [PYTHON_API.md](PYTHON_API.md) を参照。
74
74
  | レイヤ種別 | ✅ | `layer_type` (NORMAL/HIDDEN/FOLDER/ADJUST/FILL/TEXT) |
75
75
  | **フォルダ階層 (親子)** | ✅ | `parent_index` (v0.3.0) |
76
76
  | チャンネル構成 (id/length) | ✅ | `channels[]` |
77
- | シートカラー (レイヤパネルの色ラベル, `lclr`) | ❌ | 未デコード |
77
+ | シートカラー (レイヤパネルの色ラベル, `lclr`) | ✅ | `layer.sheet_color` (`{index,name}`, v0.6.0) |
78
78
 
79
79
  ## レイヤマスク / ブレンド範囲
80
80
 
81
81
  | 機能 | 状況 | API |
82
82
  |---|:---:|---|
83
83
  | マスク矩形/フラグ/既定色 | ✅ | `layer.mask` (v0.3.0) |
84
- | real/user mask (>20byte) | ✅ | `layer.mask["real"]` |
85
- | density / feather | 🟡 | `has_parameters` フラグのみ公開。値 (MaskParameters) は未デコード |
84
+ | real/user mask (size>=36) | ✅ | `layer.mask["real"]` (v0.6.0 でオフセット 1 byte ずれを修正) |
85
+ | density / feather | ✅ | `layer.mask` の `user_density`/`user_feather`/`vector_density`/`vector_feather` (v0.6.0) |
86
86
  | ベクタマスク / パス (`vmsk`/`vsms`) | ❌ | 未対応 |
87
87
  | ブレンディングレンジ ("Blend If") | ✅ | `layer.blending_ranges` (raw 32bit packed) |
88
88
 
@@ -118,9 +118,9 @@ Photoshop の汎用ディスクリプタで格納されるブロックを dict
118
118
  |---|:---:|---|
119
119
  | 調整レイヤの種別判定 | ✅ | `layer_type == ADJUST` |
120
120
  | 調整レイヤのパラメータ (levels/curves 等, binary) | ❌ | 未デコード。descriptor 形式のもの (`CgEd` 等) は `descriptor()` で取得可 |
121
- | スマートオブジェクト変換 (`SoLd`/`PlLd`) | 🟡 | `descriptor()` で試行可 (未検証、既定 skip 要調整) |
121
+ | スマートオブジェクト変換 (`SoLd`) | 🟡 | `descriptor("SoLd")` で取得可 (既定 skip=12 を設定済。実サンプル未検証) |
122
122
  | スマートオブジェクト埋め込みデータ抽出 (`lnkD`) | ❌ | 未対応 |
123
- | ベクタストローク/シェイプ (`vstk`/`vscg`) | 🟡 | `descriptor()` 経由で取得可 (未検証) |
123
+ | ベクタストローク/シェイプ (`vstk`/`vscg`/`vogk`) | 🟡 | `descriptor()` 経由で取得可 (既定 skip 設定済、実サンプル未検証) |
124
124
  | ライブシェイプ情報 (origination) | ❌ | 未対応 |
125
125
 
126
126
  ---
@@ -142,19 +142,19 @@ Photoshop の汎用ディスクリプタで格納されるブロックを dict
142
142
  | 任意リソースの生バイト | ✅ | `PSDFile.image_resource(id)` / `image_resource_ids` (v0.5.0) |
143
143
  | 上記の内容デコード (EXIF タグ解析等) | ❌ | 生バイトを返すのみ。解析は利用側 (Pillow 等) で |
144
144
  | バージョン情報 / アルファチャンネル名 / その他 | 📦→✅ | `image_resource(id)` で生バイト取得可 |
145
- | Global layer mask info | 📦 | 構造体フィールドは未充填、生バイトのみ |
145
+ | Global layer mask info | ✅ | `PSDFile.global_layer_mask` (overlay 色/opacity/kind, v0.6.0) |
146
146
 
147
147
  ---
148
148
 
149
149
  ## まとめ (一言で)
150
150
 
151
- - **得意**: レイヤ列挙 + 属性 + 階層、RGB/CMYK/Gray/Indexed のピクセル取得、
152
- テキストのラン単位スタイル、レイヤー効果/塗りの descriptor、文書メタデータ
153
- (ガイド/スライス/カンプ) と image resource の生バイト (ICC/EXIF/XMP/サムネイル)、
154
- そして **byte-identical なラウンドトリップ**。
155
- - **未対応/限定的**: Lab/Duotone/Multichannel ピクセル、調整レイヤの数値
156
- パラメータ、ベクタパス、スマートオブジェクト実体、効果込みの再合成、
157
- そして編集して保存。
151
+ - **得意**: レイヤ列挙 + 属性 + 階層 + 色ラベル、RGB/CMYK/Gray/Indexed/Lab/Duotone
152
+ のピクセル取得、テキストのラン単位スタイル、マスクの density/feather、レイヤー
153
+ 効果/塗りの descriptor、文書メタデータ (ガイド/スライス/カンプ/global layer
154
+ mask) と image resource の生バイト (ICC/EXIF/XMP/サムネイル)、そして
155
+ **byte-identical なラウンドトリップ**。
156
+ - **未対応/限定的**: Multichannel ピクセル、調整レイヤの数値パラメータ、ベクタ
157
+ パス、スマートオブジェクト実体 (`lnkD`)、効果込みの再合成、そして編集して保存。
158
+ - Lab は標準 D65 CIELAB→sRGB 近似 (Photoshop の D50 とは彩度の高い色でわずかに差)。
158
159
  - image resource の**中身の解釈** (EXIF タグ, サムネイル描画等) は行わず生バイトを
159
- 返すのみ。残る 📦 (global layer mask info の構造化) は「公開するだけ」で対応可
160
- (ROADMAP 参照)。
160
+ 返すのみ。
@@ -277,18 +277,30 @@ namespace psd {
277
277
  };
278
278
 
279
279
  struct GlobalLayerMaskInfo {
280
- int overlayColorSpace;
281
- int color1;
282
- int color2;
283
- int color3;
284
- int color4;
285
- int opacity;
286
- int kind;
280
+ bool present = false; // 空でない global layer mask info ブロックがあったか
281
+ int overlayColorSpace = 0;
282
+ int color1 = 0;
283
+ int color2 = 0;
284
+ int color3 = 0;
285
+ int color4 = 0;
286
+ int opacity = 0;
287
+ int kind = 0;
287
288
  };
288
289
 
289
290
  struct LayerMask {
290
291
  bool present = false; // マスクブロック (size>0) が存在したか
291
- bool hasReal = false; // real/user mask (size>20) を含むか
292
+ bool hasReal = false; // real/user mask (size>=36) を含むか
293
+ // マスクパラメータ (density/feather)。 flags bit4 (parameters_applied) が
294
+ // 立っているとき本体が続く。density は 0..255 (-1=不在)、feather は double。
295
+ bool hasParameters = false; // パラメータブロックが実在したか
296
+ int paramFlags = 0; // パラメータ有無ビット (bit0:userDensity /
297
+ // bit1:userFeather / bit2:vecDensity / bit3:vecFeather)
298
+ int userMaskDensity = -1; // ユーザーマスク濃度 0..255 (-1=不在)
299
+ double userMaskFeather = 0.0; // ユーザーマスクぼかし (px)
300
+ bool hasUserFeather = false;
301
+ int vectorMaskDensity = -1; // ベクタマスク濃度 0..255 (-1=不在)
302
+ double vectorMaskFeather = 0.0;
303
+ bool hasVectorFeather = false;
292
304
  int width;
293
305
  int height;
294
306
  int top;
@@ -1,6 +1,7 @@
1
1
 
2
2
  #include "psdfile.h"
3
3
  #include <cstring>
4
+ #include <cmath>
4
5
 
5
6
  #define USE_ZLIB
6
7
  #ifdef USE_ZLIB
@@ -402,6 +403,84 @@ namespace psd {
402
403
  }
403
404
  }
404
405
 
406
+ // CIELAB(D65) を sRGB 8bit へ変換する。 L∈[0,100], a,b∈[-128,127]。
407
+ // Photoshop の Lab とは参照白(D50)やレンダリング intent が厳密には異なるため
408
+ // 近似だが、従来 Lab は完全に未対応 (全ゼロ出力) だったのを実用値に置き換える。
409
+ inline void labToRgb8(double L, double a, double b,
410
+ uint8_t &R, uint8_t &G, uint8_t &B)
411
+ {
412
+ double fy = (L + 16.0) / 116.0;
413
+ double fx = fy + a / 500.0;
414
+ double fz = fy - b / 200.0;
415
+ auto finv = [](double t) {
416
+ double t3 = t * t * t;
417
+ return (t3 > 0.008856) ? t3 : (t - 16.0 / 116.0) / 7.787;
418
+ };
419
+ // D65 reference white
420
+ double X = 0.95047 * finv(fx);
421
+ double Y = 1.00000 * finv(fy);
422
+ double Z = 1.08883 * finv(fz);
423
+ // XYZ -> linear sRGB
424
+ double rl = 3.2406 * X - 1.5372 * Y - 0.4986 * Z;
425
+ double gl = -0.9689 * X + 1.8758 * Y + 0.0415 * Z;
426
+ double bl = 0.0557 * X - 0.2040 * Y + 1.0570 * Z;
427
+ auto gamma = [](double c) {
428
+ c = (c <= 0.0031308) ? (12.92 * c) : (1.055 * std::pow(c, 1.0 / 2.4) - 0.055);
429
+ return (c < 0.0) ? 0.0 : (c > 1.0 ? 1.0 : c);
430
+ };
431
+ R = (uint8_t)(gamma(rl) * 255.0 + 0.5);
432
+ G = (uint8_t)(gamma(gl) * 255.0 + 0.5);
433
+ B = (uint8_t)(gamma(bl) * 255.0 + 0.5);
434
+ }
435
+
436
+ // Lab チャネルを統合し pitch byte 単位で merged にフィルする。
437
+ // 8bit: L=0..255→0..100, a/b=0..255→-128..127。 16bit は上位バイト近似。
438
+ // 32bit Lab は Photoshop に存在しないため非対応。
439
+ template <typename T>
440
+ void mergeChannelsLab(void *merged, int width, int height,
441
+ std::vector<int> &ids,
442
+ std::vector<uint8_t*> &decodedChannels,
443
+ const ColorFormat &format, int bufPitchByte)
444
+ {
445
+ T *chL = 0, *chA = 0, *chB = 0, *chAlpha = 0;
446
+ for (uint32_t i = 0; i < ids.size(); i++) {
447
+ switch (ids[i]) {
448
+ case CH_ID_TRANSP: chAlpha = (T*)decodedChannels[i]; break;
449
+ case 0: chL = (T*)decodedChannels[i]; break; // L
450
+ case 1: chA = (T*)decodedChannels[i]; break; // a
451
+ case 2: chB = (T*)decodedChannels[i]; break; // b
452
+ default: break;
453
+ }
454
+ }
455
+ if (!chL || !chA || !chB) return;
456
+
457
+ // T の 1 サンプルを 0..255 相当へ正規化するスケール。
458
+ const double norm = (sizeof(T) == 1) ? 1.0 : (255.0 / 65535.0);
459
+
460
+ int pitchPixel = std::abs(bufPitchByte) / 4;
461
+ int linePixel = std::min(width, pitchPixel);
462
+ for (int y = 0; y < height; y++) {
463
+ uint32_t *pix = (uint32_t *)((uint8_t *)merged + bufPitchByte * y);
464
+ T *lp = chL + width * y;
465
+ T *ap = chA + width * y;
466
+ T *bp = chB + width * y;
467
+ T *alp = chAlpha ? chAlpha + width * y : 0;
468
+ for (int x = 0; x < linePixel; x++) {
469
+ double Lv = ((double)(*lp++) * norm) / 255.0 * 100.0;
470
+ double av = ((double)(*ap++) * norm) - 128.0;
471
+ double bv = ((double)(*bp++) * norm) - 128.0;
472
+ uint8_t R, G, B;
473
+ labToRgb8(Lv, av, bv, R, G, B);
474
+ if (alp) {
475
+ uint8_t A = (uint8_t)((double)(*alp++) * norm);
476
+ *pix++ = rgbaCompoToRgba32<uint8_t>(format, R, G, B, A);
477
+ } else {
478
+ *pix++ = rgbaCompoToRgba32<uint8_t>(format, R, G, B);
479
+ }
480
+ }
481
+ }
482
+ }
483
+
405
484
  // アルファチャネルにマスクチャネルをマージ
406
485
  template <typename T>
407
486
  void
@@ -878,6 +957,7 @@ namespace psd {
878
957
  decodedChannels[0], format, bufPitchByte);
879
958
  break;
880
959
  case COLOR_MODE_GRAYSCALE:
960
+ case COLOR_MODE_DUOTONE: // Duotone は grayscale として格納される (Adobe 仕様)
881
961
  switch (header.depth) {
882
962
  case 8:
883
963
  mergeChannelsGray<uint8_t>(buf, imageWidth, imageHeight, channelIds,
@@ -935,10 +1015,22 @@ namespace psd {
935
1015
  break;
936
1016
  }
937
1017
  break;
938
- case COLOR_MODE_MULTICHANNEL:
939
- case COLOR_MODE_DUOTONE:
940
1018
  case COLOR_MODE_LAB:
941
- // unsuported yet
1019
+ switch (header.depth) {
1020
+ case 8:
1021
+ mergeChannelsLab<uint8_t>(buf, imageWidth, imageHeight, channelIds,
1022
+ decodedChannels, format, bufPitchByte);
1023
+ break;
1024
+ case 16:
1025
+ mergeChannelsLab<uint16_t>(buf, imageWidth, imageHeight, channelIds,
1026
+ decodedChannels, format, bufPitchByte);
1027
+ break;
1028
+ default: // 32bit Lab は存在しない
1029
+ break;
1030
+ }
1031
+ break;
1032
+ case COLOR_MODE_MULTICHANNEL:
1033
+ // 任意スポットチャネル群で RGB への正準的な変換が無いため未対応
942
1034
  break;
943
1035
  default:
944
1036
  break;
@@ -1052,6 +1144,7 @@ namespace psd {
1052
1144
  decodedChannels[0], format, bufPitchByte);
1053
1145
  break;
1054
1146
  case COLOR_MODE_GRAYSCALE:
1147
+ case COLOR_MODE_DUOTONE: // Duotone は grayscale として格納される (Adobe 仕様)
1055
1148
  switch (header.depth) {
1056
1149
  case 8:
1057
1150
  mergeChannelsGray<uint8_t>(buf, imageWidth, imageHeight, channelIds,
@@ -1112,10 +1205,22 @@ namespace psd {
1112
1205
  break;
1113
1206
  }
1114
1207
  break;
1115
- case COLOR_MODE_MULTICHANNEL:
1116
- case COLOR_MODE_DUOTONE:
1117
1208
  case COLOR_MODE_LAB:
1118
- // unsuported yet
1209
+ switch (header.depth) {
1210
+ case 8:
1211
+ mergeChannelsLab<uint8_t>(buf, imageWidth, imageHeight, channelIds,
1212
+ decodedChannels, format, bufPitchByte);
1213
+ break;
1214
+ case 16:
1215
+ mergeChannelsLab<uint16_t>(buf, imageWidth, imageHeight, channelIds,
1216
+ decodedChannels, format, bufPitchByte);
1217
+ break;
1218
+ default: // 32bit Lab は存在しない
1219
+ break;
1220
+ }
1221
+ break;
1222
+ case COLOR_MODE_MULTICHANNEL:
1223
+ // 任意スポットチャネル群で RGB への正準的な変換が無いため未対応
1119
1224
  break;
1120
1225
  default:
1121
1226
  break;
@@ -443,17 +443,17 @@ void parseImageResources(IteratorBase &outer, Data &data) {
443
443
  }
444
444
 
445
445
  void parseLayerMask(IteratorBase &r, LayerMask &m, int size) {
446
- if (size <= 0) { std::memset(&m, 0, sizeof(LayerMask)); return; }
446
+ if (size <= 0) { m = LayerMask(); return; }
447
+ // Field order matches psd-tools' MaskData._read_body (the Adobe spec is
448
+ // wrong here): rect, defaultColor, flags, [real section if size>=36],
449
+ // [parameters if flags bit4]. There is NO filler byte after flags.
447
450
  m.top = r.getInt32(true);
448
451
  m.left = r.getInt32(true);
449
452
  m.bottom = r.getInt32(true);
450
453
  m.right = r.getInt32(true);
451
454
  m.defaultColor = r.getCh();
452
455
  m.flags = r.getCh();
453
- // The spec says one filler byte here on size==20. At size>20 the next byte
454
- // is realFlags; the original grammar consumed a byte before realFlags too.
455
- (void)r.getCh();
456
- if (size > 20) {
456
+ if (size >= 36) {
457
457
  m.hasReal = true;
458
458
  m.realFlags = r.getCh();
459
459
  m.realUserMaskBackground = r.getCh();
@@ -462,6 +462,20 @@ void parseLayerMask(IteratorBase &r, LayerMask &m, int size) {
462
462
  m.enclosingBottom = r.getInt32(true);
463
463
  m.enclosingRight = r.getInt32(true);
464
464
  }
465
+ if (m.flags & 0x10) { // parameters_applied
466
+ m.hasParameters = true;
467
+ m.paramFlags = r.getCh();
468
+ if (m.paramFlags & 0x01) m.userMaskDensity = r.getCh();
469
+ if (m.paramFlags & 0x02) {
470
+ pun64 v; v.i = (uint64_t)r.getInt64(true);
471
+ m.userMaskFeather = v.f; m.hasUserFeather = true;
472
+ }
473
+ if (m.paramFlags & 0x04) m.vectorMaskDensity = r.getCh();
474
+ if (m.paramFlags & 0x08) {
475
+ pun64 v; v.i = (uint64_t)r.getInt64(true);
476
+ m.vectorMaskFeather = v.f; m.hasVectorFeather = true;
477
+ }
478
+ }
465
479
  m.width = m.right - m.left;
466
480
  m.height = m.bottom - m.top;
467
481
  m.present = true;
@@ -580,6 +594,7 @@ void parseGlobalLayerMaskInfo(IteratorBase &r, GlobalLayerMaskInfo &g) {
580
594
  g.color4 = r.getInt16(true);
581
595
  g.opacity = r.getInt16(true);
582
596
  g.kind = r.getCh();
597
+ g.present = true;
583
598
  // trailing filler bytes are ignored
584
599
  }
585
600
 
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "psdparse"
7
- version = "0.5.0"
7
+ version = "0.6.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"
@@ -93,8 +93,17 @@ py::object layerMask(const psd::LayerInfo &l) {
93
93
  d["disabled"] = (bool)(m.flags & 2); // mask disabled
94
94
  d["inverted"] = (bool)(m.flags & 4); // invert (obsolete)
95
95
  d["from_render"] = (bool)(m.flags & 8); // mask from rendering other data
96
- d["has_parameters"] = (bool)(m.flags & 16); // density/feather present (not decoded yet)
97
- if (m.hasReal) { // real/user mask (block size > 20)
96
+ d["has_parameters"] = (bool)(m.flags & 16); // density/feather block present
97
+ // マスクパラメータ (density 0..255 / feather px)。 未指定フィールドは None。
98
+ d["user_density"] = (m.userMaskDensity >= 0) ? py::object(py::cast(m.userMaskDensity))
99
+ : py::object(py::none());
100
+ d["user_feather"] = m.hasUserFeather ? py::object(py::cast(m.userMaskFeather))
101
+ : py::object(py::none());
102
+ d["vector_density"] = (m.vectorMaskDensity >= 0) ? py::object(py::cast(m.vectorMaskDensity))
103
+ : py::object(py::none());
104
+ d["vector_feather"] = m.hasVectorFeather ? py::object(py::cast(m.vectorMaskFeather))
105
+ : py::object(py::none());
106
+ if (m.hasReal) { // real/user mask (block size >= 36)
98
107
  py::dict rd;
99
108
  rd["flags"] = m.realFlags;
100
109
  rd["background"] = m.realUserMaskBackground;
@@ -123,6 +132,30 @@ py::object layerBlendingRanges(const psd::LayerInfo &l) {
123
132
  return std::move(d);
124
133
  }
125
134
 
135
+ // Sheet (layer-panel) color label from the 'lclr' block: 2-byte index + 6
136
+ // padding bytes. Returns {"index", "name"} or None when the layer has no
137
+ // label (index 0 / "none" is still reported so callers can distinguish
138
+ // "explicitly none" from "no lclr block"). Names match Photoshop's order.
139
+ py::object layerSheetColor(const psd::LayerInfo &l) {
140
+ static const char *kNames[] = {
141
+ "none", "red", "orange", "yellow", "green", "blue", "violet", "gray",
142
+ "seafoam", "indigo", "magenta", "fuschia",
143
+ };
144
+ for (const auto &a : l.extraData.additionalLayers) {
145
+ if (a.key != 'lclr' || !a.data) continue;
146
+ psd::IteratorBase *rd = a.data->clone();
147
+ rd->init();
148
+ int idx = (uint16_t)rd->getInt16(true);
149
+ delete rd;
150
+ py::dict d;
151
+ d["index"] = idx;
152
+ d["name"] = (idx >= 0 && idx < (int)(sizeof(kNames) / sizeof(kNames[0])))
153
+ ? kNames[idx] : "unknown";
154
+ return std::move(d);
155
+ }
156
+ return py::none();
157
+ }
158
+
126
159
  // Grid & guides image resource (1032) as a dict, or None when the PSD has none.
127
160
  py::object psdGuides(psd::PSDFile &self) {
128
161
  const psd::GridGuideResource &g = self.gridGuide;
@@ -213,6 +246,19 @@ py::object psdColorTable(psd::PSDFile &self) {
213
246
  return std::move(d);
214
247
  }
215
248
 
249
+ // Global layer mask info (the document-level overlay color used to display
250
+ // masks) as a dict, or None when the block is empty/absent.
251
+ py::object psdGlobalLayerMask(psd::PSDFile &self) {
252
+ const psd::GlobalLayerMaskInfo &g = self.globalLayerMaskInfo;
253
+ if (!g.present) return py::none();
254
+ py::dict d;
255
+ d["overlay_color_space"] = g.overlayColorSpace;
256
+ d["color"] = py::make_tuple(g.color1, g.color2, g.color3, g.color4);
257
+ d["opacity"] = g.opacity; // 0..100
258
+ d["kind"] = g.kind; // 0=inverted / 1=all-masks / 128=per-layer
259
+ return std::move(d);
260
+ }
261
+
216
262
  // -------------------------------------------------------------------------
217
263
  // Generic Descriptor -> Python bridge (Tier 2).
218
264
  //
@@ -338,9 +384,13 @@ py::object layerDescriptor(const psd::LayerInfo &l, const std::string &keyStr, i
338
384
  ((int)(uint8_t)keyStr[2] << 8) | (int)(uint8_t)keyStr[3];
339
385
  if (skip < 0) {
340
386
  switch (key) {
341
- case 'lfx2': skip = 8; break;
342
- case 'SoCo': case 'GdFl': case 'PtFl': skip = 4; break;
343
- default: skip = 0; break;
387
+ case 'lfx2': skip = 8; break; // objVer + descVer
388
+ case 'SoCo': case 'GdFl': case 'PtFl': skip = 4; break; // descVer
389
+ case 'SoLd': case 'SoLE': skip = 12; break; // 'soLD' + ver + descVer
390
+ case 'vstk': case 'CgEd': skip = 4; break; // descVer
391
+ case 'vscg': skip = 8; break; // key + ver
392
+ case 'vogk': skip = 8; break; // ver + dataVer
393
+ default: skip = 0; break;
344
394
  }
345
395
  }
346
396
  return keyDescriptor(l, key, skip);
@@ -537,6 +587,9 @@ PYBIND11_MODULE(psdparse, m) {
537
587
  .def_property_readonly("fill", &layerFill,
538
588
  "Fill-layer content as {'type': 'solid'|'gradient'|'pattern', "
539
589
  "'data': {...}} from SoCo/GdFl/PtFl, or None.")
590
+ .def_property_readonly("sheet_color", &layerSheetColor,
591
+ "Layer-panel color label ('lclr') as {'index', 'name'} (0/'none' .. "
592
+ "11/'fuschia'), or None when the layer carries no lclr block.")
540
593
  .def_property_readonly("info_keys", &layerInfoKeys,
541
594
  "List of the 4cc keys of every additional-layer-info block present.")
542
595
  .def("descriptor", &layerDescriptor,
@@ -614,6 +667,9 @@ PYBIND11_MODULE(psdparse, m) {
614
667
  .def_property_readonly("color_table", &psdColorTable,
615
668
  "Indexed-color palette as a dict (colors[], valid_count, "
616
669
  "transparency_index), or None for non-indexed PSDs.")
670
+ .def_property_readonly("global_layer_mask", &psdGlobalLayerMask,
671
+ "Global layer mask info as a dict (overlay_color_space, color, "
672
+ "opacity, kind), or None when the block is empty/absent.")
617
673
  .def_property_readonly("image_resource_ids", &imageResourceIds,
618
674
  "List of the integer IDs of every image resource present.")
619
675
  .def("image_resource", &imageResource, py::arg("id"),
@@ -70,6 +70,31 @@ def sample_mask_psd():
70
70
  return _find_sample("masktest.psd")
71
71
 
72
72
 
73
+ @pytest.fixture(scope="session")
74
+ def sample_maskparams_psd():
75
+ """A PSD whose layer mask carries a real/user mask section plus mask
76
+ parameters (density + feather) and an lclr color label (synthesized)."""
77
+ return _find_sample("maskparams.psd")
78
+
79
+
80
+ @pytest.fixture(scope="session")
81
+ def sample_lab_psd():
82
+ """A Lab-mode PSD with known swatches (synthesized)."""
83
+ return _find_sample("labsample.psd")
84
+
85
+
86
+ @pytest.fixture(scope="session")
87
+ def sample_gray_psd():
88
+ """A grayscale PSD (synthesized)."""
89
+ return _find_sample("graysample.psd")
90
+
91
+
92
+ @pytest.fixture(scope="session")
93
+ def sample_duo_psd():
94
+ """The grayscale PSD with its color mode flipped to Duotone (synthesized)."""
95
+ return _find_sample("duosample.psd")
96
+
97
+
73
98
  @pytest.fixture
74
99
  def psd_ui(sample_ui_psd):
75
100
  p = psdparse.PSDFile()
@@ -98,6 +123,34 @@ def psd_mask(sample_mask_psd):
98
123
  return p
99
124
 
100
125
 
126
+ @pytest.fixture
127
+ def psd_maskparams(sample_maskparams_psd):
128
+ p = psdparse.PSDFile()
129
+ assert p.load(str(sample_maskparams_psd))
130
+ return p
131
+
132
+
133
+ @pytest.fixture
134
+ def psd_lab(sample_lab_psd):
135
+ p = psdparse.PSDFile()
136
+ assert p.load(str(sample_lab_psd))
137
+ return p
138
+
139
+
140
+ @pytest.fixture
141
+ def psd_gray(sample_gray_psd):
142
+ p = psdparse.PSDFile()
143
+ assert p.load(str(sample_gray_psd))
144
+ return p
145
+
146
+
147
+ @pytest.fixture
148
+ def psd_duo(sample_duo_psd):
149
+ p = psdparse.PSDFile()
150
+ assert p.load(str(sample_duo_psd))
151
+ return p
152
+
153
+
101
154
  @pytest.fixture
102
155
  def psd_large(sample_large_psd):
103
156
  p = psdparse.PSDFile()
@@ -0,0 +1,49 @@
1
+ """Tests for the 0.6.0 color-mode additions in getMergedImage / getLayerImage:
2
+
3
+ - Lab (mode 9) -> sRGB via a standard D65 CIELAB conversion,
4
+ - Duotone (mode 8) rendered as grayscale (Adobe stores duotone data as gray).
5
+
6
+ labsample.psd was built from known RGB swatches [white, black, red, green]
7
+ converted to Lab. Neutral swatches (white/black) round-trip exactly regardless
8
+ of reference white; red is near-exact. Green diverges because the ICC Lab
9
+ encoding of a saturated primary is reference-white dependent, so it is not
10
+ asserted tightly here.
11
+ """
12
+ import psdparse
13
+
14
+
15
+ def _rgb_pixels(psd, n):
16
+ """First n pixels of the merged image as (R, G, B) tuples (from BGRA)."""
17
+ data = psd.merged_image()
18
+ return [(data[i * 4 + 2], data[i * 4 + 1], data[i * 4 + 0]) for i in range(n)]
19
+
20
+
21
+ def test_lab_mode_is_lab(psd_lab):
22
+ assert psd_lab.header.mode == psdparse.COLOR_MODE_LAB
23
+
24
+
25
+ def test_lab_neutrals_exact(psd_lab):
26
+ px = _rgb_pixels(psd_lab, 4)
27
+ # swatch 0 = white, swatch 1 = black -- reference-white independent.
28
+ assert px[0] == (255, 255, 255)
29
+ assert px[1] == (0, 0, 0)
30
+
31
+
32
+ def test_lab_red_near(psd_lab):
33
+ px = _rgb_pixels(psd_lab, 4)
34
+ r, g, b = px[2]
35
+ assert r > 240 and g < 15 and b < 15 # essentially pure red
36
+
37
+
38
+ def test_lab_not_blank(psd_lab):
39
+ # Regression: Lab used to render an all-zero buffer.
40
+ assert any(psd_lab.merged_image())
41
+
42
+
43
+ def test_duotone_renders_as_grayscale(psd_duo, psd_gray):
44
+ assert psd_duo.header.mode == psdparse.COLOR_MODE_DUOTONE
45
+ assert psd_gray.header.mode == psdparse.COLOR_MODE_GRAYSCALE
46
+ duo = psd_duo.merged_image()
47
+ gray = psd_gray.merged_image()
48
+ assert any(duo) # not blank
49
+ assert duo == gray # duotone data is grayscale
@@ -88,6 +88,14 @@ def test_descriptor_bad_key_raises(psd_group):
88
88
  psd_group.layers[0].descriptor("ab")
89
89
 
90
90
 
91
+ def test_descriptor_absent_key_none(psd_group):
92
+ # A well-formed but absent 4cc key returns None (no exception). The
93
+ # smart-object / vector keys now carry known default skips so callers can
94
+ # omit `skip`; here we just confirm the absent-key path is clean.
95
+ for key in ("SoLd", "vstk", "vscg", "vogk", "CgEd"):
96
+ assert psd_group.layers[0].descriptor(key) is None
97
+
98
+
91
99
  def test_info_keys_are_4cc(psd_group):
92
100
  for l in psd_group.layers:
93
101
  keys = l.info_keys
@@ -0,0 +1,61 @@
1
+ """Tests for the 0.6.0 mask/label extras:
2
+
3
+ - layer mask density/feather parameters and the (fixed) real-mask section,
4
+ - the 'lclr' layer-panel color label (layer.sheet_color),
5
+ - document-level global layer mask info.
6
+
7
+ The maskparams.psd fixture was authored with psd-tools so the expected values
8
+ (bbox, density=128, feather=2.5, real rect 0..16, lclr=5) are known exactly.
9
+ """
10
+ import psdparse
11
+
12
+
13
+ def test_mask_parameters(psd_maskparams):
14
+ m = psd_maskparams.layers[0].mask
15
+ assert m is not None
16
+ assert (m["top"], m["left"], m["bottom"], m["right"]) == (1, 2, 13, 14)
17
+ assert m["has_parameters"] is True
18
+ assert m["user_density"] == 128
19
+ assert abs(m["user_feather"] - 2.5) < 1e-9
20
+ # only user density/feather were authored; vector ones stay absent
21
+ assert m["vector_density"] is None
22
+ assert m["vector_feather"] is None
23
+
24
+
25
+ def test_mask_real_section(psd_maskparams):
26
+ # Regression: the real/user-mask section used to be decoded one byte off.
27
+ m = psd_maskparams.layers[0].mask
28
+ real = m["real"]
29
+ assert real is not None
30
+ assert (real["top"], real["left"], real["bottom"], real["right"]) == (0, 0, 16, 16)
31
+ assert real["background"] == 0
32
+
33
+
34
+ def test_plain_mask_has_no_parameters(psd_mask):
35
+ # masktest.psd carries a simple 20-byte mask: no params, no real section.
36
+ m = psd_mask.layers[0].mask
37
+ assert m is not None
38
+ assert m["has_parameters"] is False
39
+ assert m["user_density"] is None and m["user_feather"] is None
40
+ assert m["real"] is None
41
+
42
+
43
+ def test_sheet_color(psd_maskparams):
44
+ sc = psd_maskparams.layers[0].sheet_color
45
+ assert sc == {"index": 5, "name": "blue"}
46
+
47
+
48
+ def test_sheet_color_none_when_zero(psd_group):
49
+ # config.psd layers carry lclr = 0 (explicit "no color").
50
+ sc = psd_group.layers[0].sheet_color
51
+ assert sc is None or sc["index"] == 0
52
+
53
+
54
+ def test_global_layer_mask_shape(psd_group):
55
+ # Most PSDs have an empty global layer mask block -> None. When present it
56
+ # is a dict with the documented keys.
57
+ g = psd_group.global_layer_mask
58
+ if g is None:
59
+ return
60
+ assert set(g) >= {"overlay_color_space", "color", "opacity", "kind"}
61
+ assert len(g["color"]) == 4
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