psdparse 0.5.0__tar.gz → 0.7.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.5.0 → psdparse-0.7.0}/PKG-INFO +22 -3
- {psdparse-0.5.0 → psdparse-0.7.0}/README.md +21 -2
- {psdparse-0.5.0 → psdparse-0.7.0}/docs/PYTHON_API.md +253 -10
- psdparse-0.7.0/docs/ROADMAP.md +107 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/docs/SUPPORT.md +59 -25
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdbase.h +27 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psddata.h +46 -11
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psddesc.cpp +1 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psddesc.h +3 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdengine.cpp +277 -1
- psdparse-0.7.0/psdparse/psdengine.h +59 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdfile.cpp +142 -0
- psdparse-0.7.0/psdparse/psdfile.h +148 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdimage.cpp +326 -6
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdparse.cpp +28 -6
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdparse.h +47 -0
- psdparse-0.7.0/psdparse/psdwrite.cpp +446 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdwrite.h +55 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/pyproject.toml +1 -1
- psdparse-0.7.0/python/psdparse_module.cpp +1142 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/conftest.py +53 -0
- psdparse-0.7.0/tests/test_colormodes.py +49 -0
- psdparse-0.7.0/tests/test_create.py +82 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_descriptors.py +8 -0
- psdparse-0.7.0/tests/test_edit.py +147 -0
- psdparse-0.7.0/tests/test_edit_effects.py +101 -0
- psdparse-0.7.0/tests/test_edit_mask.py +99 -0
- psdparse-0.7.0/tests/test_edit_mask_pixels.py +86 -0
- psdparse-0.7.0/tests/test_edit_name.py +72 -0
- psdparse-0.7.0/tests/test_edit_pixels.py +113 -0
- psdparse-0.7.0/tests/test_edit_run_style.py +87 -0
- psdparse-0.7.0/tests/test_edit_text.py +81 -0
- psdparse-0.7.0/tests/test_mask_extras.py +61 -0
- psdparse-0.5.0/docs/ROADMAP.md +0 -91
- psdparse-0.5.0/psdparse/psdengine.h +0 -34
- psdparse-0.5.0/psdparse/psdfile.h +0 -79
- psdparse-0.5.0/psdparse/psdwrite.cpp +0 -223
- psdparse-0.5.0/python/psdparse_module.cpp +0 -648
- {psdparse-0.5.0 → psdparse-0.7.0}/.gitignore +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/CMakeLists.txt +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/CMakePresets.json +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/LICENSE +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/Makefile +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/CMakeLists.txt +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/bmp.cpp +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdlayer.cpp +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdlayer.h +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdresource.h +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/python/CMakeLists.txt +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_header.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_images.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_layers.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_metadata.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_resources.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_save.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_text.py +0 -0
- {psdparse-0.5.0 → psdparse-0.7.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
|
+
Version: 0.7.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>
|
|
@@ -45,7 +45,8 @@ Pure C++17 PSD (Photoshop) reader/writer library, with pybind11-based Python bin
|
|
|
45
45
|
|
|
46
46
|
- Lazy I/O: PSD pixel data is **not** copied into memory at parse time. Only the structural metadata (a few hundred KB even for large files) is read upfront; layer pixels are paged in on demand via mmap or stream callbacks.
|
|
47
47
|
- Round-trip save: `load(p) -> save(q)` produces a byte-identical PSD file.
|
|
48
|
-
-
|
|
48
|
+
- **Edit & save**: add / delete / reorder / duplicate layers, copy layers between files, replace layer & mask pixels, edit parameters / names / masks / fill opacity, edit layer-effect (`lfx2`) values and text content, or build a PSD from scratch — all with byte-exact re-serialization of the parts you touch (unedited layers stay byte-identical).
|
|
49
|
+
- Python wrapper: `import psdparse` → `PSDFile.load(path) / layer_image(i) / merged_image() / save(path)` plus the editing API.
|
|
49
50
|
|
|
50
51
|
The library was extracted from the [psdfile](https://github.com/wamsoft/psdfile) kirikiri plugin in 2026. psdfile now consumes this library as a submodule.
|
|
51
52
|
|
|
@@ -131,11 +132,29 @@ layer_bgra = p.layer_image(0, "masked") # bytes, BGRA, 4*w*h
|
|
|
131
132
|
p.save(r"out.psd") # byte-identical round-trip
|
|
132
133
|
```
|
|
133
134
|
|
|
135
|
+
Editing (8-bit RGB), re-serialized on save — unedited layers stay byte-identical:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
p.layers[0].opacity = 128 # parameters
|
|
139
|
+
p.layers[0].name_unicode = "背景" # rename
|
|
140
|
+
p.delete_layer(3); p.move_layer(1, 4) # structure
|
|
141
|
+
p.set_layer_pixels(0, bgra_bytes, w, h) # replace pixels
|
|
142
|
+
p.set_layer_mask_pixels(0, gray, 0, 0, w, h) # mask pixels + geometry
|
|
143
|
+
p.set_effects(0, {"masterFXSwitch": False}) # layer-effect values
|
|
144
|
+
p.set_text(2, "新しいテキスト") # text content
|
|
145
|
+
p.save(r"edited.psd")
|
|
146
|
+
|
|
147
|
+
q = psdparse.PSDFile() # or build one from scratch
|
|
148
|
+
q.create_blank(1024, 768)
|
|
149
|
+
q.add_layer("bg", 0, 0, bgra_bytes, 1024, 768)
|
|
150
|
+
q.save(r"new.psd")
|
|
151
|
+
```
|
|
152
|
+
|
|
134
153
|
Full API reference: [docs/PYTHON_API.md](docs/PYTHON_API.md).
|
|
135
154
|
|
|
136
155
|
## PSD feature coverage
|
|
137
156
|
|
|
138
|
-
What psdparse can and cannot read
|
|
157
|
+
What psdparse can and cannot read **and edit**, at a glance:
|
|
139
158
|
[docs/SUPPORT.md](docs/SUPPORT.md) (対応状況マトリクス).
|
|
140
159
|
|
|
141
160
|
## Tests
|
|
@@ -4,7 +4,8 @@ Pure C++17 PSD (Photoshop) reader/writer library, with pybind11-based Python bin
|
|
|
4
4
|
|
|
5
5
|
- Lazy I/O: PSD pixel data is **not** copied into memory at parse time. Only the structural metadata (a few hundred KB even for large files) is read upfront; layer pixels are paged in on demand via mmap or stream callbacks.
|
|
6
6
|
- Round-trip save: `load(p) -> save(q)` produces a byte-identical PSD file.
|
|
7
|
-
-
|
|
7
|
+
- **Edit & save**: add / delete / reorder / duplicate layers, copy layers between files, replace layer & mask pixels, edit parameters / names / masks / fill opacity, edit layer-effect (`lfx2`) values and text content, or build a PSD from scratch — all with byte-exact re-serialization of the parts you touch (unedited layers stay byte-identical).
|
|
8
|
+
- Python wrapper: `import psdparse` → `PSDFile.load(path) / layer_image(i) / merged_image() / save(path)` plus the editing API.
|
|
8
9
|
|
|
9
10
|
The library was extracted from the [psdfile](https://github.com/wamsoft/psdfile) kirikiri plugin in 2026. psdfile now consumes this library as a submodule.
|
|
10
11
|
|
|
@@ -90,11 +91,29 @@ layer_bgra = p.layer_image(0, "masked") # bytes, BGRA, 4*w*h
|
|
|
90
91
|
p.save(r"out.psd") # byte-identical round-trip
|
|
91
92
|
```
|
|
92
93
|
|
|
94
|
+
Editing (8-bit RGB), re-serialized on save — unedited layers stay byte-identical:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
p.layers[0].opacity = 128 # parameters
|
|
98
|
+
p.layers[0].name_unicode = "背景" # rename
|
|
99
|
+
p.delete_layer(3); p.move_layer(1, 4) # structure
|
|
100
|
+
p.set_layer_pixels(0, bgra_bytes, w, h) # replace pixels
|
|
101
|
+
p.set_layer_mask_pixels(0, gray, 0, 0, w, h) # mask pixels + geometry
|
|
102
|
+
p.set_effects(0, {"masterFXSwitch": False}) # layer-effect values
|
|
103
|
+
p.set_text(2, "新しいテキスト") # text content
|
|
104
|
+
p.save(r"edited.psd")
|
|
105
|
+
|
|
106
|
+
q = psdparse.PSDFile() # or build one from scratch
|
|
107
|
+
q.create_blank(1024, 768)
|
|
108
|
+
q.add_layer("bg", 0, 0, bgra_bytes, 1024, 768)
|
|
109
|
+
q.save(r"new.psd")
|
|
110
|
+
```
|
|
111
|
+
|
|
93
112
|
Full API reference: [docs/PYTHON_API.md](docs/PYTHON_API.md).
|
|
94
113
|
|
|
95
114
|
## PSD feature coverage
|
|
96
115
|
|
|
97
|
-
What psdparse can and cannot read
|
|
116
|
+
What psdparse can and cannot read **and edit**, at a glance:
|
|
98
117
|
[docs/SUPPORT.md](docs/SUPPORT.md) (対応状況マトリクス).
|
|
99
118
|
|
|
100
119
|
## Tests
|
|
@@ -42,7 +42,11 @@ Open `path` as a `std::ifstream` and parse via `StreamReader`. Functionally equi
|
|
|
42
42
|
p.save(path: str) -> bool
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Save the currently loaded data back to disk as PSD.
|
|
45
|
+
Save the currently loaded (and optionally edited) data back to disk as PSD.
|
|
46
|
+
An **unmodified** file round-trips byte-identically (`p.load(a); p.save(b)`),
|
|
47
|
+
and edits are re-serialized on save — see [Editing & saving](#editing--saving).
|
|
48
|
+
Do not save over a file that is currently loaded (returns `False`; see
|
|
49
|
+
[Saving](#saving)).
|
|
46
50
|
|
|
47
51
|
### Header
|
|
48
52
|
|
|
@@ -92,7 +96,7 @@ Read-only view of one layer.
|
|
|
92
96
|
| `top, left, bottom, right` | `int` | layer bounding box on canvas |
|
|
93
97
|
| `width, height` | `int` | derived from bbox |
|
|
94
98
|
| `opacity` | `int` | 0..255 |
|
|
95
|
-
| `fill_opacity` | `int` | 0..255 |
|
|
99
|
+
| `fill_opacity` | `int` | 0..255. **Writable** (the `iOpa` block) |
|
|
96
100
|
| `clipping` | `int` | 0=base, 1=non-base |
|
|
97
101
|
| `blend_mode_key` | `int` | raw 4cc value (e.g. `'norm'` as int) |
|
|
98
102
|
| `blend_mode` | `BlendMode` enum | parsed blend mode |
|
|
@@ -100,13 +104,14 @@ Read-only view of one layer.
|
|
|
100
104
|
| `layer_id` | `int` | -1 if unset |
|
|
101
105
|
| `channels` | `list[ChannelInfo]` | per-channel id+length |
|
|
102
106
|
| `name` | `str` | raw Pascal-string name (CP932 etc on Japanese PSDs — pybind11 may raise UnicodeDecodeError when read) |
|
|
103
|
-
| `name_unicode` | `str` | UTF-16 Unicode name from `luni` record (preferred) |
|
|
107
|
+
| `name_unicode` | `str` | UTF-16 Unicode name from `luni` record (preferred). **Writable** — assigning renames the layer (see [Editing](#editing--saving)) |
|
|
104
108
|
| `parent_index` | `int` | index into `PSDFile.layers` of the enclosing folder, or `-1` for top level — see [Layer hierarchy](#layer-hierarchy) |
|
|
105
109
|
| `text` | `dict` \| `None` | text-layer content & style (`None` for non-text layers) — see below |
|
|
106
110
|
| `mask` | `dict` \| `None` | layer mask geometry & flags (`None` when the layer has no mask) — see below |
|
|
107
111
|
| `blending_ranges` | `dict` \| `None` | "Blend If" ranges (`None` when absent) — see below |
|
|
108
112
|
| `effects` | `dict` \| `None` | layer effects (`lfx2`) as a descriptor dict — see [Descriptor blocks](#descriptor-blocks) |
|
|
109
113
|
| `fill` | `dict` \| `None` | fill-layer content (solid/gradient/pattern) — see [Descriptor blocks](#descriptor-blocks) |
|
|
114
|
+
| `sheet_color` | `dict` \| `None` | layer-panel color label (`lclr`): `{"index", "name"}` — `None` when no `lclr` block |
|
|
110
115
|
| `info_keys` | `list[str]` | 4cc keys of every additional-layer-info block on this layer |
|
|
111
116
|
| `visible` | `bool` | flag bit 1 inverted |
|
|
112
117
|
| `transparency_protected` | `bool` | flag bit 0 |
|
|
@@ -175,16 +180,24 @@ for layer in p.layers:
|
|
|
175
180
|
"disabled": False, # bit1: mask disabled
|
|
176
181
|
"inverted": False, # bit2: invert (obsolete)
|
|
177
182
|
"from_render": False, # bit3: mask from rendering other data
|
|
178
|
-
"has_parameters":
|
|
183
|
+
"has_parameters": True, # bit4: density/feather block present
|
|
184
|
+
"user_density": 128, # 0..255, or None if not stored
|
|
185
|
+
"user_feather": 2.5, # feather radius (px), or None
|
|
186
|
+
"vector_density": None, # 0..255, or None
|
|
187
|
+
"vector_feather": None, # feather radius (px), or None
|
|
179
188
|
"real": None, # or a nested dict (below) for a real/user mask
|
|
180
189
|
}
|
|
181
190
|
```
|
|
182
191
|
|
|
183
|
-
When the record carries a *real* (user + vector combined) mask
|
|
184
|
-
with `flags`, `background`, and the enclosing
|
|
185
|
-
**pixels** are unchanged
|
|
192
|
+
When the record carries a *real* (user + vector combined) mask (block size ≥ 36),
|
|
193
|
+
`real` is a dict with `flags`, `background`, and the enclosing
|
|
194
|
+
`top/left/bottom/right`. The mask **pixels** are unchanged — fetch them with
|
|
186
195
|
`p.layer_image(i, "mask")`.
|
|
187
196
|
|
|
197
|
+
`user_density`/`user_feather`/`vector_density`/`vector_feather` are only non-`None`
|
|
198
|
+
when `has_parameters` is set and the corresponding value was stored. (Fixed in
|
|
199
|
+
0.6.0: the real-mask section was previously decoded one byte off.)
|
|
200
|
+
|
|
188
201
|
### `layer.blending_ranges` — "Blend If" ranges
|
|
189
202
|
|
|
190
203
|
`None` when absent. `gray` is the composite range; `channels` has one entry per
|
|
@@ -214,6 +227,226 @@ for i, l in enumerate(p.layers):
|
|
|
214
227
|
`FOLDER` marks a group's start and the matching `HIDDEN` layer marks its end
|
|
215
228
|
(these are Photoshop's `lsct` section dividers).
|
|
216
229
|
|
|
230
|
+
## Editing & saving
|
|
231
|
+
|
|
232
|
+
psdparse can edit a loaded PSD (or build one from scratch) and save the result.
|
|
233
|
+
Editable: layer **parameters** (opacity/visibility/clipping/blend/fill-opacity),
|
|
234
|
+
**names**, **structure** (delete/move/duplicate/cross-file copy), **pixels** and
|
|
235
|
+
**masks**, layer-**effect values**, and **text content** — plus `create_blank`
|
|
236
|
+
for new documents.
|
|
237
|
+
|
|
238
|
+
The model is *lazy*: edits only touch in-memory fields/references — nothing is
|
|
239
|
+
re-encoded until `save()`, which re-serializes just the parts you changed. The
|
|
240
|
+
original file is never touched (`load()` mmaps it read-only; `save()` writes a
|
|
241
|
+
new path), and an **unmodified** file still round-trips byte-identically — the
|
|
242
|
+
re-serialization only kicks in for layers you actually edited. Byte-exact
|
|
243
|
+
serializers back the effect (`lfx2`) and text (EngineData) editing, so unedited
|
|
244
|
+
descriptors reproduce their original bytes exactly.
|
|
245
|
+
|
|
246
|
+
Quick map of the API (details in the subsections below):
|
|
247
|
+
|
|
248
|
+
| Want to… | Use |
|
|
249
|
+
|---|---|
|
|
250
|
+
| change opacity / visibility / blend / clipping | `layer.opacity = …`, `layer.visible = …`, `layer.set_blend_mode("mul ")` |
|
|
251
|
+
| rename a layer | `layer.name_unicode = …` / `p.set_layer_name(i, …)` |
|
|
252
|
+
| change fill opacity | `layer.fill_opacity = …` |
|
|
253
|
+
| delete / move / duplicate | `p.delete_layer(i)` / `p.move_layer(a,b)` / `p.duplicate_layer(i)` |
|
|
254
|
+
| copy a layer from another file | `p.copy_layer_from(src, j)` |
|
|
255
|
+
| replace layer pixels / add an image layer | `p.set_layer_pixels(...)` / `p.add_layer(...)` |
|
|
256
|
+
| set mask pixels + geometry / mask values | `p.set_layer_mask_pixels(...)` / `p.set_layer_mask(...)` |
|
|
257
|
+
| edit effect / descriptor values | `p.set_effects(i, changes)` / `p.set_layer_descriptor(...)` |
|
|
258
|
+
| edit text content | `p.set_text(i, str)` |
|
|
259
|
+
| edit a text run's style | `p.set_run_style(i, run, size_px=…, color=…, …)` |
|
|
260
|
+
| build a new PSD | `p.create_blank(w, h)` then `add_layer(...)` |
|
|
261
|
+
|
|
262
|
+
**8-bit RGB only** for the pixel/mask/new-document operations. The stored
|
|
263
|
+
composite image is **not** re-rendered after edits (see [Saving](#saving)).
|
|
264
|
+
|
|
265
|
+
### Parameter edits (writable properties)
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
p = psdparse.PSDFile(); p.load("in.psd")
|
|
269
|
+
p.layers[3].opacity = 128 # 0..255
|
|
270
|
+
p.layers[3].visible = False
|
|
271
|
+
p.layers[3].clipping = 1
|
|
272
|
+
p.layers[3].set_blend_mode("mul ") # 4-char key; note trailing space on 3-letter keys
|
|
273
|
+
p.layers[3].name_unicode = "新しい名前" # rename (also: p.set_layer_name(3, "..."))
|
|
274
|
+
p.save("out.psd")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`opacity` / `clipping` / `visible` / `blend_mode_key` are record-level fields,
|
|
278
|
+
re-serialized directly. The rest edit the **extra-data block**, which is
|
|
279
|
+
reconstructed on save (the layer mask and blending ranges are copied through
|
|
280
|
+
byte-for-byte unless you edit the mask itself):
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
p.layers[3].name_unicode = "新しい名前" # rename (or p.set_layer_name(3, ...))
|
|
284
|
+
p.layers[3].fill_opacity = 128 # 0..255 (the 'iOpa' block)
|
|
285
|
+
|
|
286
|
+
# edit an existing mask's values (the layer must already have a mask;
|
|
287
|
+
# the mask rectangle and pixels are unchanged):
|
|
288
|
+
p.set_layer_mask(3, disabled=True, density=200, feather=2.5, default_color=0)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`set_layer_mask` takes any subset of `disabled` (bool), `density` (0..255),
|
|
292
|
+
`feather` (px), `default_color` (0..255). To change the mask **geometry**
|
|
293
|
+
(rectangle) or its pixels, use `set_layer_mask_pixels(...)`
|
|
294
|
+
(see [Mask pixels & geometry](#mask-pixels--geometry)).
|
|
295
|
+
|
|
296
|
+
### Effect / descriptor values (E3)
|
|
297
|
+
|
|
298
|
+
Layer effects (`lfx2`) and descriptor-based fill layers are edited by passing a
|
|
299
|
+
**partial** dict of changes, shaped like `layer.effects` (see
|
|
300
|
+
[Descriptor blocks](#descriptor-blocks)). Only the leaf values you include are
|
|
301
|
+
overwritten; structure, class IDs, types and every untouched value are preserved
|
|
302
|
+
(the descriptor is re-serialized byte-for-byte apart from your edits).
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
# drop-shadow opacity 100 -> 50 %, turn all effects off:
|
|
306
|
+
p.set_effects(i, {
|
|
307
|
+
"patternFill": {"Opct": {"value": 50.0}, "enab": False},
|
|
308
|
+
"masterFXSwitch": False,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
# generic form for any descriptor key (fill layers etc.):
|
|
312
|
+
p.set_layer_descriptor(i, "SoCo", {"Clr ": {"Rd ": 255.0, "Grn ": 0.0, "Bl ": 0.0}})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Value mapping when merging: numbers → Integer/Double; `{"value": ..}` (or a bare
|
|
316
|
+
number) → UnitFloat; bool → Boolean; str → String; `{"value": ..}` or str →
|
|
317
|
+
Enumerated; nested dict → sub-descriptor (recurse); list → per-index. Unknown
|
|
318
|
+
keys are ignored, and only *existing* keys are edited (you cannot add new effect
|
|
319
|
+
fields this way). `layer.descriptor_bytes(key)` returns a block's raw bytes for
|
|
320
|
+
inspection.
|
|
321
|
+
|
|
322
|
+
### Structural edits (methods on `PSDFile`)
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
p.delete_layer(i) # remove layer i
|
|
326
|
+
p.move_layer(from_i, to_i) # reorder (to_i = index in the post-removal list)
|
|
327
|
+
new_i = p.duplicate_layer(i) # copy layer i, inserted right after it
|
|
328
|
+
new_i = p.copy_layer_from(src, j, dest_index=-1) # copy layer j from another PSDFile
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Notes:
|
|
332
|
+
- `delete_layer` / `duplicate_layer` on a single layer are exact. Deleting **one
|
|
333
|
+
half of a group's FOLDER/HIDDEN divider pair unbalances the group** — delete
|
|
334
|
+
whole groups (both dividers + contents) for clean nesting. (Unbalanced results
|
|
335
|
+
still load; the hierarchy just looks odd.)
|
|
336
|
+
- **`copy_layer_from` copies across files.** The copied layer references the
|
|
337
|
+
*source's* pixel/extra bytes lazily, so **the source `PSDFile` must stay open
|
|
338
|
+
until the destination is saved** (and, in fact, until it is garbage-collected).
|
|
339
|
+
psdparse keeps a reference to the source automatically, so simply saving before
|
|
340
|
+
discarding both is enough. Source and destination must share color mode and bit
|
|
341
|
+
depth.
|
|
342
|
+
- **New-from-scratch** documents (`create_blank`) and **text content** editing
|
|
343
|
+
(`set_text`) are covered in their own subsections below.
|
|
344
|
+
- The stored **composite (merged) image is not regenerated** after edits — it
|
|
345
|
+
stays as it was until Photoshop (or another editor) recomposites on open.
|
|
346
|
+
|
|
347
|
+
### Pixel edits (E4)
|
|
348
|
+
|
|
349
|
+
Replace an existing layer's pixels, or add a whole new image layer, from BGRA
|
|
350
|
+
bytes (the same interleave `layer_image` returns). **8-bit RGB documents only.**
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
# replace layer i's pixels (left/top kept; width/height updated)
|
|
354
|
+
p.set_layer_pixels(i, bgra_bytes, width, height)
|
|
355
|
+
|
|
356
|
+
# add a new image layer; returns its index
|
|
357
|
+
new_i = p.add_layer("name", left, top, bgra_bytes, width, height,
|
|
358
|
+
blend_mode="norm", opacity=255, dest_index=-1)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- `bgra_bytes` must be exactly `width*height*4` bytes (B, G, R, A per pixel).
|
|
362
|
+
- Channels are PackBits(RLE)-encoded on `save()`; decode round-trips exactly.
|
|
363
|
+
- `add_layer` writes the name as both a Pascal string and a Unicode `luni`
|
|
364
|
+
block, so non-ASCII names (incl. emoji) survive.
|
|
365
|
+
- `set_layer_pixels` keeps an existing **mask channel** intact (only the colour
|
|
366
|
+
channels are rebuilt). Replacing a *masked* layer at a different size leaves a
|
|
367
|
+
stale mask rectangle, though — prefer same-size replacement, or follow with
|
|
368
|
+
`set_layer_mask_pixels` to reset the mask.
|
|
369
|
+
|
|
370
|
+
### Mask pixels & geometry
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
# set/replace the mask with a grayscale buffer (0 = hidden, 255 = shown),
|
|
374
|
+
# positioned at (left, top). Creates the mask if the layer had none:
|
|
375
|
+
p.set_layer_mask_pixels(i, gray_bytes, top, left, width, height)
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- `gray_bytes` is `width*height` bytes (one per pixel). This sets both the mask
|
|
379
|
+
pixels **and** the mask rectangle (geometry). Colour channels are preserved.
|
|
380
|
+
- Mask **value** attributes (disabled / density / feather / default colour) are
|
|
381
|
+
edited separately with `set_layer_mask(...)`. 8-bit documents only.
|
|
382
|
+
|
|
383
|
+
### Text content (E6)
|
|
384
|
+
|
|
385
|
+
Replace a text layer's body text. This rewrites the embedded Adobe *EngineData*
|
|
386
|
+
(re-serialized byte-for-byte in Photoshop's format) plus the `Txt ` descriptor
|
|
387
|
+
string, and re-serializes the `TySh` block keeping the warp/bounds intact.
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
p.set_text(i, "新しいテキスト\r二行目") # \r separates lines
|
|
391
|
+
p.save("out.psd")
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
- Non-ASCII and emoji are supported (stored as UTF-16 in EngineData).
|
|
395
|
+
- A trailing newline (`\r`) is added if missing (Photoshop's convention).
|
|
396
|
+
- **`set_text` collapses styling to the first run's style** (single style run
|
|
397
|
+
over the new text). To keep per-run styling, edit runs individually with
|
|
398
|
+
`set_run_style` (below) instead of changing the text.
|
|
399
|
+
- Only the *content* changes; the layer's transform, font set and bounds are
|
|
400
|
+
kept. Raises for non-text layers.
|
|
401
|
+
|
|
402
|
+
Edit an existing run's style in place (text and run lengths unchanged):
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
# run indices match layer.text["runs"]
|
|
406
|
+
p.set_run_style(i, run=0, size_px=48.0, color=(1.0, 0.0, 0.0)) # 48px, red
|
|
407
|
+
p.set_run_style(i, run=1, tracking=100, bold=True, underline=True)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
- Any subset of: `size_px` (float), `color` ((r,g,b) or (r,g,b,a), each 0..1),
|
|
411
|
+
`tracking` / `kerning` (int), `bold` / `italic` / `underline` (bool).
|
|
412
|
+
- Keys are added to the run if it inherited them from the default style sheet.
|
|
413
|
+
- **Changing the font by name is not supported** (would require editing the
|
|
414
|
+
document's font set); re-splitting text into new runs isn't either.
|
|
415
|
+
|
|
416
|
+
### New from scratch (E5)
|
|
417
|
+
|
|
418
|
+
Build a PSD without loading one first:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
p = psdparse.PSDFile()
|
|
422
|
+
p.create_blank(1024, 768) # blank 8-bit RGB, white composite
|
|
423
|
+
p.add_layer("background", 0, 0, bg_bgra, 1024, 768)
|
|
424
|
+
p.add_layer("sprite", 100, 100, sprite_bgra, 200, 150, "norm", 255)
|
|
425
|
+
p.save("new.psd")
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
- `create_blank(width, height, mode=COLOR_MODE_RGB)` — 8-bit RGB only. Resets the
|
|
429
|
+
object to an empty document with a white stored composite.
|
|
430
|
+
- Add content with `add_layer(...)`; a document with zero layers is also valid.
|
|
431
|
+
- As with edited files, the stored composite is **not** rendered from the layers
|
|
432
|
+
— it stays white until an editor recomposites on open.
|
|
433
|
+
|
|
434
|
+
### Saving
|
|
435
|
+
|
|
436
|
+
`save(path)` returns `True`/`False`. **Do not save over a file that is currently
|
|
437
|
+
loaded** (by this or any live `PSDFile`): `load()` memory-maps the file
|
|
438
|
+
read-only, so the write is refused and `save()` returns `False` (the original is
|
|
439
|
+
never corrupted). Always save to a fresh path, then swap the files yourself if
|
|
440
|
+
you want to replace the original.
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
# Merge one layer from file B into file A, on top:
|
|
444
|
+
a = psdparse.PSDFile(); a.load("A.psd")
|
|
445
|
+
b = psdparse.PSDFile(); b.load("B.psd")
|
|
446
|
+
a.copy_layer_from(b, 0) # append B's layer 0
|
|
447
|
+
a.save("merged.psd") # b is kept alive until here
|
|
448
|
+
```
|
|
449
|
+
|
|
217
450
|
## Document resources
|
|
218
451
|
|
|
219
452
|
Read-only accessors on `PSDFile` for whole-document metadata. Each returns
|
|
@@ -224,6 +457,7 @@ p.guides # dict|None : {"horizontal_grid", "vertical_grid", "guides":[{"l
|
|
|
224
457
|
p.slices # dict|None : {"group_name", "bounding":{...}, "slices":[{...}]}
|
|
225
458
|
p.layer_comps # list[dict]: [{"id","name","comment","record_visibility","record_position","record_appearance"}]
|
|
226
459
|
p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparency_index"} for indexed-color PSDs
|
|
460
|
+
p.global_layer_mask # dict|None : {"overlay_color_space", "color":(c1,c2,c3,c4), "opacity", "kind"}
|
|
227
461
|
```
|
|
228
462
|
|
|
229
463
|
### Image resources (raw)
|
|
@@ -305,9 +539,12 @@ Notes:
|
|
|
305
539
|
- `layer.effects` is `lfx2` (object-based, Photoshop 6+). The older binary
|
|
306
540
|
`lrFX` block is **not** a descriptor and returns `None` via `descriptor()`.
|
|
307
541
|
- `descriptor(key, skip)` is the generic escape hatch: `skip` is the number of
|
|
308
|
-
version-prefix bytes before the descriptor (`-1` auto-detects for
|
|
309
|
-
|
|
310
|
-
|
|
542
|
+
version-prefix bytes before the descriptor (`-1` auto-detects for the known
|
|
543
|
+
descriptor keys: `lfx2` = 8, `SoCo`/`GdFl`/`PtFl`/`vstk`/`CgEd` = 4,
|
|
544
|
+
`vscg`/`vogk` = 8, `SoLd` = 12, otherwise 0). Use `info_keys` to discover which
|
|
545
|
+
blocks a layer carries. The smart-object/vector defaults (`SoLd`/`vstk`/`vscg`/
|
|
546
|
+
`vogk`) are set from the psd-tools layouts but not yet verified against a real
|
|
547
|
+
smart-object sample — override `skip` if a parse looks wrong.
|
|
311
548
|
- Decoding is **lazy** — the descriptor is parsed from the block's raw bytes on
|
|
312
549
|
each access, so cache the result if you read it repeatedly.
|
|
313
550
|
|
|
@@ -342,6 +579,12 @@ psdparse.LAYER_TYPE_NORMAL, LAYER_TYPE_HIDDEN, LAYER_TYPE_FOLDER,
|
|
|
342
579
|
LAYER_TYPE_ADJUST, LAYER_TYPE_FILL
|
|
343
580
|
```
|
|
344
581
|
|
|
582
|
+
Pixel extraction (`merged_image()` / `layer_image()`) supports Bitmap, Grayscale,
|
|
583
|
+
RGB, Indexed, CMYK (→RGB), **Duotone** (rendered as grayscale) and **Lab**
|
|
584
|
+
(standard D65 CIELAB→sRGB approximation — Photoshop uses D50, so highly saturated
|
|
585
|
+
colors differ slightly). **Multichannel** has no canonical RGB mapping and is not
|
|
586
|
+
rendered.
|
|
587
|
+
|
|
345
588
|
## Pixel format
|
|
346
589
|
|
|
347
590
|
All `*_image()` methods return interleaved BGRA in little-endian byte order:
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# psdparse Roadmap
|
|
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
|
+
|
|
6
|
+
## Current state (2026-08-02, v0.7.0)
|
|
7
|
+
|
|
8
|
+
- ✅ Pure C++17 parser (no Boost)
|
|
9
|
+
- ✅ mmap + StreamReader / Source abstraction
|
|
10
|
+
- ✅ Python bindings (pybind11)
|
|
11
|
+
- ✅ pytest regression suite (121 tests)
|
|
12
|
+
- ✅ Round-trip PSD save (byte-identical)
|
|
13
|
+
- ✅ Edit & save: structure / pixels / mask / parameters / effects / text / new-from-scratch (E1–E6, byte-exact re-serialization)
|
|
14
|
+
- ✅ UTF-8 path I/F (Win32 conversion internal only)
|
|
15
|
+
|
|
16
|
+
## Save: from round-trip to edit-and-save
|
|
17
|
+
|
|
18
|
+
The `save()` path started as round-trip-only (correct only when the loaded `Data` is unmodified). Structural editing is being added incrementally. The編集 API is split into phases E1–E6; each builds on the last.
|
|
19
|
+
|
|
20
|
+
- ✅ **E1 — parameter edits.** *Done 2026-08-02 (0.7.0).* Record-level fields are re-serialized from the struct, so `layer.opacity` / `layer.clipping` / `layer.visible` are writable properties and `layer.set_blend_mode("mul ")` / `layer.blend_mode_key` set the blend mode. No file-format work was needed — `writeLayerRecord` already emitted these from fields.
|
|
21
|
+
- ✅ **E2 — delete / move / duplicate / cross-file copy.** *Done 2026-08-02 (0.7.0).* `writeLayerInfo` now serializes channel data **per-layer/per-channel** (bounded `copyNFrom`) when the layer list is dirty, instead of dumping the original concatenated blob — so `PSDFile.delete_layer` / `move_layer` / `duplicate_layer` / `copy_layer_from(src, i)` all produce correct pixel data. A `Data::layersDirty` flag keeps unmodified files on the exact-blob path so byte-identical round-trip is preserved. Cross-file copy holds the source alive via pybind11 `keep_alive`. Also fixed a **pre-existing crash**: the group-linking loop in `processParsed` under-flowed its `parent` stack on unbalanced FOLDER/HIDDEN dividers (now guarded) — exposed by deleting one half of a divider pair. Validated against psd-tools (all edited files open + composite) — see `tests/test_edit.py`.
|
|
22
|
+
|
|
23
|
+
- ✅ **E4 — RLE encoder + pixel replacement + new image layers.** *Done 2026-08-02 (0.7.0).* A PackBits(RLE) encoder (`psdimage.cpp`, the exact inverse of `decodePackBits`) plus an owning `VectorReader` (a `MemoryReader` that keeps its bytes alive via `shared_ptr`) back two new APIs: `PSDFile.set_layer_pixels(i, bgra, w, h)` replaces a layer's channels, and `PSDFile.add_layer(name, l, t, bgra, w, h, blend, opacity)` builds a whole new RGBA layer — channels in `(-1,0,1,2)` order and a minimal extra-data block (empty mask/ranges + Pascal name + `luni` Unicode name + `lyid`). 8-bit RGB only. Encoder round-trip is pixel-exact across edge cases (1×1, constant, runs, 128/129/255 widths); the Unicode name (incl. emoji) and all output files were cross-checked against psd-tools (`composite()`), see `tests/test_edit_pixels.py`. Also surfaced (and documented) that saving over an mmap'd path returns `False` rather than corrupting.
|
|
24
|
+
|
|
25
|
+
- ✅ **E5 — new-from-scratch PSD construction.** *Done 2026-08-02 (0.7.0).* `PSDFile.create_blank(width, height, mode=RGB)` fills a minimal valid skeleton — header (v1, 3ch, 8-bit RGB), empty color-mode/resources, empty dirty layer list, and a white raw composite (`VectorReader`) — so you can `create_blank()` → `add_layer()` → `save()` with no source file. Validated empty and multi-layer, cross-checked with psd-tools `composite()` — see `tests/test_create.py`.
|
|
26
|
+
|
|
27
|
+
- ✅ **E3b — effect / descriptor value editing + Descriptor serializer.** *Done 2026-08-02 (0.7.0).* A full **Photoshop descriptor serializer** (`writeDescriptorBody`/`writeDescriptorItem` in `psdwrite.cpp`, the exact inverse of `psddesc`'s loader, incl. references) plus a `MemoryWriter`. `Descriptor` now records `keyOrder` so re-serialization reproduces the on-disk key order, and descriptor data is padded to 4 bytes — together these make round-trip **byte-exact** (verified: `set_effects(i, {})` then save yields a byte-identical file). `PSDFile.set_effects(i, changes)` / `set_layer_descriptor(i, key, changes)` merge a partial dict onto the parsed typed descriptor (only leaf values overwritten; classIDs/types/order preserved) and swap the re-serialized block into the extra data. `layer.descriptor_bytes(key)` exposes raw block bytes. Validated against psd-tools — see `tests/test_edit_effects.py`. **This serializer is the shared foundation E6 (text editing) needs.**
|
|
28
|
+
- ✅ **E3 — extra-data field edits (rename + mask + fill opacity).** *Done 2026-08-02 (0.7.0).* Editing of extra-data-resident fields, via a per-layer `LayerExtraData::useRawBytes=false` flag that routes save through `writeLayerExtraFromFields` (reconstruct from fields) instead of the raw-bytes passthrough:
|
|
29
|
+
- **Rename** — `layer.name_unicode = "..."` / `set_layer_name(i, name)` (Pascal + `luni`).
|
|
30
|
+
- **Fill opacity** — `layer.fill_opacity = ...` (the `iOpa` block; written as the 1-byte value + 3 filler to match Photoshop / psd-tools' `B3x` reader).
|
|
31
|
+
- **Mask values** — `set_layer_mask(i, disabled=, density=, feather=, default_color=)`; a `LayerMask::edited` flag makes the mask sub-block re-serialize from fields (`serializeLayerMask`), matching `parseLayerMask` exactly (real section gated on size≥36, params on flags bit4).
|
|
32
|
+
|
|
33
|
+
The layer mask & blending ranges are otherwise copied through byte-for-byte from `maskRaw`/`blendRaw` (captured at parse). Unmodified layers keep the exact-bytes path so round-trip identity is untouched. Learned the hard way that **layer-record tagged blocks use `padding=1` (no 4-byte alignment)** — only the *global* additional info and the Pascal name pad to 4. Validated with psd-tools reading back disabled/density/feather/bg/fill-opacity and no alignment warnings — see `tests/test_edit_name.py`, `tests/test_edit_mask.py`. **Still to do in E3:** mask *geometry* (rectangle) edits (effect value edits are done — see E3b above).
|
|
34
|
+
|
|
35
|
+
- ✅ **E6b — per-run text style editing.** *Done 2026-08-02 (0.7.0).* `PSDFile.set_run_style(i, run, size_px=/color=/tracking=/kerning=/bold=/italic=/underline=)` edits an existing style run's `StyleSheetData` values in the embedded EngineData (adding keys the run inherited), leaving text, run lengths and other runs untouched. Reuses the byte-exact EngineData serializer + the shared `editTextLayer` TySh flow. Validated with psd-tools — see `tests/test_edit_run_style.py`. **Not covered:** changing a run's font by name (needs FontSet editing) or re-splitting text into new runs.
|
|
36
|
+
- 🟡 **E6 — text-layer content editing.** *Text content done 2026-08-02 (0.7.0).* A **byte-exact Adobe EngineData serializer** (`psdengine.cpp`, the inverse of the parser — replicating psd-tools/Photoshop formatting: tab indentation by depth, `%.8f` float trimming with `0.`→`.`, inline-vs-multiline arrays, `(BOM …)` string escaping, and `Node.keyOrder`/`isInt` to preserve dict order and int-vs-float). Verified byte-exact on all 6 sample text layers. `PSDFile.set_text(i, str)` rewrites `EngineDict/Editor/Text` + collapses run-length arrays to a single run, updates the `Txt ` descriptor string, and re-serializes the `TySh` block (reusing the E3b descriptor serializer) with the version/transform prefix and warp/bounds suffix preserved verbatim. psd-tools reads the new text (incl. emoji) with no warnings — see `tests/test_edit_text.py`. **Still to do in E6:** per-run style editing (font/size/colour per range) — currently `set_text` collapses to the first run's style.
|
|
37
|
+
|
|
38
|
+
- ✅ **Mask pixels & geometry editing.** *Done 2026-08-02 (0.7.0).* `PSDFile.set_layer_mask_pixels(i, gray, top, left, w, h)` RLE-encodes a grayscale buffer into the layer's user-mask channel (`-2`) and sets the mask rectangle (creating the mask if absent), so mask geometry is editable together with its pixels. Also fixed `set_layer_pixels` to **preserve** an existing mask channel instead of dropping it. Cross-checked mask pixels + rectangle with psd-tools — see `tests/test_edit_mask_pixels.py`.
|
|
39
|
+
|
|
40
|
+
The image- and text-layer editing suite (E1–E6) is now feature-complete for the
|
|
41
|
+
common cases. Remaining niche gaps are noted per-phase above (font-by-name and
|
|
42
|
+
run re-splitting in text; composite re-rendering; smart-object embedded data).
|
|
43
|
+
|
|
44
|
+
### Phase E3 (was 4c) — extra data field re-serialization (enables rename, blend-mode change)
|
|
45
|
+
|
|
46
|
+
**Problem:** `LayerExtraData::rawBytes` is the raw bytes of the entire extra-data block (layer mask, blending range, Pascal name, additional info entries). Changing `lay.extraData.layerName` doesn't update `rawBytes`. Save would emit the stale name.
|
|
47
|
+
|
|
48
|
+
**Approach:**
|
|
49
|
+
- Add `writeLayerExtraDataFromFields(WriterBase&, const LayerExtraData&)` that re-serializes each field:
|
|
50
|
+
- `LayerMask` (0 / 20 / 36 / 40 byte variants — store a "size" hint or detect from presence of `enclosing*` fields)
|
|
51
|
+
- `LayerBlendingRange` (gray + per-channel)
|
|
52
|
+
- Pascal-string `layerName` with 4-byte padding
|
|
53
|
+
- Each `AdditionalLayerInfo`: 8BIM/8B64 + key + size + data (the inner `data` iterator can still be reused for entries we don't intend to modify, e.g. shmd, lsct)
|
|
54
|
+
- Add a per-layer flag `LayerExtraData::useRawBytes` (default true). When the user mutates a field, drop to false; `writeLayerRecord` picks the reconstruction path.
|
|
55
|
+
- For `luni` (Unicode name) records specifically, expose a setter that updates `layerNameUnicode` AND drops `rawBytes`-based emission.
|
|
56
|
+
|
|
57
|
+
**Estimated size:** ~300 lines + tests for rename / mask edit. (Blend mode, opacity and clipping already land via E1's record-level setters, so E3 is specifically the *extra-data*-resident fields: Unicode name, mask geometry/params, fill opacity, effects.)
|
|
58
|
+
|
|
59
|
+
### Phase E4 (was 4d) — RLE encoder + new layer / pixel replacement
|
|
60
|
+
|
|
61
|
+
**Problem:** No way to construct new channel data. Existing layers' `channel.imageData` iterators point into the loaded file; we have no encoder that takes raw BGRA in and produces RLE-compressed channel bytes.
|
|
62
|
+
|
|
63
|
+
**Approach:**
|
|
64
|
+
- Implement a PackBits / RLE encoder (Photoshop's per-row variant with 16-bit row-length table).
|
|
65
|
+
- Add a `psd::PSDFile::set_layer_pixels(int idx, const uint8_t *bgra, int w, int h)` API that:
|
|
66
|
+
1. Splits BGRA into B / G / R / A planes,
|
|
67
|
+
2. RLE-compresses each plane,
|
|
68
|
+
3. Replaces `channel.imageData` with a fresh in-memory `MemoryReader` over the compressed bytes.
|
|
69
|
+
- Add `psd::PSDFile::add_layer(...)` for fully-synthesized layers (caller supplies bbox, name, blend mode, BGRA pixels).
|
|
70
|
+
- Python: `p.replace_layer_image(idx, image_bytes, w, h)` and `p.add_layer(name, bbox, image_bytes, blend_mode=...)`.
|
|
71
|
+
|
|
72
|
+
**Estimated size:** ~500 lines (mostly encoder) + a fixture-based round-trip test (encode, then decode through `getLayerImage`, then compare with input).
|
|
73
|
+
|
|
74
|
+
### Phase E5 (was 4e) — new-from-scratch PSD construction
|
|
75
|
+
|
|
76
|
+
Once E4 lands, the user can do:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
p = psdparse.PSDFile.create_blank(width=1024, height=768, mode=psdparse.COLOR_MODE_RGB)
|
|
80
|
+
p.add_layer("background", bbox=(0, 0, 1024, 768), pixels=bg_bgra)
|
|
81
|
+
p.add_layer("character", bbox=(100, 100, 800, 700), pixels=char_bgra)
|
|
82
|
+
p.save("out.psd")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This is mostly a constructor that fills `Data` with a minimal-but-valid skeleton (default header, empty image resources, empty color mode data, sentinel `channelImageData`, etc.).
|
|
86
|
+
|
|
87
|
+
### Phase E6 — text-layer editing
|
|
88
|
+
|
|
89
|
+
Editing text content/style means writing the `TySh` block back: re-serializing the type-tool **descriptor** (currently read-only in `psddesc.*`) and, harder, re-emitting the embedded Adobe **EngineData** mini-language (`psdengine.cpp` only parses it). Needs a Descriptor serializer + an EngineData writer. Deferred until the image-layer editing set (E3–E5) is complete, per the stated priority (image first, then text).
|
|
90
|
+
|
|
91
|
+
## Other future work
|
|
92
|
+
|
|
93
|
+
- ✅ **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
|
+
|
|
95
|
+
**Deferred (need targeted sample PSDs, next turn):**
|
|
96
|
+
- **Non-RGB `FillColor`** — only `/Type 1` (RGB) decoded today; grayscale-mode / CMYK-mode text needs a sample to confirm `/Type` + `/Values` layout.
|
|
97
|
+
- **Warp text** — lives in the `TySh` *warp* descriptor (currently skipped, not in EngineData); needs samples with each warp style + non-zero bend/distortion.
|
|
98
|
+
- **Area (paragraph) vs point text / text box bounds** and **text-on-path** — need samples.
|
|
99
|
+
- **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.
|
|
100
|
+
- ✅ **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`.
|
|
101
|
+
- ✅ **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.
|
|
102
|
+
- ✅ **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`.
|
|
103
|
+
- ✅ **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.
|
|
104
|
+
- 16-bit (`Lr16`) and 32-bit-float (`Lr32`) layer data: currently captured in `layerAndMaskTrailing` for round-trip but not exposed as decoded pixels.
|
|
105
|
+
- Layer mask re-emission for masks > 20 bytes (the field-based save path; round-trip already preserves them via raw bytes).
|
|
106
|
+
- Multichannel color extraction in `getLayerImage` (no canonical RGB representation); smart-object embedded file (`lnkD`) extraction.
|
|
107
|
+
- Linux / macOS testing. mmap path uses POSIX `mmap` but hasn't been built / tested there.
|