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.
Files changed (60) hide show
  1. {psdparse-0.5.0 → psdparse-0.7.0}/PKG-INFO +22 -3
  2. {psdparse-0.5.0 → psdparse-0.7.0}/README.md +21 -2
  3. {psdparse-0.5.0 → psdparse-0.7.0}/docs/PYTHON_API.md +253 -10
  4. psdparse-0.7.0/docs/ROADMAP.md +107 -0
  5. {psdparse-0.5.0 → psdparse-0.7.0}/docs/SUPPORT.md +59 -25
  6. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdbase.h +27 -0
  7. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psddata.h +46 -11
  8. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psddesc.cpp +1 -0
  9. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psddesc.h +3 -0
  10. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdengine.cpp +277 -1
  11. psdparse-0.7.0/psdparse/psdengine.h +59 -0
  12. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdfile.cpp +142 -0
  13. psdparse-0.7.0/psdparse/psdfile.h +148 -0
  14. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdimage.cpp +326 -6
  15. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdparse.cpp +28 -6
  16. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdparse.h +47 -0
  17. psdparse-0.7.0/psdparse/psdwrite.cpp +446 -0
  18. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdwrite.h +55 -0
  19. {psdparse-0.5.0 → psdparse-0.7.0}/pyproject.toml +1 -1
  20. psdparse-0.7.0/python/psdparse_module.cpp +1142 -0
  21. {psdparse-0.5.0 → psdparse-0.7.0}/tests/conftest.py +53 -0
  22. psdparse-0.7.0/tests/test_colormodes.py +49 -0
  23. psdparse-0.7.0/tests/test_create.py +82 -0
  24. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_descriptors.py +8 -0
  25. psdparse-0.7.0/tests/test_edit.py +147 -0
  26. psdparse-0.7.0/tests/test_edit_effects.py +101 -0
  27. psdparse-0.7.0/tests/test_edit_mask.py +99 -0
  28. psdparse-0.7.0/tests/test_edit_mask_pixels.py +86 -0
  29. psdparse-0.7.0/tests/test_edit_name.py +72 -0
  30. psdparse-0.7.0/tests/test_edit_pixels.py +113 -0
  31. psdparse-0.7.0/tests/test_edit_run_style.py +87 -0
  32. psdparse-0.7.0/tests/test_edit_text.py +81 -0
  33. psdparse-0.7.0/tests/test_mask_extras.py +61 -0
  34. psdparse-0.5.0/docs/ROADMAP.md +0 -91
  35. psdparse-0.5.0/psdparse/psdengine.h +0 -34
  36. psdparse-0.5.0/psdparse/psdfile.h +0 -79
  37. psdparse-0.5.0/psdparse/psdwrite.cpp +0 -223
  38. psdparse-0.5.0/python/psdparse_module.cpp +0 -648
  39. {psdparse-0.5.0 → psdparse-0.7.0}/.gitignore +0 -0
  40. {psdparse-0.5.0 → psdparse-0.7.0}/CMakeLists.txt +0 -0
  41. {psdparse-0.5.0 → psdparse-0.7.0}/CMakePresets.json +0 -0
  42. {psdparse-0.5.0 → psdparse-0.7.0}/LICENSE +0 -0
  43. {psdparse-0.5.0 → psdparse-0.7.0}/Makefile +0 -0
  44. {psdparse-0.5.0 → psdparse-0.7.0}/docs/ARCHITECTURE.md +0 -0
  45. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/CMakeLists.txt +0 -0
  46. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/bmp.cpp +0 -0
  47. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psd_cli.cpp +0 -0
  48. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdlayer.cpp +0 -0
  49. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdlayer.h +0 -0
  50. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdresource.cpp +0 -0
  51. {psdparse-0.5.0 → psdparse-0.7.0}/psdparse/psdresource.h +0 -0
  52. {psdparse-0.5.0 → psdparse-0.7.0}/python/CMakeLists.txt +0 -0
  53. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_header.py +0 -0
  54. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_images.py +0 -0
  55. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_layers.py +0 -0
  56. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_metadata.py +0 -0
  57. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_resources.py +0 -0
  58. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_save.py +0 -0
  59. {psdparse-0.5.0 → psdparse-0.7.0}/tests/test_text.py +0 -0
  60. {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.5.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
- - Python wrapper: `import psdparse` → `PSDFile.load(path) / layer_image(i) / merged_image() / save(path)`.
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, at a glance:
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
- - Python wrapper: `import psdparse` → `PSDFile.load(path) / layer_image(i) / merged_image() / save(path)`.
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, at a glance:
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. **The current implementation is round-trip-only**: `p.load(a); p.save(b)` produces a byte-identical copy. Modifying `layers` after load and then saving is not yet supported (see [ROADMAP.md](ROADMAP.md) for the per-channel / RLE-encoder work needed for that).
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": False, # bit4: density/feather present (not decoded yet)
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, `real` is a dict
184
- with `flags`, `background`, and the enclosing `top/left/bottom/right`. The mask
185
- **pixels** are unchanged from before — fetch them with
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 `lfx2` = 8
309
- and `SoCo`/`GdFl`/`PtFl` = 4, otherwise 0). Use `info_keys` to discover which
310
- blocks a layer carries.
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.