psdparse 0.7.0__tar.gz → 0.8.1__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 (61) hide show
  1. {psdparse-0.7.0 → psdparse-0.8.1}/PKG-INFO +19 -1
  2. {psdparse-0.7.0 → psdparse-0.8.1}/README.md +18 -0
  3. {psdparse-0.7.0 → psdparse-0.8.1}/docs/PYTHON_API.md +37 -0
  4. {psdparse-0.7.0 → psdparse-0.8.1}/docs/ROADMAP.md +17 -2
  5. {psdparse-0.7.0 → psdparse-0.8.1}/docs/SUPPORT.md +4 -2
  6. psdparse-0.8.1/examples/README.md +84 -0
  7. psdparse-0.8.1/examples/composite.py +111 -0
  8. psdparse-0.8.1/examples/extract_layers.py +105 -0
  9. psdparse-0.8.1/examples/variations.py +153 -0
  10. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdfile.cpp +34 -0
  11. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdfile.h +6 -0
  12. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdimage.cpp +22 -0
  13. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdlayer.cpp +35 -42
  14. {psdparse-0.7.0 → psdparse-0.8.1}/pyproject.toml +1 -1
  15. {psdparse-0.7.0 → psdparse-0.8.1}/python/psdparse_module.cpp +47 -4
  16. {psdparse-0.7.0 → psdparse-0.8.1}/tests/conftest.py +14 -0
  17. psdparse-0.8.1/tests/test_comps.py +78 -0
  18. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit.py +45 -0
  19. psdparse-0.8.1/tests/test_merged.py +54 -0
  20. {psdparse-0.7.0 → psdparse-0.8.1}/.gitignore +0 -0
  21. {psdparse-0.7.0 → psdparse-0.8.1}/CMakeLists.txt +0 -0
  22. {psdparse-0.7.0 → psdparse-0.8.1}/CMakePresets.json +0 -0
  23. {psdparse-0.7.0 → psdparse-0.8.1}/LICENSE +0 -0
  24. {psdparse-0.7.0 → psdparse-0.8.1}/Makefile +0 -0
  25. {psdparse-0.7.0 → psdparse-0.8.1}/docs/ARCHITECTURE.md +0 -0
  26. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/CMakeLists.txt +0 -0
  27. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/bmp.cpp +0 -0
  28. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psd_cli.cpp +0 -0
  29. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdbase.h +0 -0
  30. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psddata.h +0 -0
  31. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psddesc.cpp +0 -0
  32. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psddesc.h +0 -0
  33. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdengine.cpp +0 -0
  34. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdengine.h +0 -0
  35. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdlayer.h +0 -0
  36. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdparse.cpp +0 -0
  37. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdparse.h +0 -0
  38. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdresource.cpp +0 -0
  39. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdresource.h +0 -0
  40. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdwrite.cpp +0 -0
  41. {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdwrite.h +0 -0
  42. {psdparse-0.7.0 → psdparse-0.8.1}/python/CMakeLists.txt +0 -0
  43. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_colormodes.py +0 -0
  44. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_create.py +0 -0
  45. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_descriptors.py +0 -0
  46. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_effects.py +0 -0
  47. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_mask.py +0 -0
  48. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_mask_pixels.py +0 -0
  49. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_name.py +0 -0
  50. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_pixels.py +0 -0
  51. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_run_style.py +0 -0
  52. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_text.py +0 -0
  53. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_header.py +0 -0
  54. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_images.py +0 -0
  55. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_layers.py +0 -0
  56. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_mask_extras.py +0 -0
  57. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_metadata.py +0 -0
  58. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_resources.py +0 -0
  59. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_save.py +0 -0
  60. {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_text.py +0 -0
  61. {psdparse-0.7.0 → psdparse-0.8.1}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.7.0
3
+ Version: 0.8.1
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>
@@ -166,6 +166,24 @@ Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need
166
166
  python -m pytest -v
167
167
  ```
168
168
 
169
+ ## Examples (composite / variations / sprite extraction)
170
+
171
+ psdparse gives you the pieces (per-layer pixels, position, opacity, masks) but
172
+ does **not** composite or re-render — the Python imaging ecosystem does that
173
+ better. [`examples/`](examples/) shows the recipes:
174
+
175
+ - **`composite.py`** — composite layers with Pillow (position + opacity + mask,
176
+ normal blend), and optionally write the result back as the PSD's stored
177
+ preview via `set_merged_image`.
178
+ - **`variations.py`** — enumerate tachie/expression combinations by treating
179
+ layer folders as mutually-exclusive option slots.
180
+ - **`extract_layers.py`** — export per-layer PNGs + a manifest, with alpha edge
181
+ extension (bleed) so sprites stay clean under rotation/scaling.
182
+
183
+ See [examples/README.md](examples/README.md). On the sample tachie PSDs
184
+ (all normal blend) `composite.py` matches Photoshop's stored composite to ~0.1
185
+ mean level difference.
186
+
169
187
  ## tools/psd_export.py
170
188
 
171
189
  ```
@@ -125,6 +125,24 @@ Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need
125
125
  python -m pytest -v
126
126
  ```
127
127
 
128
+ ## Examples (composite / variations / sprite extraction)
129
+
130
+ psdparse gives you the pieces (per-layer pixels, position, opacity, masks) but
131
+ does **not** composite or re-render — the Python imaging ecosystem does that
132
+ better. [`examples/`](examples/) shows the recipes:
133
+
134
+ - **`composite.py`** — composite layers with Pillow (position + opacity + mask,
135
+ normal blend), and optionally write the result back as the PSD's stored
136
+ preview via `set_merged_image`.
137
+ - **`variations.py`** — enumerate tachie/expression combinations by treating
138
+ layer folders as mutually-exclusive option slots.
139
+ - **`extract_layers.py`** — export per-layer PNGs + a manifest, with alpha edge
140
+ extension (bleed) so sprites stay clean under rotation/scaling.
141
+
142
+ See [examples/README.md](examples/README.md). On the sample tachie PSDs
143
+ (all normal blend) `composite.py` matches Photoshop's stored composite to ~0.1
144
+ mean level difference.
145
+
128
146
  ## tools/psd_export.py
129
147
 
130
148
  ```
@@ -112,6 +112,7 @@ Read-only view of one layer.
112
112
  | `effects` | `dict` \| `None` | layer effects (`lfx2`) as a descriptor dict — see [Descriptor blocks](#descriptor-blocks) |
113
113
  | `fill` | `dict` \| `None` | fill-layer content (solid/gradient/pattern) — see [Descriptor blocks](#descriptor-blocks) |
114
114
  | `sheet_color` | `dict` \| `None` | layer-panel color label (`lclr`): `{"index", "name"}` — `None` when no `lclr` block |
115
+ | `comp_states` | `dict` | per layer-comp state `{comp_id: {"enabled", "offset_x", "offset_y"}}` (empty if the layer is in no comps). `enabled` says if the layer shows in that comp — see [Layer comps](#layer-comps) |
115
116
  | `info_keys` | `list[str]` | 4cc keys of every additional-layer-info block on this layer |
116
117
  | `visible` | `bool` | flag bit 1 inverted |
117
118
  | `transparency_protected` | `bool` | flag bit 0 |
@@ -258,6 +259,7 @@ Quick map of the API (details in the subsections below):
258
259
  | edit text content | `p.set_text(i, str)` |
259
260
  | edit a text run's style | `p.set_run_style(i, run, size_px=…, color=…, …)` |
260
261
  | build a new PSD | `p.create_blank(w, h)` then `add_layer(...)` |
262
+ | write a composited preview back | `p.set_merged_image(bgra)` |
261
263
 
262
264
  **8-bit RGB only** for the pixel/mask/new-document operations. The stored
263
265
  composite image is **not** re-rendered after edits (see [Saving](#saving)).
@@ -329,6 +331,9 @@ new_i = p.copy_layer_from(src, j, dest_index=-1) # copy layer j from another PS
329
331
  ```
330
332
 
331
333
  Notes:
334
+ - `duplicate_layer` / `copy_layer_from` assign the copy a **fresh `layer_id`**
335
+ (max existing lyid + 1, like Photoshop) so layer IDs stay unique within the
336
+ document; the `lyid` additional-info block is rewritten on save.
332
337
  - `delete_layer` / `duplicate_layer` on a single layer are exact. Deleting **one
333
338
  half of a group's FOLDER/HIDDEN divider pair unbalances the group** — delete
334
339
  whole groups (both dividers + contents) for clean nesting. (Unbalanced results
@@ -433,6 +438,14 @@ p.save("new.psd")
433
438
 
434
439
  ### Saving
435
440
 
441
+ The stored **composite (merged) image** is not re-rendered after edits. If you
442
+ composite the layers yourself (e.g. with Pillow — see [`examples/`](../examples/)),
443
+ write the result back as the PSD's preview with:
444
+
445
+ ```python
446
+ p.set_merged_image(bgra_bytes) # canvas-sized BGRA (header.width*header.height*4)
447
+ ```
448
+
436
449
  `save(path)` returns `True`/`False`. **Do not save over a file that is currently
437
450
  loaded** (by this or any live `PSDFile`): `load()` memory-maps the file
438
451
  read-only, so the write is refused and `save()` returns `False` (the original is
@@ -460,6 +473,30 @@ p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparenc
460
473
  p.global_layer_mask # dict|None : {"overlay_color_space", "color":(c1,c2,c3,c4), "opacity", "kind"}
461
474
  ```
462
475
 
476
+ ### Layer comps
477
+
478
+ `p.layer_comps` lists the document's layer comps (saved layer-state snapshots).
479
+ Which layers each comp shows is on the **layers**: `layer.comp_states` maps a
480
+ comp id to that layer's state in the comp:
481
+
482
+ ```python
483
+ { comp_id: {"enabled": True, "offset_x": 0, "offset_y": 0}, ... }
484
+ ```
485
+
486
+ `enabled` is whether the layer is visible in that comp; a layer the comp doesn't
487
+ mention isn't in the dict (fall back to its current visibility). To render a comp
488
+ (visibility only — position/appearance overrides aren't applied):
489
+
490
+ ```python
491
+ for comp in p.layer_comps:
492
+ show = {i for i, l in enumerate(p.layers)
493
+ if (l.comp_states[comp["id"]]["enabled"]
494
+ if comp["id"] in l.comp_states else l.visible)}
495
+ # composite `show` with Pillow — see examples/variations.py (composite_comp)
496
+ ```
497
+
498
+ `examples/variations.py --comps` renders every comp this way.
499
+
463
500
  ### Image resources (raw)
464
501
 
465
502
  Most image resources are exposed as their raw on-disk bytes; decoding (EXIF
@@ -3,12 +3,13 @@
3
3
  For a feature-by-feature account of what is and isn't supported today, see
4
4
  [SUPPORT.md](SUPPORT.md). This file tracks planned work.
5
5
 
6
- ## Current state (2026-08-02, v0.7.0)
6
+ ## Current state (2026-08-12, v0.8.1)
7
7
 
8
8
  - ✅ Pure C++17 parser (no Boost)
9
9
  - ✅ mmap + StreamReader / Source abstraction
10
10
  - ✅ Python bindings (pybind11)
11
- - ✅ pytest regression suite (121 tests)
11
+ - ✅ pytest regression suite (140 tests)
12
+ - ✅ Python composite recipes (`examples/`) + `set_merged_image`; layer comps exposed (`layer.comp_states`)
12
13
  - ✅ Round-trip PSD save (byte-identical)
13
14
  - ✅ Edit & save: structure / pixels / mask / parameters / effects / text / new-from-scratch (E1–E6, byte-exact re-serialization)
14
15
  - ✅ UTF-8 path I/F (Win32 conversion internal only)
@@ -90,6 +91,20 @@ Editing text content/style means writing the `TySh` block back: re-serializing t
90
91
 
91
92
  ## Other future work
92
93
 
94
+ - ✅ **`duplicate_layer` assigns a fresh `lyid`.** *Done 2026-08-12.* The
95
+ duplicated layer used to copy the source's `lyid` (layer ID) verbatim, so the
96
+ output PSD contained duplicate layer IDs — Photoshop itself assigns a *new*
97
+ ID when duplicating. Downstream tools that use `lyid` as a persistent layer
98
+ identity (e.g. elements_console's uitool reference resolution) then saw
99
+ ambiguous matches. Fix (`psdfile.cpp`): `assignFreshLayerId` sets
100
+ `layerId = max existing lyid + 1` and replaces (or appends) the `lyid`
101
+ additional-info entry on the copy, flipping `useRawBytes=false` so save
102
+ re-serializes it; applied in both `duplicateLayer` and `copyLayerFrom` (the
103
+ latter picks an ID unique within the *destination* document). `add_layer`
104
+ already picked max+1 — verified. Tests (`tests/test_edit.py`): duplicate →
105
+ all `layer.layer_id` distinct → save → reload → still distinct; double
106
+ duplicate cross-checked via psd-tools tagged blocks; `copy_layer_from`
107
+ uniqueness in the destination.
93
108
  - ✅ **Text layer content extraction (`TySh` type-tool additional info).** *Done 2026-07-27.* `lay.text` returns `{"text", "orientation", "justification", "transform", "runs":[{"length","font","size_px","color","tracking","kerning","auto_kerning"}]}` (or `None` for non-text layers). Implementation: `psddesc` now reads `tdta` raw data (length-prefixed), `loadLayerTypeTool` (`psdlayer.cpp`) parses the `TySh` header + text descriptor, and `psdengine.cpp` parses the embedded Adobe *EngineData* mini-language (FontSet / StyleRun / ParagraphRun) into per-run styling. Validated against `tests/data/fontsample.psd` (multi-font/size/color, **vertical**, emoji, tracking) — see `tests/test_text.py`.
94
109
 
95
110
  **Deferred (need targeted sample PSDs, next turn):**
@@ -54,7 +54,8 @@ Python API の使い方は [PYTHON_API.md](PYTHON_API.md) を参照。
54
54
  | テキストのラン単位スタイル編集 | ✅ | `set_run_style(i, run, size_px=/color=/tracking=/bold=…)` (v0.7.0) |
55
55
  | テキストのフォント変更 (FontSet 追加) / ラン再構成 | ❌ | 既存ランの値上書きのみ |
56
56
  | マスク幾何の単独編集 (画素なし) | 🟡 | `set_layer_mask_pixels` で画素とセットのみ |
57
- | 効果込みの合成画像 (composite) 再生成 | ❌ | 編集後は旧合成のまま (開いた Photoshop が再合成) |
57
+ | 合成済み画像 (composite) の入れ替え | ✅ | `set_merged_image(bgra)` — Python で合成した結果を書き戻せる (v0.7.x) |
58
+ | 効果込みの合成 (composite) の自動再生成 | ❌ | psdparse 自身は再描画しない。合成は Python (Pillow 等, `examples/`) で |
58
59
 
59
60
  ## 圧縮 / ビット深度
60
61
 
@@ -163,7 +164,8 @@ Photoshop の汎用ディスクリプタで格納されるブロックを dict
163
164
  | グリッド & ガイド (1032) | ✅ | `PSDFile.guides` (v0.3.0) |
164
165
  | スライス (1050 v6) | ✅ | `PSDFile.slices` (v0.3.0) |
165
166
  | スライス (1050 v7/v8, descriptor) | ❌ | 未格納 |
166
- | レイヤーカンプ (1065) | ✅ | `PSDFile.layer_comps` (v0.3.0) |
167
+ | レイヤーカンプ (1065, 文書レベル) | ✅ | `PSDFile.layer_comps` (id/name/comment/record_*, v0.3.0) |
168
+ | レイヤーカンプの各レイヤ状態 (可視) | ✅ | `layer.comp_states` = `{comp_id: {enabled, offset_x, offset_y}}` (v0.7.x)。位置/効果の上書きは未適用 |
167
169
  | インデックスカラーパレット (色/count/透明index) | ✅ | `PSDFile.color_table` (v0.3.0) |
168
170
  | ICC プロファイル (1039) | ✅ | `PSDFile.icc_profile` (生バイト, v0.5.0) |
169
171
  | EXIF (1058) | ✅ | `PSDFile.exif` (生バイト, v0.5.0) |
@@ -0,0 +1,84 @@
1
+ # psdparse examples
2
+
3
+ Recipes that combine **psdparse** (structure + pixel extraction + editing) with
4
+ the Python imaging ecosystem (Pillow / numpy). psdparse deliberately does *not*
5
+ composite or re-render — it hands you the pieces, and these scripts show how to
6
+ put them together.
7
+
8
+ Install the deps: `pip install psdparse pillow numpy`.
9
+
10
+ ## composite.py — composite layers with Pillow
11
+
12
+ Composites layers bottom-to-top (position + per-layer opacity + mask), **normal
13
+ blend only**. Layer effects and non-normal blend modes are not re-rendered
14
+ (read `layer.effects` / `layer.blend_mode` and implement them here if needed).
15
+
16
+ ```bash
17
+ python composite.py character.psd out.png
18
+ python composite.py character.psd out.png --write-back preview.psd
19
+ ```
20
+
21
+ `--write-back` writes the composite into a copy of the PSD as its stored preview
22
+ via `PSDFile.set_merged_image(...)` — useful after editing layers, since psdparse
23
+ does not regenerate the composite itself.
24
+
25
+ ```python
26
+ from composite import composite
27
+ img = composite(psd) # PIL.Image of the visible layers
28
+ img = composite(psd, show={0, 3}) # only these layer indices
29
+ ```
30
+
31
+ On the sample tachie PSDs (all normal blend) this matches Photoshop's stored
32
+ composite to ~0.1 mean level difference.
33
+
34
+ ## variations.py — tachie / expression combinations
35
+
36
+ Game and VTuber character PSDs group mutually-exclusive parts (eyes, mouth,
37
+ expression, outfit) into **folders**; each folder is a slot where one option
38
+ shows at a time. This enumerates option combinations and composites each.
39
+
40
+ ```bash
41
+ python variations.py character.psd out_dir/ # sweep the first group
42
+ python variations.py character.psd out_dir/ --group 表情 # sweep a named group
43
+ python variations.py doc.psd out_dir/ --comps # render each Photoshop layer comp
44
+ ```
45
+
46
+ `--comps` renders each of the document's Photoshop **layer comps** using
47
+ `layer.comp_states` (visibility only; position/appearance overrides aren't
48
+ applied). Otherwise it sweeps folder-based option slots.
49
+
50
+ ```python
51
+ from variations import option_groups, composite_choices
52
+ groups = option_groups(psd) # {folder_index: [option_index, ...]}
53
+ img = composite_choices(psd, {folder_i: option_i})
54
+ ```
55
+
56
+ It treats each top-level folder as a simple "pick one direct child" slot. Real
57
+ data sometimes nests (a body folder with an always-on base plus arm options) —
58
+ adapt `option_groups` for that.
59
+
60
+ ## extract_layers.py — sprite extraction with alpha bleed
61
+
62
+ Turns a PSD into per-layer PNGs plus a `manifest.json`, and **extends each
63
+ layer's base colour into its transparent area** (alpha bleed / edge dilation) so
64
+ the alpha edge doesn't leak a halo when the sprite is rotated or scaled with
65
+ bilinear filtering — a standard step when prepping tachie sprites for a game
66
+ engine.
67
+
68
+ ```bash
69
+ python extract_layers.py character.psd out_dir/ --bleed 8
70
+ python extract_layers.py character.psd out_dir/ --no-bleed
71
+ ```
72
+
73
+ The bleed only fills RGB under `alpha == 0`; the alpha channel is left untouched.
74
+
75
+ ```python
76
+ from extract_layers import alpha_bleed
77
+ safe = alpha_bleed(layer_rgba_image, passes=8)
78
+ ```
79
+
80
+ ## See also
81
+
82
+ `tools/psd_export.py` (repo root) dumps `layers.json` + `merged.png` + per-layer
83
+ PNGs in one shot. The examples here are smaller, focused building blocks you can
84
+ copy into your own pipeline.
@@ -0,0 +1,111 @@
1
+ """Composite psdparse layers with Pillow.
2
+
3
+ psdparse gives you each layer's pixels (`layer_image`), position, opacity and
4
+ visibility; the actual compositing is done here in Python with Pillow. This keeps
5
+ the C++ core lean and lets you use the mature imaging ecosystem.
6
+
7
+ Scope: **normal blend** + per-layer opacity + mask (via ``layer_image(i,
8
+ "masked")``) + position, composited bottom-to-top. Non-normal blend modes and
9
+ layer effects are *not* applied (psdparse does not re-render effects); if you
10
+ need those, read `layer.blend_mode` / `layer.effects` and implement them here.
11
+
12
+ python composite.py input.psd output.png [--write-back edited.psd]
13
+
14
+ As a library:
15
+
16
+ from composite import composite
17
+ img = composite(psd) # PIL.Image, currently-visible layers
18
+ img = composite(psd, show={0, 3, 7}) # only these layer indices
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+
24
+ import psdparse
25
+ from PIL import Image
26
+
27
+ # Layer types that carry pixels (skip folder/divider markers).
28
+ _PIXEL_TYPES = (
29
+ psdparse.LayerType.NORMAL,
30
+ psdparse.LayerType.TEXT,
31
+ psdparse.LayerType.ADJUST,
32
+ psdparse.LayerType.FILL,
33
+ )
34
+
35
+
36
+ def layer_rgba(psd: psdparse.PSDFile, index: int) -> Image.Image | None:
37
+ """A layer's masked pixels as an RGBA PIL image (opacity folded into alpha),
38
+ or None for empty layers."""
39
+ layer = psd.layers[index]
40
+ if layer.width <= 0 or layer.height <= 0:
41
+ return None
42
+ bgra = psd.layer_image(index, "masked") # mask applied to alpha
43
+ img = Image.frombytes("RGBA", (layer.width, layer.height), bgra, "raw", "BGRA")
44
+ if layer.opacity < 255:
45
+ alpha = img.getchannel("A").point(lambda v: v * layer.opacity // 255)
46
+ img.putalpha(alpha)
47
+ return img
48
+
49
+
50
+ def _paste_clipped(canvas: Image.Image, img: Image.Image, x: int, y: int) -> None:
51
+ """alpha-composite `img` onto `canvas` at (x, y), clipping to the canvas
52
+ (handles layers that extend past the edges / negative offsets)."""
53
+ cw, ch = canvas.size
54
+ w, h = img.size
55
+ sx0, sy0 = max(0, -x), max(0, -y)
56
+ sx1, sy1 = min(w, cw - x), min(h, ch - y)
57
+ if sx1 <= sx0 or sy1 <= sy0:
58
+ return
59
+ crop = img.crop((sx0, sy0, sx1, sy1))
60
+ tmp = Image.new("RGBA", (cw, ch), (0, 0, 0, 0))
61
+ tmp.paste(crop, (max(0, x), max(0, y)))
62
+ canvas.alpha_composite(tmp)
63
+
64
+
65
+ def composite(psd: psdparse.PSDFile, show: set[int] | None = None) -> Image.Image:
66
+ """Composite layers bottom-to-top into a canvas-sized RGBA image.
67
+
68
+ `show`: set of layer indices to include. Default (None) = every currently
69
+ **visible** pixel layer. Folder/divider markers are always skipped.
70
+ """
71
+ canvas = Image.new("RGBA", (psd.header.width, psd.header.height), (0, 0, 0, 0))
72
+ for i, layer in enumerate(psd.layers): # index 0 = bottom-most
73
+ if layer.layer_type not in _PIXEL_TYPES:
74
+ continue
75
+ if show is None:
76
+ if not layer.visible:
77
+ continue
78
+ elif i not in show:
79
+ continue
80
+ img = layer_rgba(psd, i)
81
+ if img is not None:
82
+ _paste_clipped(canvas, img, layer.left, layer.top)
83
+ return canvas
84
+
85
+
86
+ def main() -> None:
87
+ ap = argparse.ArgumentParser(description="Composite visible layers with Pillow.")
88
+ ap.add_argument("input")
89
+ ap.add_argument("output", help="output PNG")
90
+ ap.add_argument("--write-back", metavar="PSD",
91
+ help="also write the composite into a copy of the PSD "
92
+ "(as the stored preview) via set_merged_image")
93
+ args = ap.parse_args()
94
+
95
+ psd = psdparse.PSDFile()
96
+ if not psd.load(args.input):
97
+ raise SystemExit(f"failed to load {args.input}")
98
+ img = composite(psd)
99
+ img.save(args.output)
100
+ print(f"wrote {args.output} ({img.width}x{img.height})")
101
+
102
+ if args.write_back:
103
+ # set_merged_image wants canvas-sized BGRA
104
+ bgra = img.convert("RGBA").tobytes("raw", "BGRA")
105
+ psd.set_merged_image(bgra)
106
+ psd.save(args.write_back)
107
+ print(f"wrote {args.write_back} with the composite as its stored preview")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
@@ -0,0 +1,105 @@
1
+ """Extract each layer to a PNG, with alpha edge-extension (bleed), + a manifest.
2
+
3
+ A common step when turning a tachie PSD into game-ready sprites: pull each part
4
+ out as its own image, and **extend the base colour into the transparent area** so
5
+ the alpha edge doesn't leak a dark/transparent halo when the sprite is rotated or
6
+ scaled with bilinear filtering. This writes:
7
+
8
+ out_dir/000_<name>.png ... each pixel layer (RGB bled under the alpha)
9
+ out_dir/manifest.json ... index/name/position/size/opacity/blend/parent
10
+
11
+ python extract_layers.py character.psd out_dir/ [--bleed 8] [--no-bleed]
12
+
13
+ Needs numpy (for the bleed). The bleed leaves the alpha channel untouched — it
14
+ only fills RGB in transparent pixels, which is exactly what edge-safe scaling
15
+ wants.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+
23
+ import numpy as np
24
+ import psdparse
25
+ from PIL import Image
26
+
27
+ from composite import _PIXEL_TYPES, layer_rgba
28
+
29
+
30
+ def alpha_bleed(img: Image.Image, passes: int = 8) -> Image.Image:
31
+ """Extend RGB into transparent pixels by iterative dilation (`passes` px).
32
+ Alpha is preserved; only the colour under alpha==0 is filled from neighbours."""
33
+ arr = np.array(img.convert("RGBA"))
34
+ rgb = arr[..., :3].astype(np.float32)
35
+ filled = arr[..., 3] > 0
36
+ for _ in range(passes):
37
+ if filled.all():
38
+ break
39
+ color_sum = np.zeros_like(rgb)
40
+ count = np.zeros(filled.shape, np.float32)
41
+ for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
42
+ m = np.roll(filled, (dy, dx), (0, 1))
43
+ shifted = np.roll(rgb, (dy, dx), (0, 1))
44
+ if dy == 1: m[0, :] = False # kill np.roll wrap-around
45
+ elif dy == -1: m[-1, :] = False
46
+ if dx == 1: m[:, 0] = False
47
+ elif dx == -1: m[:, -1] = False
48
+ color_sum += shifted * m[..., None]
49
+ count += m
50
+ newly = (~filled) & (count > 0)
51
+ rgb[newly] = color_sum[newly] / count[newly][..., None]
52
+ filled |= newly
53
+ arr[..., :3] = np.clip(rgb, 0, 255).astype(np.uint8)
54
+ return Image.fromarray(arr, "RGBA")
55
+
56
+
57
+ def _blend_key(layer: psdparse.LayerInfo) -> str:
58
+ k = layer.blend_mode_key
59
+ return "".join(chr((k >> s) & 0xFF) for s in (24, 16, 8, 0))
60
+
61
+
62
+ def main() -> None:
63
+ ap = argparse.ArgumentParser(description="Extract layers to PNGs with alpha bleed.")
64
+ ap.add_argument("input")
65
+ ap.add_argument("out_dir")
66
+ ap.add_argument("--bleed", type=int, default=8, help="edge-extension passes (px)")
67
+ ap.add_argument("--no-bleed", action="store_true")
68
+ args = ap.parse_args()
69
+
70
+ psd = psdparse.PSDFile()
71
+ if not psd.load(args.input):
72
+ raise SystemExit(f"failed to load {args.input}")
73
+ os.makedirs(args.out_dir, exist_ok=True)
74
+
75
+ manifest = {"width": psd.header.width, "height": psd.header.height, "layers": []}
76
+ for i, layer in enumerate(psd.layers):
77
+ if layer.layer_type not in _PIXEL_TYPES:
78
+ continue
79
+ img = layer_rgba(psd, i)
80
+ if img is None:
81
+ continue
82
+ if not args.no_bleed and args.bleed > 0:
83
+ img = alpha_bleed(img, args.bleed)
84
+ try:
85
+ name = layer.name_unicode
86
+ except UnicodeDecodeError:
87
+ name = f"layer{i}"
88
+ safe = "".join(c if c.isalnum() or c in "._-()()  " else "_" for c in name).strip()
89
+ fname = f"{i:03d}_{safe or 'layer'}.png"
90
+ img.save(os.path.join(args.out_dir, fname))
91
+ manifest["layers"].append({
92
+ "index": i, "name": name, "file": fname,
93
+ "left": layer.left, "top": layer.top,
94
+ "width": layer.width, "height": layer.height,
95
+ "opacity": layer.opacity, "blend": _blend_key(layer),
96
+ "visible": layer.visible, "parent_index": layer.parent_index,
97
+ })
98
+
99
+ with open(os.path.join(args.out_dir, "manifest.json"), "w", encoding="utf-8") as f:
100
+ json.dump(manifest, f, ensure_ascii=False, indent=2)
101
+ print(f"extracted {len(manifest['layers'])} layers to {args.out_dir}")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,153 @@
1
+ """Generate character (tachie) variations from grouped layers.
2
+
3
+ Game/VTuber character PSDs put mutually-exclusive parts (eyes, mouth, outfit,
4
+ expression) into layer **folders** — each folder is a "slot" where one option is
5
+ shown at a time. This example enumerates option combinations and composites each
6
+ with Pillow (see composite.py).
7
+
8
+ python variations.py character.psd out_dir/ # sweep the first slot
9
+ python variations.py character.psd out_dir/ --group 表情 --group 目
10
+
11
+ As a library:
12
+
13
+ from variations import option_groups, composite_choices
14
+ groups = option_groups(psd) # {folder_index: [option_index, ...]}
15
+ img = composite_choices(psd, {folder_i: option_i}) # PIL.Image
16
+
17
+ Note: this treats each top-level folder as a simple "pick one of its direct
18
+ pixel-layer children" slot. Real PSDs sometimes nest (a body folder with an
19
+ always-on base plus arm options); adapt `option_groups` for those.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import itertools
25
+ import os
26
+
27
+ import psdparse
28
+
29
+ from composite import composite, _PIXEL_TYPES
30
+
31
+
32
+ def _label(psd: psdparse.PSDFile, index: int) -> str:
33
+ try:
34
+ return psd.layers[index].name_unicode
35
+ except UnicodeDecodeError:
36
+ return f"layer{index}"
37
+
38
+
39
+ def option_groups(psd: psdparse.PSDFile) -> dict[int, list[int]]:
40
+ """Top-level folders → their direct pixel-layer children (the options)."""
41
+ groups: dict[int, list[int]] = {}
42
+ for i, layer in enumerate(psd.layers):
43
+ if layer.layer_type == psdparse.LayerType.FOLDER and layer.parent_index == -1:
44
+ options = [j for j, c in enumerate(psd.layers)
45
+ if c.parent_index == i and c.layer_type in _PIXEL_TYPES]
46
+ if options:
47
+ groups[i] = options
48
+ return groups
49
+
50
+
51
+ def composite_choices(psd: psdparse.PSDFile, choices: dict[int, int]):
52
+ """Composite with one option chosen per group.
53
+
54
+ `choices`: {folder_index: option_index}. For each chosen group the picked
55
+ option is shown and its siblings hidden; groups not listed keep their
56
+ currently-visible option; layers outside any group follow their visibility.
57
+ """
58
+ show = {i for i, l in enumerate(psd.layers)
59
+ if l.visible and l.layer_type in _PIXEL_TYPES}
60
+ groups = option_groups(psd)
61
+ for folder_index, chosen in choices.items():
62
+ for opt in groups.get(folder_index, []):
63
+ show.discard(opt)
64
+ show.add(chosen)
65
+ return composite(psd, show)
66
+
67
+
68
+ def find_group(psd: psdparse.PSDFile, name: str) -> int | None:
69
+ for folder_index in option_groups(psd):
70
+ if _label(psd, folder_index) == name:
71
+ return folder_index
72
+ return None
73
+
74
+
75
+ def composite_comp(psd: psdparse.PSDFile, comp_id: int):
76
+ """Composite a document **layer comp** (`comp_id` from psd.layer_comps).
77
+
78
+ Each layer's `comp_states[comp_id].enabled` says whether it shows in that
79
+ comp; layers the comp doesn't mention keep their current visibility. (Comp
80
+ position/appearance overrides are not applied — visibility only.)
81
+ """
82
+ show = set()
83
+ for i, layer in enumerate(psd.layers):
84
+ if layer.layer_type not in _PIXEL_TYPES:
85
+ continue
86
+ st = layer.comp_states
87
+ visible = st[comp_id]["enabled"] if comp_id in st else layer.visible
88
+ if visible:
89
+ show.add(i)
90
+ return composite(psd, show)
91
+
92
+
93
+ def main() -> None:
94
+ ap = argparse.ArgumentParser(description="Composite tachie layer-group combinations.")
95
+ ap.add_argument("input")
96
+ ap.add_argument("out_dir")
97
+ ap.add_argument("--group", action="append", default=[],
98
+ help="folder name to sweep (repeatable). Default: the first group.")
99
+ ap.add_argument("--comps", action="store_true",
100
+ help="instead of sweeping groups, render each document layer comp")
101
+ ap.add_argument("--limit", type=int, default=24, help="max images to write")
102
+ args = ap.parse_args()
103
+
104
+ psd = psdparse.PSDFile()
105
+ if not psd.load(args.input):
106
+ raise SystemExit(f"failed to load {args.input}")
107
+
108
+ if args.comps:
109
+ comps = psd.layer_comps
110
+ if not comps:
111
+ raise SystemExit("this PSD has no document layer comps")
112
+ os.makedirs(args.out_dir, exist_ok=True)
113
+ for n, c in enumerate(comps):
114
+ img = composite_comp(psd, c["id"])
115
+ safe = "".join(ch if ch.isalnum() else "_" for ch in c["name"]).strip("_")
116
+ img.save(os.path.join(args.out_dir, f"comp{n:02d}_{safe or c['id']}.png"))
117
+ print(f"wrote {len(comps)} layer comps to {args.out_dir}")
118
+ return
119
+
120
+ groups = option_groups(psd)
121
+ if not groups:
122
+ raise SystemExit("no option groups (top-level folders) found")
123
+
124
+ print("option groups:")
125
+ for folder_index, opts in groups.items():
126
+ print(f" {_label(psd, folder_index)!r}: {[_label(psd, o) for o in opts]}")
127
+
128
+ # which groups to sweep
129
+ if args.group:
130
+ sweep = [find_group(psd, n) for n in args.group]
131
+ if None in sweep:
132
+ raise SystemExit(f"group not found among {[_label(psd, g) for g in groups]}")
133
+ else:
134
+ sweep = [next(iter(groups))]
135
+
136
+ os.makedirs(args.out_dir, exist_ok=True)
137
+ combos = itertools.product(*[groups[g] for g in sweep])
138
+ n = 0
139
+ for combo in combos:
140
+ if n >= args.limit:
141
+ print(f"(stopped at --limit {args.limit})")
142
+ break
143
+ choices = dict(zip(sweep, combo))
144
+ img = composite_choices(psd, choices)
145
+ tag = "_".join(_label(psd, o).split()[0] for o in combo) or f"v{n}"
146
+ path = os.path.join(args.out_dir, f"{n:03d}_{tag}.png")
147
+ img.save(path)
148
+ n += 1
149
+ print(f"wrote {n} images to {args.out_dir}")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()