psdparse 0.3.0__tar.gz → 0.5.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 (44) hide show
  1. {psdparse-0.3.0 → psdparse-0.5.0}/PKG-INFO +6 -1
  2. {psdparse-0.3.0 → psdparse-0.5.0}/README.md +5 -0
  3. {psdparse-0.3.0 → psdparse-0.5.0}/docs/PYTHON_API.md +80 -0
  4. {psdparse-0.3.0 → psdparse-0.5.0}/docs/ROADMAP.md +6 -1
  5. psdparse-0.5.0/docs/SUPPORT.md +160 -0
  6. {psdparse-0.3.0 → psdparse-0.5.0}/pyproject.toml +1 -1
  7. {psdparse-0.3.0 → psdparse-0.5.0}/python/psdparse_module.cpp +239 -1
  8. psdparse-0.5.0/tests/test_descriptors.py +103 -0
  9. psdparse-0.5.0/tests/test_resources.py +69 -0
  10. {psdparse-0.3.0 → psdparse-0.5.0}/.gitignore +0 -0
  11. {psdparse-0.3.0 → psdparse-0.5.0}/CMakeLists.txt +0 -0
  12. {psdparse-0.3.0 → psdparse-0.5.0}/CMakePresets.json +0 -0
  13. {psdparse-0.3.0 → psdparse-0.5.0}/LICENSE +0 -0
  14. {psdparse-0.3.0 → psdparse-0.5.0}/Makefile +0 -0
  15. {psdparse-0.3.0 → psdparse-0.5.0}/docs/ARCHITECTURE.md +0 -0
  16. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/CMakeLists.txt +0 -0
  17. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/bmp.cpp +0 -0
  18. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psd_cli.cpp +0 -0
  19. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdbase.h +0 -0
  20. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psddata.h +0 -0
  21. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psddesc.cpp +0 -0
  22. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psddesc.h +0 -0
  23. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdengine.cpp +0 -0
  24. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdengine.h +0 -0
  25. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdfile.cpp +0 -0
  26. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdfile.h +0 -0
  27. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdimage.cpp +0 -0
  28. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdlayer.cpp +0 -0
  29. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdlayer.h +0 -0
  30. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdparse.cpp +0 -0
  31. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdparse.h +0 -0
  32. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdresource.cpp +0 -0
  33. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdresource.h +0 -0
  34. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdwrite.cpp +0 -0
  35. {psdparse-0.3.0 → psdparse-0.5.0}/psdparse/psdwrite.h +0 -0
  36. {psdparse-0.3.0 → psdparse-0.5.0}/python/CMakeLists.txt +0 -0
  37. {psdparse-0.3.0 → psdparse-0.5.0}/tests/conftest.py +0 -0
  38. {psdparse-0.3.0 → psdparse-0.5.0}/tests/test_header.py +0 -0
  39. {psdparse-0.3.0 → psdparse-0.5.0}/tests/test_images.py +0 -0
  40. {psdparse-0.3.0 → psdparse-0.5.0}/tests/test_layers.py +0 -0
  41. {psdparse-0.3.0 → psdparse-0.5.0}/tests/test_metadata.py +0 -0
  42. {psdparse-0.3.0 → psdparse-0.5.0}/tests/test_save.py +0 -0
  43. {psdparse-0.3.0 → psdparse-0.5.0}/tests/test_text.py +0 -0
  44. {psdparse-0.3.0 → psdparse-0.5.0}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.3.0
3
+ Version: 0.5.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>
@@ -133,6 +133,11 @@ p.save(r"out.psd") # byte-identical round-trip
133
133
 
134
134
  Full API reference: [docs/PYTHON_API.md](docs/PYTHON_API.md).
135
135
 
136
+ ## PSD feature coverage
137
+
138
+ What psdparse can and cannot read, at a glance:
139
+ [docs/SUPPORT.md](docs/SUPPORT.md) (対応状況マトリクス).
140
+
136
141
  ## Tests
137
142
 
138
143
  Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need sample PSDs placed at the repo root or `tests/data/` (not committed — listed in `.gitignore`); without them the tests skip rather than fail.
@@ -92,6 +92,11 @@ p.save(r"out.psd") # byte-identical round-trip
92
92
 
93
93
  Full API reference: [docs/PYTHON_API.md](docs/PYTHON_API.md).
94
94
 
95
+ ## PSD feature coverage
96
+
97
+ What psdparse can and cannot read, at a glance:
98
+ [docs/SUPPORT.md](docs/SUPPORT.md) (対応状況マトリクス).
99
+
95
100
  ## Tests
96
101
 
97
102
  Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need sample PSDs placed at the repo root or `tests/data/` (not committed — listed in `.gitignore`); without them the tests skip rather than fail.
@@ -105,6 +105,9 @@ Read-only view of one layer.
105
105
  | `text` | `dict` \| `None` | text-layer content & style (`None` for non-text layers) — see below |
106
106
  | `mask` | `dict` \| `None` | layer mask geometry & flags (`None` when the layer has no mask) — see below |
107
107
  | `blending_ranges` | `dict` \| `None` | "Blend If" ranges (`None` when absent) — see below |
108
+ | `effects` | `dict` \| `None` | layer effects (`lfx2`) as a descriptor dict — see [Descriptor blocks](#descriptor-blocks) |
109
+ | `fill` | `dict` \| `None` | fill-layer content (solid/gradient/pattern) — see [Descriptor blocks](#descriptor-blocks) |
110
+ | `info_keys` | `list[str]` | 4cc keys of every additional-layer-info block on this layer |
108
111
  | `visible` | `bool` | flag bit 1 inverted |
109
112
  | `transparency_protected` | `bool` | flag bit 0 |
110
113
  | `obsolete` | `bool` | flag bit 2 |
@@ -223,6 +226,34 @@ p.layer_comps # list[dict]: [{"id","name","comment","record_visibility","recor
223
226
  p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparency_index"} for indexed-color PSDs
224
227
  ```
225
228
 
229
+ ### Image resources (raw)
230
+
231
+ Most image resources are exposed as their raw on-disk bytes; decoding (EXIF
232
+ tags, rendering the thumbnail, parsing the ICC profile) is left to the caller.
233
+
234
+ ```python
235
+ p.image_resource_ids # list[int] : IDs of every resource present
236
+ p.image_resource(id) # bytes|None : raw bytes of the resource with that ID
237
+ p.icc_profile # bytes|None : ICC profile (resource 1039)
238
+ p.exif # bytes|None : EXIF block (1058)
239
+ p.xmp # str|None : XMP packet (1060), UTF-8 XML
240
+ p.thumbnail # dict|None : {"format","width","height","bits","resource_id","data"}
241
+ ```
242
+
243
+ - **`thumbnail`** — resource 1036 (RGB) or legacy 1033 (BGR). When
244
+ `format == "jpeg"`, `data` is JFIF JPEG bytes ready for `PIL.Image.open`:
245
+
246
+ ```python
247
+ import io
248
+ from PIL import Image
249
+ th = p.thumbnail
250
+ if th and th["format"] == "jpeg":
251
+ Image.open(io.BytesIO(th["data"])).save("thumb.png")
252
+ ```
253
+
254
+ - **`xmp`** decodes as UTF-8 `str`; if a file's packet is not valid UTF-8, read
255
+ the raw bytes with `p.image_resource(1060)` instead.
256
+
226
257
  - **`guides`** — grid spacing (in 1/32 px) and each guide's `location` (1/32 px
227
258
  from origin) and `direction` (`"vertical"` / `"horizontal"`).
228
259
  - **`slices`** — Photoshop slices (v6). Each slice has `id`, `name`, bbox
@@ -231,6 +262,55 @@ p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparenc
231
262
  - **`color_table`** — only present for `COLOR_MODE_INDEXED` PSDs; `colors` is the
232
263
  palette and `transparency_index` is `-1` when there is no transparent entry.
233
264
 
265
+ ## Descriptor blocks
266
+
267
+ Photoshop stores layer **effects**, **fill-layer** content and many other
268
+ tagged blocks as its generic *descriptor* tree (the same OSType structure used
269
+ throughout PSD). These accessors decode a block into nested Python
270
+ dicts/lists so they can be read without a decoder per feature.
271
+
272
+ ```python
273
+ layer.effects # dict|None : object-based effects ('lfx2')
274
+ layer.fill # dict|None : {"type": "solid"|"gradient"|"pattern", "data": {...}}
275
+ layer.info_keys # list[str] : every additional-info 4cc key present on the layer
276
+ layer.descriptor(key, skip=-1) # dict|None : parse an arbitrary key as a descriptor
277
+ ```
278
+
279
+ **Value mapping** (descriptor item → Python):
280
+
281
+ | Descriptor type | Python |
282
+ |---|---|
283
+ | Integer / Double | `int` / `float` |
284
+ | Boolean | `bool` |
285
+ | String / Alias / Class | `str` |
286
+ | UnitFloat | `{"value": float, "unit": str}` (unit: `percent`, `angle`, `pixels`, …) |
287
+ | Enumerated | `{"type": str, "value": str}` |
288
+ | Descriptor (nested) | `dict` (keys are raw 4cc, **may end in a space**, e.g. `"Scl "`) |
289
+ | List | `list` |
290
+ | RawData (`tdta`) | `bytes` |
291
+ | Reference / unknown | `None` |
292
+
293
+ ```python
294
+ fx = layer.effects
295
+ if fx:
296
+ print("effects on:", fx.get("masterFXSwitch"))
297
+ po = fx.get("patternFill") # a nested descriptor dict
298
+ if po:
299
+ print("pattern overlay opacity:", po["Opct"]["value"]) # -> 100.0
300
+ ```
301
+
302
+ Notes:
303
+ - Keys are the **raw 4cc** as stored (trailing spaces preserved) — index with
304
+ the exact string, e.g. `fx["Scl "]`, `color["Rd "]`.
305
+ - `layer.effects` is `lfx2` (object-based, Photoshop 6+). The older binary
306
+ `lrFX` block is **not** a descriptor and returns `None` via `descriptor()`.
307
+ - `descriptor(key, skip)` is the generic escape hatch: `skip` is the number of
308
+ version-prefix bytes before the descriptor (`-1` auto-detects for `lfx2` = 8
309
+ and `SoCo`/`GdFl`/`PtFl` = 4, otherwise 0). Use `info_keys` to discover which
310
+ blocks a layer carries.
311
+ - Decoding is **lazy** — the descriptor is parsed from the block's raw bytes on
312
+ each access, so cache the result if you read it repeatedly.
313
+
234
314
  ## Enums
235
315
 
236
316
  ```python
@@ -1,5 +1,8 @@
1
1
  # psdparse Roadmap
2
2
 
3
+ For a feature-by-feature account of what is and isn't supported today, see
4
+ [SUPPORT.md](SUPPORT.md). This file tracks planned work.
5
+
3
6
  ## Current state (2026-06-12)
4
7
 
5
8
  - ✅ Pure C++17 parser (no Boost)
@@ -78,7 +81,9 @@ This is mostly a constructor that fills `Data` with a minimal-but-valid skeleton
78
81
  - **Warp text** — lives in the `TySh` *warp* descriptor (currently skipped, not in EngineData); needs samples with each warp style + non-zero bend/distortion.
79
82
  - **Area (paragraph) vs point text / text box bounds** and **text-on-path** — need samples.
80
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.
81
- - ✅ **Tier-1 reference metadata exposed to Python.** *Done 2026-08-02 (0.3.0).* Data the C++ core already parsed but Python couldn't reach is now bound: `layer.parent_index` (folder hierarchy), `layer.mask` (bbox / flags / real-mask dict), `layer.blending_ranges`, and document-level `PSDFile.guides` / `.slices` / `.layer_comps` / `.color_table`. All dict-shaped, matching the existing `layer.text` style. Validated against `config.psd` / `system.psd` (hierarchy, blend ranges, guides, slices) and a psd-tools-synthesized `masktest.psd` (mask bbox/flags cross-checked) — see `tests/test_metadata.py`. **Next (Tier 2, highest leverage):** a generic `Descriptor → dict` bridge that routes the currently-skipped `lfx2` (layer effects), `SoCo`/`GdFl`/`PtFl` (fill layers) and `SoLd` (smart-object transform) raw bytes through the existing complete descriptor parser — unlocks effects/fill/smart-object metadata cheaply without per-feature decoders.
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
+ - ✅ **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`.
82
87
  - 16-bit (`Lr16`) and 32-bit-float (`Lr32`) layer data: currently captured in `layerAndMaskTrailing` for round-trip but not exposed as decoded pixels.
83
88
  - Layer mask: density / feather decoding (the `has_parameters` flag is exposed but the `MaskParameters` values are not read yet), plus re-emission for masks > 20 bytes.
84
89
  - Image resources: most are currently passed through as raw bytes. Higher-level accessors for ICC profile, EXIF, thumbnail, version info, etc. would be nice for tools.
@@ -0,0 +1,160 @@
1
+ # psdparse PSD 対応状況 (Support Matrix)
2
+
3
+ psdparse が PSD のどの機能を、どの水準で扱えるかの一覧です。psdparse は
4
+ **参照 (読み取り) + ラウンドトリップ保存** に重点を置いた実装で、編集保存や
5
+ 高度な再合成は対象外です。
6
+
7
+ 対応状況の凡例:
8
+
9
+ | 記号 | 意味 |
10
+ |:---:|---|
11
+ | ✅ | 対応済み — 構造化して取得できる |
12
+ | 🟡 | 部分対応 — 一部の値のみ / 生 descriptor 経由 |
13
+ | 📦 | 生バイトのみ保持 (ラウンドトリップ用。Python へは未公開) |
14
+ | ❌ | 未対応 — パースしていない |
15
+
16
+ 最終更新: 2026-08-02 (v0.4.0)。詳細な今後の計画は [ROADMAP.md](ROADMAP.md)、
17
+ Python API の使い方は [PYTHON_API.md](PYTHON_API.md) を参照。
18
+
19
+ ---
20
+
21
+ ## ファイル全体
22
+
23
+ | 機能 | 状況 | 備考 |
24
+ |---|:---:|---|
25
+ | ヘッダ (幅/高さ/チャンネル/深度/モード/版) | ✅ | `PSDFile.header` |
26
+ | 解像度 (dpi, image resource 1005) | ✅ | `header.hres` / `header.vres` |
27
+ | PSB (large document, version 2) | ❌ | `version` は読めるが、PSB 特有の 8 byte 長フィールドの分岐が無く未対応 |
28
+ | ラウンドトリップ保存 (byte-identical) | ✅ | `load(a) -> save(b)` が完全一致 |
29
+ | 編集して保存 (レイヤ追加/削除/差し替え) | ❌ | ROADMAP Phase 4b–4e。RLE エンコーダ未実装 |
30
+
31
+ ## 圧縮 / ビット深度
32
+
33
+ | 項目 | 状況 | 備考 |
34
+ |---|:---:|---|
35
+ | Raw / RLE(PackBits) / ZIP(±prediction) | ✅ | 展開対応 |
36
+ | ビット深度 1 / 8 / 16 / 32 | ✅ | それ以外は非対応 |
37
+
38
+ ## カラーモード (ピクセル展開: `merged_image` / `layer_image`)
39
+
40
+ | モード | 状況 | 備考 |
41
+ |---|:---:|---|
42
+ | Bitmap (1bit) | ✅ | |
43
+ | Grayscale (8/16/32) | ✅ | |
44
+ | RGB (8/16/32) | ✅ | |
45
+ | Indexed (8bit) | ✅ | パレットは `PSDFile.color_table` |
46
+ | CMYK (8/16/32) | ✅ | RGB へ変換して出力 |
47
+ | Multichannel | ❌ | `getLayerImage` 未対応 |
48
+ | Duotone | ❌ | 同上 |
49
+ | Lab | ❌ | 同上 |
50
+
51
+ 出力は常に **BGRA インターリーブ** (4 byte/px)。ICC を用いた色変換は行いません。
52
+
53
+ ## 画像取得
54
+
55
+ | 機能 | 状況 | 備考 |
56
+ |---|:---:|---|
57
+ | 合成画像 (merged/composite) | ✅ | `merged_image()` — PSD 保存済みの合成を返す |
58
+ | レイヤ画像 (mask 込み/無し/mask のみ) | ✅ | `layer_image(i, "masked"/"image"/"mask")` |
59
+ | 効果・調整を適用した再合成 (`composite()` 相当) | ❌ | 保存済み合成の取得のみ。再描画はしない |
60
+
61
+ ---
62
+
63
+ ## レイヤ属性
64
+
65
+ | 属性 | 状況 | API |
66
+ |---|:---:|---|
67
+ | 位置/サイズ (bbox, width, height) | ✅ | `top/left/bottom/right`, `width`, `height` |
68
+ | ブレンドモード | ✅ | `blend_mode` (enum), `blend_mode_key` |
69
+ | 不透明度 / 塗り不透明度 | ✅ | `opacity`, `fill_opacity` |
70
+ | クリッピング | ✅ | `clipping` |
71
+ | 可視/ロック等フラグ | ✅ | `visible`, `transparency_protected`, `obsolete`, `pixel_data_irrelevant` |
72
+ | レイヤ名 (raw / Unicode) | ✅ | `name`, `name_unicode` |
73
+ | レイヤ ID | ✅ | `layer_id` |
74
+ | レイヤ種別 | ✅ | `layer_type` (NORMAL/HIDDEN/FOLDER/ADJUST/FILL/TEXT) |
75
+ | **フォルダ階層 (親子)** | ✅ | `parent_index` (v0.3.0) |
76
+ | チャンネル構成 (id/length) | ✅ | `channels[]` |
77
+ | シートカラー (レイヤパネルの色ラベル, `lclr`) | ❌ | 未デコード |
78
+
79
+ ## レイヤマスク / ブレンド範囲
80
+
81
+ | 機能 | 状況 | API |
82
+ |---|:---:|---|
83
+ | マスク矩形/フラグ/既定色 | ✅ | `layer.mask` (v0.3.0) |
84
+ | real/user mask (>20byte) | ✅ | `layer.mask["real"]` |
85
+ | density / feather | 🟡 | `has_parameters` フラグのみ公開。値 (MaskParameters) は未デコード |
86
+ | ベクタマスク / パス (`vmsk`/`vsms`) | ❌ | 未対応 |
87
+ | ブレンディングレンジ ("Blend If") | ✅ | `layer.blending_ranges` (raw 32bit packed) |
88
+
89
+ ## テキストレイヤ
90
+
91
+ | 機能 | 状況 | 備考 |
92
+ |---|:---:|---|
93
+ | 本文 / 縦横 / 変換行列 | ✅ | `layer.text` (`text`/`orientation`/`transform`) |
94
+ | ラン単位スタイル (font/size/color/tracking/kerning) | ✅ | `text["runs"]` |
95
+ | 段落別の行揃え | ✅ | `text["paragraphs"]` (v0.2.2) |
96
+ | ワープ (warp) | ❌ | TySh warp descriptor 未処理 |
97
+ | 非 RGB の FillColor | ❌ | `/Type 1` (RGB) のみ |
98
+ | leading / 疑似ボールド / 下線 等 | 🟡 | EngineData にキーはあるが既定値サンプルのみで未検証 |
99
+
100
+ ## Descriptor ブロック (v0.4.0)
101
+
102
+ Photoshop の汎用ディスクリプタで格納されるブロックを dict 化して取得。
103
+
104
+ | 機能 | 状況 | API |
105
+ |---|:---:|---|
106
+ | レイヤー効果 (`lfx2`, object-based) | ✅ | `layer.effects` (nested descriptor dict) |
107
+ | 旧レイヤー効果 (`lrFX`, binary) | ❌ | descriptor でないため未対応 |
108
+ | 塗りつぶしレイヤ (`SoCo`/`GdFl`/`PtFl`) | ✅ | `layer.fill` (`{type, data}`) |
109
+ | 任意キーの descriptor 取得 | ✅ | `layer.descriptor(key, skip)` / `layer.info_keys` |
110
+
111
+ `effects` / `fill` は **生 descriptor を辞書化したもの** で、効果ごとの型付き
112
+ アクセサ (drop shadow の angle/distance を名前で、等) はまだありません。値の
113
+ マッピング規則は [PYTHON_API.md](PYTHON_API.md#descriptor-blocks) を参照。
114
+
115
+ ## 調整レイヤ / 塗りつぶし / スマートオブジェクト / シェイプ
116
+
117
+ | 機能 | 状況 | 備考 |
118
+ |---|:---:|---|
119
+ | 調整レイヤの種別判定 | ✅ | `layer_type == ADJUST` |
120
+ | 調整レイヤのパラメータ (levels/curves 等, binary) | ❌ | 未デコード。descriptor 形式のもの (`CgEd` 等) は `descriptor()` で取得可 |
121
+ | スマートオブジェクト変換 (`SoLd`/`PlLd`) | 🟡 | `descriptor()` で試行可 (未検証、既定 skip 要調整) |
122
+ | スマートオブジェクト埋め込みデータ抽出 (`lnkD`) | ❌ | 未対応 |
123
+ | ベクタストローク/シェイプ (`vstk`/`vscg`) | 🟡 | `descriptor()` 経由で取得可 (未検証) |
124
+ | ライブシェイプ情報 (origination) | ❌ | 未対応 |
125
+
126
+ ---
127
+
128
+ ## Image Resources / 文書メタデータ
129
+
130
+ | リソース | 状況 | API |
131
+ |---|:---:|---|
132
+ | 解像度 (1005) | ✅ | `header.hres/vres` |
133
+ | グリッド & ガイド (1032) | ✅ | `PSDFile.guides` (v0.3.0) |
134
+ | スライス (1050 v6) | ✅ | `PSDFile.slices` (v0.3.0) |
135
+ | スライス (1050 v7/v8, descriptor) | ❌ | 未格納 |
136
+ | レイヤーカンプ (1065) | ✅ | `PSDFile.layer_comps` (v0.3.0) |
137
+ | インデックスカラーパレット (色/count/透明index) | ✅ | `PSDFile.color_table` (v0.3.0) |
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 | 📦 | 構造体フィールドは未充填、生バイトのみ |
146
+
147
+ ---
148
+
149
+ ## まとめ (一言で)
150
+
151
+ - **得意**: レイヤ列挙 + 属性 + 階層、RGB/CMYK/Gray/Indexed のピクセル取得、
152
+ テキストのラン単位スタイル、レイヤー効果/塗りの descriptor、文書メタデータ
153
+ (ガイド/スライス/カンプ) と image resource の生バイト (ICC/EXIF/XMP/サムネイル)、
154
+ そして **byte-identical なラウンドトリップ**。
155
+ - **未対応/限定的**: Lab/Duotone/Multichannel ピクセル、調整レイヤの数値
156
+ パラメータ、ベクタパス、スマートオブジェクト実体、効果込みの再合成、
157
+ そして編集して保存。
158
+ - image resource の**中身の解釈** (EXIF タグ, サムネイル描画等) は行わず生バイトを
159
+ 返すのみ。残る 📦 (global layer mask info の構造化) は「公開するだけ」で対応可
160
+ (ROADMAP 参照)。
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "psdparse"
7
- version = "0.3.0"
7
+ version = "0.5.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"
@@ -9,6 +9,7 @@
9
9
 
10
10
  #include "psdfile.h"
11
11
  #include "psdparse.h"
12
+ #include "psddesc.h"
12
13
 
13
14
  #include <fstream>
14
15
  #include <memory>
@@ -212,6 +213,215 @@ py::object psdColorTable(psd::PSDFile &self) {
212
213
  return std::move(d);
213
214
  }
214
215
 
216
+ // -------------------------------------------------------------------------
217
+ // Generic Descriptor -> Python bridge (Tier 2).
218
+ //
219
+ // Photoshop stores layer effects (lfx2), fill layers (SoCo/GdFl/PtFl) and
220
+ // several other tagged blocks as its generic OSType "descriptor" tree. The
221
+ // C++ core already has a complete descriptor parser (psddesc.*); these helpers
222
+ // convert a parsed Descriptor into nested Python dicts/lists so the previously
223
+ // skipped blocks become readable without per-feature decoders.
224
+ // -------------------------------------------------------------------------
225
+
226
+ const char *descUnitName(psd::DescriptorUnit u) {
227
+ switch (u) {
228
+ case psd::UNIT_POINTS: return "points";
229
+ case psd::UNIT_MILLIMETERS: return "millimeters";
230
+ case psd::UNIT_ANGLE: return "angle";
231
+ case psd::UNIT_DENSITY: return "density";
232
+ case psd::UNIT_DISTANCE: return "distance";
233
+ case psd::UNIT_NONE: return "none";
234
+ case psd::UNIT_PERCENT: return "percent";
235
+ case psd::UNIT_PIXELS: return "pixels";
236
+ default: return "unknown";
237
+ }
238
+ }
239
+
240
+ py::dict descToPy(psd::Descriptor *d);
241
+
242
+ // Dispatch a single descriptor item to a Python value. Uses dynamic_cast
243
+ // rather than the `type` field because DescriptorReference and
244
+ // DescriptorRawData share the same type tag ('tdta').
245
+ py::object descItemToPy(psd::DescriptorItem *it) {
246
+ if (!it) return py::none();
247
+ if (auto *x = dynamic_cast<psd::DescriptorInteger*>(it)) return py::cast(x->val);
248
+ if (auto *x = dynamic_cast<psd::DescriptorDouble*>(it)) return py::cast(x->val);
249
+ if (auto *x = dynamic_cast<psd::DescriptorBoolean*>(it)) return py::cast(x->val);
250
+ if (auto *x = dynamic_cast<psd::DescriptorString*>(it)) return py::cast(x->val); // u16str -> str
251
+ if (auto *x = dynamic_cast<psd::DescriptorUnitFloat*>(it)) {
252
+ py::dict u; u["value"] = x->val; u["unit"] = descUnitName(x->unit);
253
+ return std::move(u);
254
+ }
255
+ if (auto *x = dynamic_cast<psd::DescriptorEnumerated*>(it)) {
256
+ py::dict e; e["type"] = x->typeId; e["value"] = x->enumId;
257
+ return std::move(e);
258
+ }
259
+ if (auto *x = dynamic_cast<psd::DescriptorList*>(it)) {
260
+ py::list out;
261
+ for (auto *item : x->items) out.append(descItemToPy(item));
262
+ return std::move(out);
263
+ }
264
+ if (auto *x = dynamic_cast<psd::Descriptor*>(it)) return descToPy(x);
265
+ if (auto *x = dynamic_cast<psd::DescriptorRawData*>(it)) return py::bytes(x->bytes);
266
+ if (auto *x = dynamic_cast<psd::DescriptorClass*>(it)) return py::cast(x->classId);
267
+ if (auto *x = dynamic_cast<psd::DescriptorAlias*>(it)) return py::cast(x->alias);
268
+ // DescriptorReference and anything unrecognized -> None.
269
+ return py::none();
270
+ }
271
+
272
+ py::dict descToPy(psd::Descriptor *d) {
273
+ py::dict out;
274
+ for (const auto &kv : d->itemMap) // keys are raw 4cc (may end in space)
275
+ out[py::str(kv.first)] = descItemToPy(kv.second);
276
+ return out;
277
+ }
278
+
279
+ // Locate an additional-layer-info entry by 4cc key, parse its bytes as a
280
+ // descriptor (after skipping `skip` version-prefix bytes) and return a dict,
281
+ // or None when the key is absent / unparseable.
282
+ py::object keyDescriptor(const psd::LayerInfo &l, int key, int skip) {
283
+ for (const auto &a : l.extraData.additionalLayers) {
284
+ if (a.key != key || !a.data) continue;
285
+ psd::IteratorBase *rd = a.data->clone();
286
+ rd->init(); // rewind to the start of this key's data
287
+ if (skip > 0) rd->advance(skip);
288
+ psd::Descriptor desc;
289
+ desc.load(rd); // partial parse still leaves valid items
290
+ delete rd;
291
+ if (desc.itemMap.empty()) return py::none();
292
+ return descToPy(&desc);
293
+ }
294
+ return py::none();
295
+ }
296
+
297
+ // Object-based layer effects ('lfx2'): objVer(4) + descVer(4) then descriptor.
298
+ py::object layerEffects(const psd::LayerInfo &l) {
299
+ return keyDescriptor(l, 'lfx2', 8);
300
+ }
301
+
302
+ // Fill-layer content ('SoCo' solid / 'GdFl' gradient / 'PtFl' pattern):
303
+ // version(4) then descriptor. Returns {"type": ..., "data": {...}} or None.
304
+ py::object layerFill(const psd::LayerInfo &l) {
305
+ const struct { int key; const char *type; } tbl[] = {
306
+ {'SoCo', "solid"}, {'GdFl', "gradient"}, {'PtFl', "pattern"},
307
+ };
308
+ for (const auto &e : tbl) {
309
+ py::object d = keyDescriptor(l, e.key, 4);
310
+ if (!d.is_none()) {
311
+ py::dict out;
312
+ out["type"] = e.type;
313
+ out["data"] = d;
314
+ return std::move(out);
315
+ }
316
+ }
317
+ return py::none();
318
+ }
319
+
320
+ // List the 4cc keys of all additional-layer-info blocks present on a layer.
321
+ py::list layerInfoKeys(const psd::LayerInfo &l) {
322
+ py::list out;
323
+ for (const auto &a : l.extraData.additionalLayers) {
324
+ char s[4] = { (char)((a.key >> 24) & 0xff), (char)((a.key >> 16) & 0xff),
325
+ (char)((a.key >> 8) & 0xff), (char)(a.key & 0xff) };
326
+ out.append(py::str(s, 4));
327
+ }
328
+ return out;
329
+ }
330
+
331
+ // Generic escape hatch: parse an arbitrary additional-info key as a descriptor.
332
+ // `skip` defaults (-1) to the known version-prefix length for well-known keys,
333
+ // or 0 otherwise.
334
+ py::object layerDescriptor(const psd::LayerInfo &l, const std::string &keyStr, int skip) {
335
+ if (keyStr.size() != 4)
336
+ throw std::invalid_argument("key must be a 4-character string");
337
+ int key = ((int)(uint8_t)keyStr[0] << 24) | ((int)(uint8_t)keyStr[1] << 16) |
338
+ ((int)(uint8_t)keyStr[2] << 8) | (int)(uint8_t)keyStr[3];
339
+ if (skip < 0) {
340
+ switch (key) {
341
+ case 'lfx2': skip = 8; break;
342
+ case 'SoCo': case 'GdFl': case 'PtFl': skip = 4; break;
343
+ default: skip = 0; break;
344
+ }
345
+ }
346
+ return keyDescriptor(l, key, skip);
347
+ }
348
+
349
+ // -------------------------------------------------------------------------
350
+ // Image resource raw-bytes access.
351
+ //
352
+ // Most image resources are kept as raw bytes internally (imageResourceList)
353
+ // but were never reachable from Python. These helpers expose them: a generic
354
+ // by-ID accessor plus typed shortcuts for the common ones (ICC / EXIF / XMP /
355
+ // thumbnail). Decoding (parsing EXIF tags, rendering the thumbnail) is left to
356
+ // the caller with e.g. Pillow.
357
+ // -------------------------------------------------------------------------
358
+
359
+ std::string resourceBytes(const psd::ImageResourceInfo &res) {
360
+ std::string buf((size_t)(res.size > 0 ? res.size : 0), '\0');
361
+ if (res.size > 0 && res.data) {
362
+ psd::IteratorBase *rd = res.data->clone();
363
+ rd->init(); // rewind to the start of this resource
364
+ rd->getData(&buf[0], res.size);
365
+ delete rd;
366
+ }
367
+ return buf;
368
+ }
369
+
370
+ const psd::ImageResourceInfo *findResource(const psd::PSDFile &self, int id) {
371
+ for (const auto &res : self.imageResourceList)
372
+ if (res.id == id) return &res;
373
+ return nullptr;
374
+ }
375
+
376
+ py::list imageResourceIds(psd::PSDFile &self) {
377
+ py::list out;
378
+ for (const auto &res : self.imageResourceList) out.append((int)res.id);
379
+ return out;
380
+ }
381
+
382
+ py::object imageResource(psd::PSDFile &self, int id) {
383
+ const psd::ImageResourceInfo *res = findResource(self, id);
384
+ return res ? py::object(py::bytes(resourceBytes(*res))) : py::none();
385
+ }
386
+
387
+ py::object iccProfile(psd::PSDFile &self) { return imageResource(self, 1039); }
388
+ py::object exifData(psd::PSDFile &self) { return imageResource(self, 1058); }
389
+
390
+ // XMP packet (resource 1060) is UTF-8 XML. Returned as str; use
391
+ // image_resource(1060) for the raw bytes if the packet is not valid UTF-8.
392
+ py::object xmpMetadata(psd::PSDFile &self) {
393
+ const psd::ImageResourceInfo *res = findResource(self, 1060);
394
+ if (!res) return py::none();
395
+ return py::str(resourceBytes(*res));
396
+ }
397
+
398
+ // Embedded thumbnail (resource 1036 = RGB / legacy 1033 = BGR). Header is 28
399
+ // bytes; for format==1 the payload is a JFIF JPEG. Returns a dict with the
400
+ // header fields and the raw payload, or None.
401
+ py::object thumbnail(psd::PSDFile &self) {
402
+ const psd::ImageResourceInfo *res = findResource(self, 1036);
403
+ if (!res) res = findResource(self, 1033);
404
+ if (!res) return py::none();
405
+ std::string raw = resourceBytes(*res);
406
+ if (raw.size() < 28) return py::none();
407
+ auto be32 = [&](size_t o) {
408
+ return (uint32_t)(((uint8_t)raw[o] << 24) | ((uint8_t)raw[o+1] << 16) |
409
+ ((uint8_t)raw[o+2] << 8) | (uint8_t)raw[o+3]);
410
+ };
411
+ auto be16 = [&](size_t o) {
412
+ return (uint16_t)(((uint8_t)raw[o] << 8) | (uint8_t)raw[o+1]);
413
+ };
414
+ py::dict d;
415
+ uint32_t fmt = be32(0);
416
+ d["format"] = (fmt == 1) ? "jpeg" : "raw";
417
+ d["width"] = be32(4);
418
+ d["height"] = be32(8);
419
+ d["bits"] = be16(24);
420
+ d["resource_id"] = (int)res->id; // 1036 = RGB order, 1033 = BGR order
421
+ d["data"] = py::bytes(raw.data() + 28, raw.size() - 28);
422
+ return std::move(d);
423
+ }
424
+
215
425
  py::bytes layerImage(psd::PSDFile &self, int index, const std::string &mode) {
216
426
  if (!self.isLoaded) throw std::runtime_error("PSD not loaded");
217
427
  if (index < 0 || index >= (int)self.layerList.size())
@@ -321,6 +531,19 @@ PYBIND11_MODULE(psdparse, m) {
321
531
  "or None when the layer has no mask.")
322
532
  .def_property_readonly("blending_ranges", &layerBlendingRanges,
323
533
  "Layer blending ranges as a dict (gray, channels[]), or None.")
534
+ .def_property_readonly("effects", &layerEffects,
535
+ "Object-based layer effects ('lfx2') as a nested descriptor dict "
536
+ "(drop shadow / glow / overlay / stroke / bevel ...), or None.")
537
+ .def_property_readonly("fill", &layerFill,
538
+ "Fill-layer content as {'type': 'solid'|'gradient'|'pattern', "
539
+ "'data': {...}} from SoCo/GdFl/PtFl, or None.")
540
+ .def_property_readonly("info_keys", &layerInfoKeys,
541
+ "List of the 4cc keys of every additional-layer-info block present.")
542
+ .def("descriptor", &layerDescriptor,
543
+ py::arg("key"), py::arg("skip") = -1,
544
+ "Parse an arbitrary additional-info `key` (4-char str) as a Photoshop "
545
+ "descriptor dict. `skip` = version-prefix bytes before the descriptor "
546
+ "(-1 = auto for known keys, else 0). Returns None if absent/unparseable.")
324
547
  .def_property_readonly("visible", [](const psd::LayerInfo &l){ return l.isVisible(); })
325
548
  .def_property_readonly("transparency_protected", [](const psd::LayerInfo &l){ return l.isTransparencyProtected(); })
326
549
  .def_property_readonly("obsolete", [](const psd::LayerInfo &l){ return l.isObsolete(); })
@@ -390,7 +613,22 @@ PYBIND11_MODULE(psdparse, m) {
390
613
  "(id, name, comment, record_*). Empty list when there are none.")
391
614
  .def_property_readonly("color_table", &psdColorTable,
392
615
  "Indexed-color palette as a dict (colors[], valid_count, "
393
- "transparency_index), or None for non-indexed PSDs.");
616
+ "transparency_index), or None for non-indexed PSDs.")
617
+ .def_property_readonly("image_resource_ids", &imageResourceIds,
618
+ "List of the integer IDs of every image resource present.")
619
+ .def("image_resource", &imageResource, py::arg("id"),
620
+ "Raw bytes of the image resource with the given integer ID, or None.")
621
+ .def_property_readonly("icc_profile", &iccProfile,
622
+ "ICC profile (resource 1039) as raw bytes, or None.")
623
+ .def_property_readonly("exif", &exifData,
624
+ "EXIF block (resource 1058) as raw bytes, or None.")
625
+ .def_property_readonly("xmp", &xmpMetadata,
626
+ "XMP metadata (resource 1060) as a UTF-8 str, or None. Use "
627
+ "image_resource(1060) for raw bytes if not valid UTF-8.")
628
+ .def_property_readonly("thumbnail", &thumbnail,
629
+ "Embedded thumbnail (resource 1036/1033) as a dict "
630
+ "(format, width, height, bits, resource_id, data), or None. "
631
+ "For format=='jpeg', data is JFIF JPEG bytes.");
394
632
 
395
633
  // Enum-like ints exposed as module attributes for convenience
396
634
  m.attr("LAYER_TYPE_NORMAL") = (int)psd::LAYER_TYPE_NORMAL;
@@ -0,0 +1,103 @@
1
+ """Tests for the Tier-2 generic descriptor bridge (0.4.0):
2
+
3
+ layer.effects (lfx2), layer.fill (SoCo/GdFl/PtFl), layer.info_keys and the
4
+ generic layer.descriptor(key) escape hatch.
5
+
6
+ The group sample (config.psd) carries a layer with an 'lfx2' PatternOverlay;
7
+ values here were cross-checked against psd-tools 1.17 reading the same file.
8
+ """
9
+ import psdparse
10
+ import pytest
11
+
12
+
13
+ def _effects_layers(psd):
14
+ return [l for l in psd.layers if l.effects is not None]
15
+
16
+
17
+ def test_effects_extraction(psd_group):
18
+ hits = _effects_layers(psd_group)
19
+ if not hits:
20
+ pytest.skip("sample has no layer effects")
21
+ e = hits[0].effects
22
+ assert isinstance(e, dict)
23
+ # master switch is a plain bool
24
+ assert e.get("masterFXSwitch") in (True, False)
25
+
26
+
27
+ def test_unit_float_shape(psd_group):
28
+ hits = _effects_layers(psd_group)
29
+ if not hits:
30
+ pytest.skip("sample has no layer effects")
31
+ e = hits[0].effects
32
+ scl = e.get("Scl ") # a UnitFloat
33
+ if scl is not None:
34
+ assert set(scl.keys()) == {"value", "unit"}
35
+ assert isinstance(scl["value"], float)
36
+ assert isinstance(scl["unit"], str)
37
+
38
+
39
+ def test_descriptor_value_types(psd_group):
40
+ """Every converted value is a JSON-ish Python type (recursively)."""
41
+ hits = _effects_layers(psd_group)
42
+ if not hits:
43
+ pytest.skip("sample has no layer effects")
44
+
45
+ def check(v):
46
+ assert isinstance(v, (int, float, bool, str, bytes, dict, list, type(None)))
47
+ if isinstance(v, dict):
48
+ for x in v.values():
49
+ check(x)
50
+ elif isinstance(v, list):
51
+ for x in v:
52
+ check(x)
53
+
54
+ check(hits[0].effects)
55
+
56
+
57
+ def test_pattern_overlay_values(psd_group):
58
+ """Cross-checked against psd-tools: PatternOverlay opacity=100, scale=50%."""
59
+ hits = _effects_layers(psd_group)
60
+ if not hits:
61
+ pytest.skip("sample has no layer effects")
62
+ pf = hits[0].effects.get("patternFill")
63
+ if not isinstance(pf, dict):
64
+ pytest.skip("sample effect is not a pattern overlay")
65
+ assert pf.get("enab") is True
66
+ assert pf["Opct"]["value"] == pytest.approx(100.0)
67
+
68
+
69
+ def test_generic_descriptor_matches_effects(psd_group):
70
+ hits = _effects_layers(psd_group)
71
+ if not hits:
72
+ pytest.skip("sample has no layer effects")
73
+ l = hits[0]
74
+ assert l.descriptor("lfx2").keys() == l.effects.keys()
75
+
76
+
77
+ def test_binary_key_returns_none(psd_group):
78
+ """lrFX is a binary (non-descriptor) block -> None, must not crash."""
79
+ for l in psd_group.layers:
80
+ if "lrFX" in l.info_keys:
81
+ assert l.descriptor("lrFX") is None
82
+ return
83
+ pytest.skip("sample has no lrFX block")
84
+
85
+
86
+ def test_descriptor_bad_key_raises(psd_group):
87
+ with pytest.raises(ValueError):
88
+ psd_group.layers[0].descriptor("ab")
89
+
90
+
91
+ def test_info_keys_are_4cc(psd_group):
92
+ for l in psd_group.layers:
93
+ keys = l.info_keys
94
+ assert isinstance(keys, list)
95
+ for k in keys:
96
+ assert isinstance(k, str) and len(k) == 4
97
+
98
+
99
+ def test_fill_none_for_non_fill(psd_group):
100
+ # config/system samples contain no fill layers.
101
+ for l in psd_group.layers:
102
+ if l.layer_type != psdparse.LayerType.FILL:
103
+ assert l.fill is None
@@ -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