psdparse 0.4.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.4.0 → psdparse-0.6.0}/PKG-INFO +1 -1
  2. {psdparse-0.4.0 → psdparse-0.6.0}/docs/PYTHON_API.md +54 -7
  3. {psdparse-0.4.0 → psdparse-0.6.0}/docs/ROADMAP.md +4 -3
  4. {psdparse-0.4.0 → psdparse-0.6.0}/docs/SUPPORT.md +27 -23
  5. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psddata.h +20 -8
  6. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdimage.cpp +111 -6
  7. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdparse.cpp +20 -5
  8. {psdparse-0.4.0 → psdparse-0.6.0}/pyproject.toml +1 -1
  9. {psdparse-0.4.0 → psdparse-0.6.0}/python/psdparse_module.cpp +153 -6
  10. {psdparse-0.4.0 → psdparse-0.6.0}/tests/conftest.py +53 -0
  11. psdparse-0.6.0/tests/test_colormodes.py +49 -0
  12. {psdparse-0.4.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.6.0/tests/test_resources.py +69 -0
  15. {psdparse-0.4.0 → psdparse-0.6.0}/.gitignore +0 -0
  16. {psdparse-0.4.0 → psdparse-0.6.0}/CMakeLists.txt +0 -0
  17. {psdparse-0.4.0 → psdparse-0.6.0}/CMakePresets.json +0 -0
  18. {psdparse-0.4.0 → psdparse-0.6.0}/LICENSE +0 -0
  19. {psdparse-0.4.0 → psdparse-0.6.0}/Makefile +0 -0
  20. {psdparse-0.4.0 → psdparse-0.6.0}/README.md +0 -0
  21. {psdparse-0.4.0 → psdparse-0.6.0}/docs/ARCHITECTURE.md +0 -0
  22. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/CMakeLists.txt +0 -0
  23. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/bmp.cpp +0 -0
  24. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psd_cli.cpp +0 -0
  25. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdbase.h +0 -0
  26. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psddesc.cpp +0 -0
  27. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psddesc.h +0 -0
  28. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdengine.cpp +0 -0
  29. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdengine.h +0 -0
  30. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdfile.cpp +0 -0
  31. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdfile.h +0 -0
  32. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdlayer.cpp +0 -0
  33. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdlayer.h +0 -0
  34. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdparse.h +0 -0
  35. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdresource.cpp +0 -0
  36. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdresource.h +0 -0
  37. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdwrite.cpp +0 -0
  38. {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdwrite.h +0 -0
  39. {psdparse-0.4.0 → psdparse-0.6.0}/python/CMakeLists.txt +0 -0
  40. {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_header.py +0 -0
  41. {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_images.py +0 -0
  42. {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_layers.py +0 -0
  43. {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_metadata.py +0 -0
  44. {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_save.py +0 -0
  45. {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_text.py +0 -0
  46. {psdparse-0.4.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.4.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,8 +233,37 @@ 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"}
237
+ ```
238
+
239
+ ### Image resources (raw)
240
+
241
+ Most image resources are exposed as their raw on-disk bytes; decoding (EXIF
242
+ tags, rendering the thumbnail, parsing the ICC profile) is left to the caller.
243
+
244
+ ```python
245
+ p.image_resource_ids # list[int] : IDs of every resource present
246
+ p.image_resource(id) # bytes|None : raw bytes of the resource with that ID
247
+ p.icc_profile # bytes|None : ICC profile (resource 1039)
248
+ p.exif # bytes|None : EXIF block (1058)
249
+ p.xmp # str|None : XMP packet (1060), UTF-8 XML
250
+ p.thumbnail # dict|None : {"format","width","height","bits","resource_id","data"}
227
251
  ```
228
252
 
253
+ - **`thumbnail`** — resource 1036 (RGB) or legacy 1033 (BGR). When
254
+ `format == "jpeg"`, `data` is JFIF JPEG bytes ready for `PIL.Image.open`:
255
+
256
+ ```python
257
+ import io
258
+ from PIL import Image
259
+ th = p.thumbnail
260
+ if th and th["format"] == "jpeg":
261
+ Image.open(io.BytesIO(th["data"])).save("thumb.png")
262
+ ```
263
+
264
+ - **`xmp`** decodes as UTF-8 `str`; if a file's packet is not valid UTF-8, read
265
+ the raw bytes with `p.image_resource(1060)` instead.
266
+
229
267
  - **`guides`** — grid spacing (in 1/32 px) and each guide's `location` (1/32 px
230
268
  from origin) and `direction` (`"vertical"` / `"horizontal"`).
231
269
  - **`slices`** — Photoshop slices (v6). Each slice has `id`, `name`, bbox
@@ -277,9 +315,12 @@ Notes:
277
315
  - `layer.effects` is `lfx2` (object-based, Photoshop 6+). The older binary
278
316
  `lrFX` block is **not** a descriptor and returns `None` via `descriptor()`.
279
317
  - `descriptor(key, skip)` is the generic escape hatch: `skip` is the number of
280
- version-prefix bytes before the descriptor (`-1` auto-detects for `lfx2` = 8
281
- and `SoCo`/`GdFl`/`PtFl` = 4, otherwise 0). Use `info_keys` to discover which
282
- 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.
283
324
  - Decoding is **lazy** — the descriptor is parsed from the block's raw bytes on
284
325
  each access, so cache the result if you read it repeatedly.
285
326
 
@@ -314,6 +355,12 @@ psdparse.LAYER_TYPE_NORMAL, LAYER_TYPE_HIDDEN, LAYER_TYPE_FOLDER,
314
355
  LAYER_TYPE_ADJUST, LAYER_TYPE_FILL
315
356
  ```
316
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
+
317
364
  ## Pixel format
318
365
 
319
366
  All `*_image()` methods return interleaved BGRA in little-endian byte order:
@@ -83,8 +83,9 @@ This is mostly a constructor that fills `Data` with a minimal-but-valid skeleton
83
83
  - **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.
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
+ - ✅ **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.
86
88
  - 16-bit (`Lr16`) and 32-bit-float (`Lr32`) layer data: currently captured in `layerAndMaskTrailing` for round-trip but not exposed as decoded pixels.
87
- - 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.
88
- - 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.
89
- - 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.
90
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
  ---
@@ -135,22 +135,26 @@ Photoshop の汎用ディスクリプタで格納されるブロックを dict
135
135
  | スライス (1050 v7/v8, descriptor) | ❌ | 未格納 |
136
136
  | レイヤーカンプ (1065) | ✅ | `PSDFile.layer_comps` (v0.3.0) |
137
137
  | インデックスカラーパレット (色/count/透明index) | ✅ | `PSDFile.color_table` (v0.3.0) |
138
- | ICC プロファイル (1039) | 📦 | 生バイト保持のみ・未公開 |
139
- | EXIF (1058/1059) | 📦 | 同上 |
140
- | XMP メタデータ (1060) | 📦 | 同上 |
141
- | サムネイル (1033/1036) | 📦 | 同上 |
142
- | バージョン情報 / アルファチャンネル名 / その他 | 📦 | 同上 |
143
- | Global layer mask info | 📦 | 構造体フィールドは未充填、生バイトのみ |
138
+ | ICC プロファイル (1039) | ✅ | `PSDFile.icc_profile` (生バイト, v0.5.0) |
139
+ | EXIF (1058) | ✅ | `PSDFile.exif` (生バイト, v0.5.0) |
140
+ | XMP メタデータ (1060) | ✅ | `PSDFile.xmp` (UTF-8 str, v0.5.0) |
141
+ | サムネイル (1036/1033) | ✅ | `PSDFile.thumbnail` (JPEG bytes + 寸法, v0.5.0) |
142
+ | 任意リソースの生バイト | ✅ | `PSDFile.image_resource(id)` / `image_resource_ids` (v0.5.0) |
143
+ | 上記の内容デコード (EXIF タグ解析等) | ❌ | 生バイトを返すのみ。解析は利用側 (Pillow 等) で |
144
+ | バージョン情報 / アルファチャンネル名 / その他 | 📦→✅ | `image_resource(id)` で生バイト取得可 |
145
+ | Global layer mask info | ✅ | `PSDFile.global_layer_mask` (overlay 色/opacity/kind, v0.6.0) |
144
146
 
145
147
  ---
146
148
 
147
149
  ## まとめ (一言で)
148
150
 
149
- - **得意**: レイヤ列挙 + 属性 + 階層、RGB/CMYK/Gray/Indexed のピクセル取得、
150
- テキストのラン単位スタイル、レイヤー効果/塗りの descriptor、主要な文書
151
- メタデータ (ガイド/スライス/カンプ)、そして **byte-identical なラウンドトリップ**。
152
- - **未対応/限定的**: Lab/Duotone/Multichannel ピクセル、調整レイヤの数値
153
- パラメータ、ベクタパス、スマートオブジェクト実体、効果込みの再合成、
154
- そして編集して保存。
155
- - 生バイトは全ブロックが保持されているので、📦 の項目は「公開するだけ」で
156
- 対応できるものが多い (ROADMAP 参照)。
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 とは彩度の高い色でわずかに差)。
159
+ - image resource の**中身の解釈** (EXIF タグ, サムネイル描画等) は行わず生バイトを
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.4.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,14 +384,94 @@ 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);
347
397
  }
348
398
 
399
+ // -------------------------------------------------------------------------
400
+ // Image resource raw-bytes access.
401
+ //
402
+ // Most image resources are kept as raw bytes internally (imageResourceList)
403
+ // but were never reachable from Python. These helpers expose them: a generic
404
+ // by-ID accessor plus typed shortcuts for the common ones (ICC / EXIF / XMP /
405
+ // thumbnail). Decoding (parsing EXIF tags, rendering the thumbnail) is left to
406
+ // the caller with e.g. Pillow.
407
+ // -------------------------------------------------------------------------
408
+
409
+ std::string resourceBytes(const psd::ImageResourceInfo &res) {
410
+ std::string buf((size_t)(res.size > 0 ? res.size : 0), '\0');
411
+ if (res.size > 0 && res.data) {
412
+ psd::IteratorBase *rd = res.data->clone();
413
+ rd->init(); // rewind to the start of this resource
414
+ rd->getData(&buf[0], res.size);
415
+ delete rd;
416
+ }
417
+ return buf;
418
+ }
419
+
420
+ const psd::ImageResourceInfo *findResource(const psd::PSDFile &self, int id) {
421
+ for (const auto &res : self.imageResourceList)
422
+ if (res.id == id) return &res;
423
+ return nullptr;
424
+ }
425
+
426
+ py::list imageResourceIds(psd::PSDFile &self) {
427
+ py::list out;
428
+ for (const auto &res : self.imageResourceList) out.append((int)res.id);
429
+ return out;
430
+ }
431
+
432
+ py::object imageResource(psd::PSDFile &self, int id) {
433
+ const psd::ImageResourceInfo *res = findResource(self, id);
434
+ return res ? py::object(py::bytes(resourceBytes(*res))) : py::none();
435
+ }
436
+
437
+ py::object iccProfile(psd::PSDFile &self) { return imageResource(self, 1039); }
438
+ py::object exifData(psd::PSDFile &self) { return imageResource(self, 1058); }
439
+
440
+ // XMP packet (resource 1060) is UTF-8 XML. Returned as str; use
441
+ // image_resource(1060) for the raw bytes if the packet is not valid UTF-8.
442
+ py::object xmpMetadata(psd::PSDFile &self) {
443
+ const psd::ImageResourceInfo *res = findResource(self, 1060);
444
+ if (!res) return py::none();
445
+ return py::str(resourceBytes(*res));
446
+ }
447
+
448
+ // Embedded thumbnail (resource 1036 = RGB / legacy 1033 = BGR). Header is 28
449
+ // bytes; for format==1 the payload is a JFIF JPEG. Returns a dict with the
450
+ // header fields and the raw payload, or None.
451
+ py::object thumbnail(psd::PSDFile &self) {
452
+ const psd::ImageResourceInfo *res = findResource(self, 1036);
453
+ if (!res) res = findResource(self, 1033);
454
+ if (!res) return py::none();
455
+ std::string raw = resourceBytes(*res);
456
+ if (raw.size() < 28) return py::none();
457
+ auto be32 = [&](size_t o) {
458
+ return (uint32_t)(((uint8_t)raw[o] << 24) | ((uint8_t)raw[o+1] << 16) |
459
+ ((uint8_t)raw[o+2] << 8) | (uint8_t)raw[o+3]);
460
+ };
461
+ auto be16 = [&](size_t o) {
462
+ return (uint16_t)(((uint8_t)raw[o] << 8) | (uint8_t)raw[o+1]);
463
+ };
464
+ py::dict d;
465
+ uint32_t fmt = be32(0);
466
+ d["format"] = (fmt == 1) ? "jpeg" : "raw";
467
+ d["width"] = be32(4);
468
+ d["height"] = be32(8);
469
+ d["bits"] = be16(24);
470
+ d["resource_id"] = (int)res->id; // 1036 = RGB order, 1033 = BGR order
471
+ d["data"] = py::bytes(raw.data() + 28, raw.size() - 28);
472
+ return std::move(d);
473
+ }
474
+
349
475
  py::bytes layerImage(psd::PSDFile &self, int index, const std::string &mode) {
350
476
  if (!self.isLoaded) throw std::runtime_error("PSD not loaded");
351
477
  if (index < 0 || index >= (int)self.layerList.size())
@@ -461,6 +587,9 @@ PYBIND11_MODULE(psdparse, m) {
461
587
  .def_property_readonly("fill", &layerFill,
462
588
  "Fill-layer content as {'type': 'solid'|'gradient'|'pattern', "
463
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.")
464
593
  .def_property_readonly("info_keys", &layerInfoKeys,
465
594
  "List of the 4cc keys of every additional-layer-info block present.")
466
595
  .def("descriptor", &layerDescriptor,
@@ -537,7 +666,25 @@ PYBIND11_MODULE(psdparse, m) {
537
666
  "(id, name, comment, record_*). Empty list when there are none.")
538
667
  .def_property_readonly("color_table", &psdColorTable,
539
668
  "Indexed-color palette as a dict (colors[], valid_count, "
540
- "transparency_index), or None for non-indexed PSDs.");
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.")
673
+ .def_property_readonly("image_resource_ids", &imageResourceIds,
674
+ "List of the integer IDs of every image resource present.")
675
+ .def("image_resource", &imageResource, py::arg("id"),
676
+ "Raw bytes of the image resource with the given integer ID, or None.")
677
+ .def_property_readonly("icc_profile", &iccProfile,
678
+ "ICC profile (resource 1039) as raw bytes, or None.")
679
+ .def_property_readonly("exif", &exifData,
680
+ "EXIF block (resource 1058) as raw bytes, or None.")
681
+ .def_property_readonly("xmp", &xmpMetadata,
682
+ "XMP metadata (resource 1060) as a UTF-8 str, or None. Use "
683
+ "image_resource(1060) for raw bytes if not valid UTF-8.")
684
+ .def_property_readonly("thumbnail", &thumbnail,
685
+ "Embedded thumbnail (resource 1036/1033) as a dict "
686
+ "(format, width, height, bits, resource_id, data), or None. "
687
+ "For format=='jpeg', data is JFIF JPEG bytes.");
541
688
 
542
689
  // Enum-like ints exposed as module attributes for convenience
543
690
  m.attr("LAYER_TYPE_NORMAL") = (int)psd::LAYER_TYPE_NORMAL;
@@ -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
@@ -0,0 +1,69 @@
1
+ """Tests for image-resource raw-bytes access (0.5.0):
2
+
3
+ PSDFile.image_resource_ids / image_resource(id) and the typed shortcuts
4
+ icc_profile / exif / xmp / thumbnail. EXIF and ICC bytes here were verified
5
+ byte-identical against psd-tools 1.17.
6
+ """
7
+ import psdparse
8
+ import pytest
9
+
10
+
11
+ def test_resource_ids_are_ints(psd_group):
12
+ ids = psd_group.image_resource_ids
13
+ assert isinstance(ids, list)
14
+ assert all(isinstance(i, int) for i in ids)
15
+ assert len(ids) > 0
16
+
17
+
18
+ def test_generic_matches_typed(psd_group):
19
+ # image_resource(1058) is the same bytes as the exif shortcut.
20
+ assert psd_group.image_resource(1058) == psd_group.exif
21
+
22
+
23
+ def test_absent_resource_is_none(psd_group):
24
+ # 9999 is not a real Photoshop resource id.
25
+ assert psd_group.image_resource(9999) is None
26
+
27
+
28
+ def test_exif_present(psd_group):
29
+ exif = psd_group.exif
30
+ if exif is None:
31
+ pytest.skip("sample has no EXIF resource")
32
+ assert isinstance(exif, bytes) and len(exif) > 0
33
+
34
+
35
+ def test_xmp_is_xml(psd_group):
36
+ xmp = psd_group.xmp
37
+ if xmp is None:
38
+ pytest.skip("sample has no XMP resource")
39
+ assert isinstance(xmp, str)
40
+ assert "xpacket" in xmp or "xmpmeta" in xmp
41
+
42
+
43
+ def test_icc_profile(psd_text):
44
+ # fontsample.psd carries an ICC profile.
45
+ icc = psd_text.icc_profile
46
+ if icc is None:
47
+ pytest.skip("sample has no ICC profile")
48
+ assert isinstance(icc, bytes) and len(icc) > 0
49
+
50
+
51
+ def test_thumbnail_header(psd_group):
52
+ th = psd_group.thumbnail
53
+ if th is None:
54
+ pytest.skip("sample has no thumbnail")
55
+ assert th["width"] > 0 and th["height"] > 0
56
+ assert th["format"] in ("jpeg", "raw")
57
+ assert isinstance(th["data"], bytes)
58
+ if th["format"] == "jpeg":
59
+ assert th["data"][:2] == b"\xff\xd8" # JFIF/JPEG SOI marker
60
+
61
+
62
+ def test_thumbnail_decodes(psd_group):
63
+ Image = pytest.importorskip("PIL.Image")
64
+ th = psd_group.thumbnail
65
+ if th is None or th["format"] != "jpeg":
66
+ pytest.skip("sample has no JPEG thumbnail")
67
+ import io
68
+ img = Image.open(io.BytesIO(th["data"]))
69
+ assert (img.width, img.height) == (th["width"], th["height"])
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