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.
- {psdparse-0.4.0 → psdparse-0.6.0}/PKG-INFO +1 -1
- {psdparse-0.4.0 → psdparse-0.6.0}/docs/PYTHON_API.md +54 -7
- {psdparse-0.4.0 → psdparse-0.6.0}/docs/ROADMAP.md +4 -3
- {psdparse-0.4.0 → psdparse-0.6.0}/docs/SUPPORT.md +27 -23
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psddata.h +20 -8
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdimage.cpp +111 -6
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdparse.cpp +20 -5
- {psdparse-0.4.0 → psdparse-0.6.0}/pyproject.toml +1 -1
- {psdparse-0.4.0 → psdparse-0.6.0}/python/psdparse_module.cpp +153 -6
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/conftest.py +53 -0
- psdparse-0.6.0/tests/test_colormodes.py +49 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_descriptors.py +8 -0
- psdparse-0.6.0/tests/test_mask_extras.py +61 -0
- psdparse-0.6.0/tests/test_resources.py +69 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/.gitignore +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/CMakeLists.txt +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/CMakePresets.json +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/LICENSE +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/Makefile +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/README.md +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/CMakeLists.txt +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/bmp.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdbase.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psddesc.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psddesc.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdengine.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdengine.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdfile.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdfile.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdlayer.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdlayer.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdparse.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdresource.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdwrite.cpp +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/psdparse/psdwrite.h +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/python/CMakeLists.txt +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_header.py +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_images.py +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_layers.py +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_metadata.py +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_save.py +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tests/test_text.py +0 -0
- {psdparse-0.4.0 → psdparse-0.6.0}/tools/psd_export.py +0 -0
|
@@ -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":
|
|
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
|
|
184
|
-
with `flags`, `background`, and the enclosing
|
|
185
|
-
**pixels** are unchanged
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
88
|
-
-
|
|
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.
|
|
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 | ❌ |
|
|
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 (
|
|
85
|
-
| density / feather |
|
|
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
|
|
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
|
|
140
|
-
| XMP メタデータ (1060) |
|
|
141
|
-
| サムネイル (1033
|
|
142
|
-
|
|
|
143
|
-
|
|
|
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
|
-
- **得意**: レイヤ列挙 + 属性 +
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
281
|
-
int
|
|
282
|
-
int
|
|
283
|
-
int
|
|
284
|
-
int
|
|
285
|
-
int
|
|
286
|
-
int
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
97
|
-
|
|
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;
|
|
342
|
-
case 'SoCo': case 'GdFl': case 'PtFl': skip = 4;
|
|
343
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|