psdparse 0.8.0__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {psdparse-0.8.0 → psdparse-0.8.1}/PKG-INFO +1 -1
  2. {psdparse-0.8.0 → psdparse-0.8.1}/docs/PYTHON_API.md +3 -0
  3. {psdparse-0.8.0 → psdparse-0.8.1}/docs/ROADMAP.md +16 -2
  4. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdfile.cpp +34 -0
  5. {psdparse-0.8.0 → psdparse-0.8.1}/pyproject.toml +1 -1
  6. {psdparse-0.8.0 → psdparse-0.8.1}/python/psdparse_module.cpp +4 -2
  7. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit.py +45 -0
  8. {psdparse-0.8.0 → psdparse-0.8.1}/.gitignore +0 -0
  9. {psdparse-0.8.0 → psdparse-0.8.1}/CMakeLists.txt +0 -0
  10. {psdparse-0.8.0 → psdparse-0.8.1}/CMakePresets.json +0 -0
  11. {psdparse-0.8.0 → psdparse-0.8.1}/LICENSE +0 -0
  12. {psdparse-0.8.0 → psdparse-0.8.1}/Makefile +0 -0
  13. {psdparse-0.8.0 → psdparse-0.8.1}/README.md +0 -0
  14. {psdparse-0.8.0 → psdparse-0.8.1}/docs/ARCHITECTURE.md +0 -0
  15. {psdparse-0.8.0 → psdparse-0.8.1}/docs/SUPPORT.md +0 -0
  16. {psdparse-0.8.0 → psdparse-0.8.1}/examples/README.md +0 -0
  17. {psdparse-0.8.0 → psdparse-0.8.1}/examples/composite.py +0 -0
  18. {psdparse-0.8.0 → psdparse-0.8.1}/examples/extract_layers.py +0 -0
  19. {psdparse-0.8.0 → psdparse-0.8.1}/examples/variations.py +0 -0
  20. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/CMakeLists.txt +0 -0
  21. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/bmp.cpp +0 -0
  22. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psd_cli.cpp +0 -0
  23. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdbase.h +0 -0
  24. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psddata.h +0 -0
  25. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psddesc.cpp +0 -0
  26. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psddesc.h +0 -0
  27. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdengine.cpp +0 -0
  28. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdengine.h +0 -0
  29. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdfile.h +0 -0
  30. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdimage.cpp +0 -0
  31. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdlayer.cpp +0 -0
  32. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdlayer.h +0 -0
  33. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdparse.cpp +0 -0
  34. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdparse.h +0 -0
  35. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdresource.cpp +0 -0
  36. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdresource.h +0 -0
  37. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdwrite.cpp +0 -0
  38. {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdwrite.h +0 -0
  39. {psdparse-0.8.0 → psdparse-0.8.1}/python/CMakeLists.txt +0 -0
  40. {psdparse-0.8.0 → psdparse-0.8.1}/tests/conftest.py +0 -0
  41. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_colormodes.py +0 -0
  42. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_comps.py +0 -0
  43. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_create.py +0 -0
  44. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_descriptors.py +0 -0
  45. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_effects.py +0 -0
  46. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_mask.py +0 -0
  47. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_mask_pixels.py +0 -0
  48. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_name.py +0 -0
  49. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_pixels.py +0 -0
  50. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_run_style.py +0 -0
  51. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_text.py +0 -0
  52. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_header.py +0 -0
  53. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_images.py +0 -0
  54. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_layers.py +0 -0
  55. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_mask_extras.py +0 -0
  56. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_merged.py +0 -0
  57. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_metadata.py +0 -0
  58. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_resources.py +0 -0
  59. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_save.py +0 -0
  60. {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_text.py +0 -0
  61. {psdparse-0.8.0 → psdparse-0.8.1}/tools/psd_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: psdparse
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Fast PSD (Photoshop) reader/writer — C++17 core with pybind11 bindings
5
5
  Keywords: psd,photoshop,parser,image,graphics
6
6
  Author-Email: wamsoft <wtnbgo@gmail.com>
@@ -331,6 +331,9 @@ new_i = p.copy_layer_from(src, j, dest_index=-1) # copy layer j from another PS
331
331
  ```
332
332
 
333
333
  Notes:
334
+ - `duplicate_layer` / `copy_layer_from` assign the copy a **fresh `layer_id`**
335
+ (max existing lyid + 1, like Photoshop) so layer IDs stay unique within the
336
+ document; the `lyid` additional-info block is rewritten on save.
334
337
  - `delete_layer` / `duplicate_layer` on a single layer are exact. Deleting **one
335
338
  half of a group's FOLDER/HIDDEN divider pair unbalances the group** — delete
336
339
  whole groups (both dividers + contents) for clean nesting. (Unbalanced results
@@ -3,12 +3,12 @@
3
3
  For a feature-by-feature account of what is and isn't supported today, see
4
4
  [SUPPORT.md](SUPPORT.md). This file tracks planned work.
5
5
 
6
- ## Current state (2026-08-02, v0.8.0)
6
+ ## Current state (2026-08-12, v0.8.1)
7
7
 
8
8
  - ✅ Pure C++17 parser (no Boost)
9
9
  - ✅ mmap + StreamReader / Source abstraction
10
10
  - ✅ Python bindings (pybind11)
11
- - ✅ pytest regression suite (137 tests)
11
+ - ✅ pytest regression suite (140 tests)
12
12
  - ✅ Python composite recipes (`examples/`) + `set_merged_image`; layer comps exposed (`layer.comp_states`)
13
13
  - ✅ Round-trip PSD save (byte-identical)
14
14
  - ✅ Edit & save: structure / pixels / mask / parameters / effects / text / new-from-scratch (E1–E6, byte-exact re-serialization)
@@ -91,6 +91,20 @@ Editing text content/style means writing the `TySh` block back: re-serializing t
91
91
 
92
92
  ## Other future work
93
93
 
94
+ - ✅ **`duplicate_layer` assigns a fresh `lyid`.** *Done 2026-08-12.* The
95
+ duplicated layer used to copy the source's `lyid` (layer ID) verbatim, so the
96
+ output PSD contained duplicate layer IDs — Photoshop itself assigns a *new*
97
+ ID when duplicating. Downstream tools that use `lyid` as a persistent layer
98
+ identity (e.g. elements_console's uitool reference resolution) then saw
99
+ ambiguous matches. Fix (`psdfile.cpp`): `assignFreshLayerId` sets
100
+ `layerId = max existing lyid + 1` and replaces (or appends) the `lyid`
101
+ additional-info entry on the copy, flipping `useRawBytes=false` so save
102
+ re-serializes it; applied in both `duplicateLayer` and `copyLayerFrom` (the
103
+ latter picks an ID unique within the *destination* document). `add_layer`
104
+ already picked max+1 — verified. Tests (`tests/test_edit.py`): duplicate →
105
+ all `layer.layer_id` distinct → save → reload → still distinct; double
106
+ duplicate cross-checked via psd-tools tagged blocks; `copy_layer_from`
107
+ uniqueness in the destination.
94
108
  - ✅ **Text layer content extraction (`TySh` type-tool additional info).** *Done 2026-07-27.* `lay.text` returns `{"text", "orientation", "justification", "transform", "runs":[{"length","font","size_px","color","tracking","kerning","auto_kerning"}]}` (or `None` for non-text layers). Implementation: `psddesc` now reads `tdta` raw data (length-prefixed), `loadLayerTypeTool` (`psdlayer.cpp`) parses the `TySh` header + text descriptor, and `psdengine.cpp` parses the embedded Adobe *EngineData* mini-language (FontSet / StyleRun / ParagraphRun) into per-run styling. Validated against `tests/data/fontsample.psd` (multi-font/size/color, **vertical**, emoji, tracking) — see `tests/test_text.py`.
95
109
 
96
110
  **Deferred (need targeted sample PSDs, next turn):**
@@ -321,6 +321,38 @@ bool PSDFile::setAdditionalInfoBytes(int index, int key, const uint8_t *data, in
321
321
 
322
322
  // --- 構造編集 --------------------------------------------------------------
323
323
 
324
+ namespace {
325
+
326
+ // 文書内の既存 lyid の最大値 + 1 (lyid 無しレイヤは layerId<=0 なので無視される)。
327
+ int nextLayerId(const std::vector<LayerInfo> &layers) {
328
+ int maxId = 0;
329
+ for (const auto &l : layers) if (l.layerId > maxId) maxId = l.layerId;
330
+ return maxId + 1;
331
+ }
332
+
333
+ // レイヤに新しい lyid を割り当て、extra data の 'lyid' ブロックも新値で置き換える。
334
+ // Photoshop は複製時に新 ID を振るので、複製系 API はこれを通す。
335
+ void assignFreshLayerId(LayerInfo &lay, int newId) {
336
+ lay.layerId = newId;
337
+ auto buf = std::make_shared<std::vector<uint8_t>>();
338
+ buf->push_back((uint8_t)(newId >> 24)); buf->push_back((uint8_t)(newId >> 16));
339
+ buf->push_back((uint8_t)(newId >> 8)); buf->push_back((uint8_t)(newId & 0xff));
340
+ LayerExtraData &ex = lay.extraData;
341
+ for (auto &a : ex.additionalLayers) {
342
+ if (a.key == 'lyid') {
343
+ delete a.data;
344
+ a.data = new VectorReader(buf);
345
+ a.size = 4;
346
+ ex.useRawBytes = false;
347
+ return;
348
+ }
349
+ }
350
+ ex.additionalLayers.push_back(AdditionalLayerInfo(0, 'lyid', 4, new VectorReader(buf)));
351
+ ex.useRawBytes = false;
352
+ }
353
+
354
+ } // anonymous namespace
355
+
324
356
  bool PSDFile::deleteLayer(int index) {
325
357
  if (index < 0 || index >= (int)layerList.size()) return false;
326
358
  layerList.erase(layerList.begin() + index);
@@ -342,6 +374,7 @@ bool PSDFile::moveLayer(int from, int to) {
342
374
  int PSDFile::duplicateLayer(int index) {
343
375
  if (index < 0 || index >= (int)layerList.size()) return -1;
344
376
  LayerInfo copy = layerList[(size_t)index];
377
+ assignFreshLayerId(copy, nextLayerId(layerList)); // Photoshop 同様、複製は新 ID
345
378
  layerList.insert(layerList.begin() + index + 1, copy);
346
379
  layersDirty = true;
347
380
  return index + 1;
@@ -352,6 +385,7 @@ int PSDFile::copyLayerFrom(const PSDFile &src, int srcIndex, int destIndex) {
352
385
  LayerInfo copy = src.layerList[(size_t)srcIndex]; // channel/extra は src を参照
353
386
  copy.owner = this; // owner はどこからも参照されないが整合のため付け替え
354
387
  copy.parent = nullptr;
388
+ assignFreshLayerId(copy, nextLayerId(layerList)); // 取り込み先の文書内で一意な ID
355
389
  int pos = (destIndex < 0 || destIndex > (int)layerList.size())
356
390
  ? (int)layerList.size() : destIndex;
357
391
  layerList.insert(layerList.begin() + pos, copy);
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "psdparse"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  description = "Fast PSD (Photoshop) reader/writer — C++17 core with pybind11 bindings"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -922,7 +922,8 @@ PYBIND11_MODULE(psdparse, m) {
922
922
  },
923
923
  py::arg("index"),
924
924
  "Duplicate the layer at `index` (inserted right after it). Returns the "
925
- "new layer's index. The copy shares the source's pixel bytes lazily.")
925
+ "new layer's index. The copy shares the source's pixel bytes lazily "
926
+ "but gets a fresh layer_id (max existing lyid + 1, like Photoshop).")
926
927
  .def("copy_layer_from",
927
928
  [](psd::PSDFile &self, const psd::PSDFile &src, int src_index, int dest_index) {
928
929
  int r = self.copyLayerFrom(src, src_index, dest_index);
@@ -935,7 +936,8 @@ PYBIND11_MODULE(psdparse, m) {
935
936
  "`dest_index` (default: append). Returns the new index. The copied "
936
937
  "layer references `source`'s pixel/extra bytes lazily, so `source` is "
937
938
  "kept alive until this file is garbage-collected (do not let it close "
938
- "before save()). Assumes matching color mode and bit depth.")
939
+ "before save()). The copy gets a layer_id unique within this document. "
940
+ "Assumes matching color mode and bit depth.")
939
941
  .def("set_effects",
940
942
  [](psd::PSDFile &self, int index, py::dict changes) {
941
943
  editLayerDescriptor(self, index, 'lfx2', 8, changes);
@@ -91,6 +91,41 @@ def test_duplicate_layer(psd_group, tmp_path):
91
91
  assert _hash(q.layer_image(6, "image")) == src_hash
92
92
 
93
93
 
94
+ def _assert_ids_distinct(p):
95
+ """All positive layer IDs in the document must be unique."""
96
+ ids = [l.layer_id for l in p.layers if l.layer_id > 0]
97
+ assert len(ids) == len(set(ids)), f"duplicate layer ids: {sorted(ids)}"
98
+
99
+
100
+ def test_duplicate_layer_assigns_fresh_id(psd_group, tmp_path):
101
+ # Photoshop assigns a NEW lyid when duplicating; copying it verbatim breaks
102
+ # downstream tools that use lyid as a persistent layer identity.
103
+ src_id = psd_group.layers[5].layer_id
104
+ new_index = psd_group.duplicate_layer(5)
105
+ new_id = psd_group.layers[new_index].layer_id
106
+ assert new_id != src_id
107
+ _assert_ids_distinct(psd_group)
108
+ q = _reload(psd_group, tmp_path) # lyid block must be rewritten too
109
+ assert q.layers[new_index].layer_id == new_id
110
+ assert q.layers[5].layer_id == src_id
111
+ _assert_ids_distinct(q)
112
+
113
+
114
+ def test_duplicate_layer_ids_distinct_psd_tools(psd_group, tmp_path):
115
+ import pytest
116
+ PSDImage = pytest.importorskip("psd_tools").PSDImage
117
+ psd_group.duplicate_layer(5)
118
+ psd_group.duplicate_layer(5) # two copies of the same source
119
+ dst = tmp_path / "dup.psd"
120
+ assert psd_group.save(str(dst))
121
+ from psd_tools.constants import Tag
122
+ img = PSDImage.open(str(dst))
123
+ records = img._record.layer_and_mask_information.layer_info.layer_records
124
+ ids = [r.tagged_blocks.get_data(Tag.LAYER_ID, None) for r in records]
125
+ ids = [i for i in ids if i is not None and i > 0]
126
+ assert len(ids) == len(set(ids)), f"duplicate layer ids: {sorted(ids)}"
127
+
128
+
94
129
  # --- E2: move --------------------------------------------------------------
95
130
 
96
131
  def test_move_layer(psd_group, tmp_path):
@@ -116,6 +151,16 @@ def test_copy_layer_from(psd_group, psd_maskparams, tmp_path):
116
151
  assert _hash(q.layer_image(new_index, "image")) == src_hash
117
152
 
118
153
 
154
+ def test_copy_layer_from_assigns_fresh_id(psd_group, psd_maskparams, tmp_path):
155
+ # The imported layer's lyid must be unique within the DESTINATION document,
156
+ # regardless of what id it carried in the source.
157
+ new_index = psd_group.copy_layer_from(psd_maskparams, 0)
158
+ _assert_ids_distinct(psd_group)
159
+ q = _reload(psd_group, tmp_path)
160
+ assert q.layers[new_index].layer_id == psd_group.layers[new_index].layer_id
161
+ _assert_ids_distinct(q)
162
+
163
+
119
164
  def test_copy_survives_source_gc(psd_group, sample_maskparams_psd, tmp_path):
120
165
  # The copied layer references the source's bytes lazily; keep_alive must
121
166
  # keep the source object alive until this file is saved even if the local
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes