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.
- {psdparse-0.8.0 → psdparse-0.8.1}/PKG-INFO +1 -1
- {psdparse-0.8.0 → psdparse-0.8.1}/docs/PYTHON_API.md +3 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/docs/ROADMAP.md +16 -2
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdfile.cpp +34 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/pyproject.toml +1 -1
- {psdparse-0.8.0 → psdparse-0.8.1}/python/psdparse_module.cpp +4 -2
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit.py +45 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/.gitignore +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/CMakeLists.txt +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/CMakePresets.json +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/LICENSE +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/Makefile +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/README.md +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/docs/SUPPORT.md +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/examples/README.md +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/examples/composite.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/examples/extract_layers.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/examples/variations.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/CMakeLists.txt +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/bmp.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdbase.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psddata.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psddesc.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psddesc.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdengine.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdengine.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdfile.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdimage.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdlayer.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdlayer.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdparse.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdparse.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdresource.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdwrite.cpp +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/psdparse/psdwrite.h +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/python/CMakeLists.txt +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/conftest.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_colormodes.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_comps.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_create.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_descriptors.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_effects.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_mask.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_mask_pixels.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_name.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_pixels.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_run_style.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_edit_text.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_header.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_images.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_layers.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_mask_extras.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_merged.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_metadata.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_resources.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_save.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tests/test_text.py +0 -0
- {psdparse-0.8.0 → psdparse-0.8.1}/tools/psd_export.py +0 -0
|
@@ -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-
|
|
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 (
|
|
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);
|
|
@@ -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()).
|
|
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
|
|
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
|