The Python bindings expose a small surface area focused on reading PSDs, extracting raw pixel data, and saving a loaded file back as PSD. Pixel data is returned as raw BGRA bytes objects suitable for handing to PIL, NumPy, etc.
All public path arguments are Python str. pybind11 transparently encodes them as UTF-8 before reaching the C++ layer.
The bindings cover the C++ library's public editing surface; the authoritative
C++ headers are psdparse/psdfile.h + psdparse/psdengine.h.
import psdparsep = psdparse.PSDFile()p.load(path: str) -> boolMemory-map the file at path and parse. On Windows the path is converted UTF-8 → UTF-16 internally before CreateFileMappingW. Layer pixels are paged in lazily — the parse step only reads structural metadata (a few hundred KB even for very large PSDs).
p.load_bytes(data: bytes) -> boolParse a PSD already loaded into a Python bytes. The bytes are copied into an internal std::vector. Useful when the file came from somewhere other than disk.
p.load_streamed(path: str) -> boolOpen path as a std::ifstream and parse via StreamReader. Functionally equivalent to load() but exercises the stream code path — handy for testing and for environments where mmap isn't appropriate (network paths, etc.).
p.save(path: str) -> boolSave the currently loaded (and optionally edited) data back to disk as PSD.
An unmodified file round-trips byte-identically (p.load(a); p.save(b)),
and edits are re-serialized on save — see Editing & saving.
Do not save over a file that is currently loaded (returns False; see
Saving).
p.header.width # int
p.header.height # int
p.header.channels # int
p.header.depth # int (8 / 16 / 32)
p.header.mode # int (use psdparse.COLOR_MODE_* constants to compare)
p.header.version # int (1 = PSD, 2 = PSB)
p.header.is_psb # bool -- version == 2 (large document format)p.layers # list[LayerInfo] -- read-only, flat, bottom-to-top
p.roots # list[int] -- top-level layer indices (tree view)
p.children(i) # list[int] -- direct children of layers[i]; i=-1 for roots
p.merged_alpha # bool
p.is_loaded # bool
p.layer_source # None, or "Lr16" / "Lr32" when a 16/32-bit document keeps
# its layers in that document-level block (save() rewrites it)See Layer hierarchy for how the flat list and the tree view relate.
p.merged_image() -> bytesReturns the composite image as raw BGRA bytes. Length = width * height * 4. Raises RuntimeError if the PSD didn't store a composite (rare).
p.layer_image(index: int, mode: str = "masked") -> bytesReturns the pixels of one layer as raw BGRA bytes.
mode="masked"(default) — the image with the layer mask applied to alphamode="image"— the image only, ignoring maskmode="mask"— the mask only, rendered as grayscale-in-BGRA
Length = layer.width * layer.height * 4. Returns b"" for empty layers (width == 0 or height == 0). Raises IndexError on bad index, ValueError on bad mode.
p.composite(effects: bool = True, background: tuple | None = None) -> (bytes, dict)Renders the document from its layers instead of returning the stored merged
image: blend modes (including Photoshop's special handling of fill opacity for
linear burn / color dodge / hard mix and the like), pass-through / isolated
groups, clipping (layer, group or adjustment layer as the base), knockout,
layer and vector masks (density / feather), fill layers (solid / gradient /
pattern), shapes with their strokes, adjustment layers (all but color lookup),
fill opacity, channel restriction, artboards, and layer effects when
effects=True. Returns (bgra, stats) — canvas-sized BGRA (straight alpha)
and a dict counting what could not be reproduced:
skipped_adjustments (e.g. color lookup), unsupported_clip_base,
unsupported_effects (e.g. noise gradients). See
SUPPORT.md for how closely each
part follows Photoshop.
background=(r, g, b) composites onto an opaque colour instead of
transparency.
Large images are split across threads (rows of each blend / adjustment /
blur / distance pass), and the common blend modes use an AVX2 kernel on CPUs
that have it (picked at run time; PSDFX_SIMD=0 turns it off). The result is
byte-identical to a single-threaded, non-SIMD run.
psdparse.set_threads(count: int) -> None # 0 = automatic (default), 1 = single-threaded
psdparse.get_threads() -> intThe automatic count is the number of logical cores, capped at 16; the
PSDFX_THREADS environment variable overrides it when set_threads was not
called.
p.render_layer(index: int, effects: bool = True) -> (bytes, left, top, width, height) | NoneRenders a single layer (masks, fill / shape content, opacity and effects) onto
a transparent surface, not composited with the layers below. The rectangle
includes what the effects add around the layer (shadow, glow, stroke). None
for groups, dividers, adjustment layers and empty layers.
p.merged_has_transparency -> boolWhether the stored merged image carries transparency. merged_image() is
un-matted from the white background Photoshop composites transparent documents
onto, so it can be compared with composite() directly.
out, stats = p.composite()
p.set_merged_image(out) # refresh the stored composite after editsThe drawing code is a plain C API (psdparse/psdfx.h, see
PSDFX.md), so other hosts can call the same routines.
Read-only view of one layer.
| Attribute | Type | Notes |
|---|---|---|
top, left, bottom, right |
int |
layer bounding box on canvas |
width, height |
int |
derived from bbox |
opacity |
int |
0..255 |
fill_opacity |
int |
0..255. Writable (the iOpa block) |
clipping |
int |
0=base, 1=non-base |
blend_mode_key |
int |
raw 4cc value (e.g. 'norm' as int) |
blend_mode |
BlendMode enum |
parsed blend mode |
layer_type |
LayerType enum |
NORMAL / HIDDEN / FOLDER / ADJUST / FILL / TEXT |
layer_id |
int |
-1 if unset |
channels |
list[ChannelInfo] |
per-channel id+length |
name |
str |
raw Pascal-string name (CP932 etc on Japanese PSDs — pybind11 may raise UnicodeDecodeError when read) |
name_unicode |
str |
UTF-16 Unicode name from luni record (preferred). Writable — assigning renames the layer (see Editing) |
parent_index |
int |
index into PSDFile.layers of the enclosing folder, or -1 for top level — see Layer hierarchy |
children |
list[int] |
indices of the direct children, bottom-to-top; empty for non-folders. Dividers excluded — see Layer hierarchy |
is_group |
bool |
True for FOLDER layers |
text |
dict | None |
text-layer content & style (None for non-text layers) — see below |
mask |
dict | None |
layer mask geometry & flags (None when the layer has no mask) — see below |
blending_ranges |
dict | None |
"Blend If" ranges (None when absent) — see below |
effects |
dict | None |
layer effects (lfx2) as a descriptor dict — see Descriptor blocks |
fill |
dict | None |
fill-layer content (solid/gradient/pattern) — see Descriptor blocks |
name_raw |
bytes |
the Pascal layer name as stored (system encoding). name decodes it as UTF-8 and falls back to name_unicode (or replacement characters) instead of raising |
sheet_color |
dict | None |
layer-panel color label (lclr): {"index", "name"} — None when no lclr block |
legacy_effects |
dict | None |
old-style effects (lrFX) as {type: values}: common_state, drop_shadow, inner_shadow, outer_glow, inner_glow, bevel, solid_fill; colors are [color_space, c0, c1, c2, c3], blend modes 4-char keys, blur / intensity / distance the stored 32-bit values |
adjustment |
dict | None |
adjustment layer parameters {"type", "key", ...} — see Adjustment layers |
artboard |
dict | None |
artboard (artb): {"rect": (left, top, right, bottom), "preset_name", "background_type", "color"} |
smart_object |
dict | None |
smart object placement (SoLd / SoLE / PlLd): {"key", "uuid", "placed_id", "page", "total_pages", "anti_alias", "placed_type", "transform", "size", "filters", "linked_file"} — transform is the 4 corners (x, y) (top-left, top-right, bottom-right, bottom-left), linked_file an index into PSDFile.linked_files |
vector_mask |
dict | None |
vector mask (vmsk, or vsms on shape layers): {"key", "inverted", "not_linked", "disabled", "path"} — see Paths |
shape |
dict | None |
shape fill / stroke / live-shape origins (vscg / vstk / vogk) — see Shapes and path rasterization |
comp_states |
dict |
per layer-comp state {comp_id: {"enabled", "offset_x", "offset_y"}} (empty if the layer is in no comps). enabled says if the layer shows in that comp — see Layer comps |
info_keys |
list[str] |
4cc keys of every additional-layer-info block on this layer |
visible |
bool |
flag bit 1 inverted |
transparency_protected |
bool |
flag bit 0 |
obsolete |
bool |
flag bit 2 |
pixel_data_irrelevant |
bool |
flag bit 4 |
Tip: For Japanese PSDs prefer name_unicode and fall back to name only inside a try / except UnicodeDecodeError.
For text layers (layer_type == LayerType.TEXT) this returns a dict parsed from
the TySh type-tool block and its embedded Adobe EngineData. For every other
layer it returns None.
{
"text": "普通のテキスト\r二行目\r三行目", # full string; line breaks are CR ('\r')
"orientation": "horizontal", # or "vertical"
"justification": 0, # first paragraph: 0=left 1=right 2=center
"transform": [xx, xy, yx, yy, tx, ty], # affine placement transform (tx,ty = translation)
"runs": [ # per-run character styling, in text order
{
"length": 8, # run length in UTF-16 code units (see note)
"font": "NotoSansJP-Thin", # resolved font-set family name
"size_px": 75.0, # font size (px)
"color": (1.0, 0.0, 0.0, 1.0), # RGBA, each 0..1 (None if unspecified)
"tracking": -100, # letter spacing, 1/1000 em
"kerning": 0, # manual kerning
"auto_kerning": False, # metrics/optical kerning enabled
"bold": False, # FauxBold
"italic": False, # FauxItalic
"underline": False, # Underline
"leading": None, # line spacing in px; None = auto
"baseline_shift": 0.0, # px
"strikethrough": False,
"font_caps": 0, # 0 normal / 1 small caps / 2 all caps
"font_baseline": 0, # 0 normal / 1 superscript / 2 subscript
"horizontal_scale": 1.0, # 1.0 = 100%
"vertical_scale": 1.0,
"ligatures": True,
},
...
],
"paragraphs": [ # paragraphs (split on '\r'), in text order
{"length": 8, "justification": 0, # length in UTF-16 code units
"first_line_indent": 0.0, "start_indent": 0.0, "end_indent": 0.0, # px
"space_before": 0.0, "space_after": 0.0, # px
"auto_leading": 1.2, "hyphenate": False},
...
],
"warp": None, # or {"style": "warpArc", "value": 50.0,
# "horizontal_distortion", "vertical_distortion",
# "rotate": "horizontal"|"vertical"}
}Lengths (size_px, leading, baseline_shift, indents, spacing) are in the
same units: document pixels. Values a run or paragraph inherits from the
document's default style sheet are scaled by dpi/72 like size_px.
Notes:
lengthis in UTF-16 code units, matching Photoshop's EngineDataRunLengthArray. Astral characters (e.g. emoji) count as 2. To slice the text by runs, index intotext.encode("utf-16-le")(2 bytes per unit) rather than the Pythonstr(which is code-point indexed).- Adjacent runs may share identical styling — Photoshop stores runs as authored, so the run split does not always coincide with a style change.
coloris decoded from EngineData'sFillColor /Type 1(RGB) and reordered from its on-disk[A R G B]to(R, G, B, A). Non-RGB fill types are not yet decoded (colorisNone).bold/italic/underlineare Photoshop's faux styles (FauxBold,FauxItalic,Underline) — the same three flagsset_run_stylewrites, so what you set reads back here (added in 0.9.0).paragraphssplits the body on\r;justificationis per paragraph, while the top-leveljustificationis just the first paragraph's value.
for layer in p.layers:
t = layer.text
if t is None:
continue
print(t["text"], "→", {r["font"] for r in t["runs"]})None for layers without a mask. When present:
{
"top": 8, "left": 10, "bottom": 40, "right": 50, # mask bbox on canvas
"width": 40, "height": 32,
"default_color": 0, # 0..255, area outside the stored mask rect
"flags": 0, # raw flag byte
"relative": False, # bit0: position relative to layer
"disabled": False, # bit1: mask disabled
"inverted": False, # bit2: invert (obsolete)
"from_render": False, # bit3: mask from rendering other data
"has_parameters": True, # bit4: density/feather block present
"user_density": 128, # 0..255, or None if not stored
"user_feather": 2.5, # feather radius (px), or None
"vector_density": None, # 0..255, or None
"vector_feather": None, # feather radius (px), or None
"real": None, # or a nested dict (below) for a real/user mask
}When the record carries a real (user + vector combined) mask (block size ≥ 36),
real is a dict with flags, background, and the enclosing
top/left/bottom/right. The mask pixels are unchanged — fetch them with
p.layer_image(i, "mask").
user_density/user_feather/vector_density/vector_feather are only non-None
when has_parameters is set and the corresponding value was stored. (Fixed in
0.6.0: the real-mask section was previously decoded one byte off.)
None when absent. gray is the composite range; channels has one entry per
channel. Each value is the raw 32-bit packed range (two 16-bit black/white
sub-ranges — split yourself if you need the individual sliders):
{
"gray": (65535, 65535), # (source, dest)
"channels": [(0, 65535), (0, 65535), ...],
}Layers are a flat list in file order — that is PSD's own encoding, and both
drawing order and the editing API (delete_layer(i), move_layer(from, to), …)
are defined on it. A group is a FOLDER layer above its contents plus a
matching HIDDEN layer (</Layer group>) below them; these are Photoshop's
lsct section dividers.
For walking the structure, use the tree view — a derived read-only view that hides that encoding:
def walk(i, depth=0):
l = p.layers[i]
print(" " * depth + l.name_unicode)
for c in l.children:
walk(c, depth + 1)
for r in p.roots: # top-level layers, bottom-to-top
walk(r)p.roots— indices of the top-level layers, bottom-to-top.layer.children— indices of that layer's direct children, bottom-to-top; empty for non-folders. Dividers are left out — they are an encoding artifact, not content, so walking the tree reaches every real layer exactly once.layer.is_group—TrueforFOLDERlayers.p.children(i)— same asp.layers[i].children;p.children(-1)isroots.
Both views stay in sync: the hierarchy is recomputed after every structural edit (add / delete / move / duplicate / copy).
layer.parent_index is still there (-1 = top level, otherwise the index of
the enclosing FOLDER layer) if you would rather build the tree yourself. Note
it reports the divider as a child of its folder, which is why children filters
dividers out.
psdparse can edit a loaded PSD (or build one from scratch) and save the result.
Editable: layer parameters (opacity/visibility/clipping/blend/fill-opacity),
names, structure (delete/move/duplicate/cross-file copy, folder-aware
moves), pixels and masks, layer-effect values, and text
(content, per-run style, run/paragraph structure, alignment, placement and flow
box) — plus create_blank for new documents.
The model is lazy: edits only touch in-memory fields/references — nothing is
re-encoded until save(), which re-serializes just the parts you changed. The
original file is never touched (load() mmaps it read-only; save() writes a
new path), and an unmodified file still round-trips byte-identically — the
re-serialization only kicks in for layers you actually edited. Byte-exact
serializers back the effect (lfx2) and text (EngineData) editing, so unedited
descriptors reproduce their original bytes exactly.
Quick map of the API (details in the subsections below):
| Want to… | Use |
|---|---|
| change opacity / visibility / blend / clipping | layer.opacity = …, layer.visible = …, layer.set_blend_mode("mul ") |
| rename a layer | layer.name_unicode = … / p.set_layer_name(i, …) |
| change fill opacity | layer.fill_opacity = … |
| delete / move / duplicate | p.delete_layer(i) / p.move_layer(a,b) / p.duplicate_layer(i) |
| move a layer or a whole folder within its level | p.move_layer_sibling(i, up=True) / p.group_span(i) / p.move_layer_range(...) |
| copy a layer from another file | p.copy_layer_from(src, j) |
| replace layer pixels / add an image layer | p.set_layer_pixels(...) / p.add_layer(...) |
| set mask pixels + geometry / mask values | p.set_layer_mask_pixels(...) / p.set_layer_mask(...) |
| edit effect / descriptor values | p.set_effects(i, changes) / p.set_layer_descriptor(...) |
| edit text content | p.set_text(i, str) |
| edit a text run's style | p.set_run_style(i, run, font=…, size_px=…, color=…, …) |
| replace text and its run / paragraph structure | p.set_rich_text(i, str, runs, paragraphs) |
| change paragraph alignment | p.set_justification(i, 2) |
| list a text layer's fonts | p.text_fonts(i) |
| move a text layer / resize its flow box | p.move_text_layer(i, dx, dy) / p.set_text_bounds(i, l,t,r,b) |
| build a new PSD | p.create_blank(w, h) then add_layer(...) |
| write a composited preview back | p.set_merged_image(bgra) |
| blank the stored composite | p.set_merged_image_solid() |
choose how Txt2 is handled |
p.set_text_engine_policy(0/1/2) / p.drop_text_engine_data() |
8-bit RGB only for the pixel/mask/new-document operations. The stored
composite image is not re-rendered after edits (see Saving); use
set_merged_image_solid() to blank it rather than ship a stale one.
p = psdparse.PSDFile(); p.load("in.psd")
p.layers[3].opacity = 128 # 0..255
p.layers[3].visible = False
p.layers[3].clipping = 1
p.layers[3].set_blend_mode("mul ") # 4-char key; note trailing space on 3-letter keys
p.layers[3].name_unicode = "新しい名前" # rename (also: p.set_layer_name(3, "..."))
p.save("out.psd")opacity / clipping / visible / blend_mode_key are record-level fields,
re-serialized directly. The rest edit the extra-data block, which is
reconstructed on save (the layer mask and blending ranges are copied through
byte-for-byte unless you edit the mask itself):
p.layers[3].name_unicode = "新しい名前" # rename (or p.set_layer_name(3, ...))
p.layers[3].fill_opacity = 128 # 0..255 (the 'iOpa' block)
# edit an existing mask's values (the layer must already have a mask;
# the mask rectangle and pixels are unchanged):
p.set_layer_mask(3, disabled=True, density=200, feather=2.5, default_color=0)set_layer_mask takes any subset of disabled (bool), density (0..255),
feather (px), default_color (0..255). To change the mask geometry
(rectangle) or its pixels, use set_layer_mask_pixels(...)
(see Mask pixels & geometry).
Layer effects (lfx2) and descriptor-based fill layers are edited by passing a
partial dict of changes, shaped like layer.effects (see
Descriptor blocks). Only the leaf values you include are
overwritten; structure, class IDs, types and every untouched value are preserved
(the descriptor is re-serialized byte-for-byte apart from your edits).
# drop-shadow opacity 100 -> 50 %, turn all effects off:
p.set_effects(i, {
"patternFill": {"Opct": {"value": 50.0}, "enab": False},
"masterFXSwitch": False,
})
# generic form for any descriptor key (fill layers etc.):
p.set_layer_descriptor(i, "SoCo", {"Clr ": {"Rd ": 255.0, "Grn ": 0.0, "Bl ": 0.0}})Value mapping when merging: numbers → Integer/Double; {"value": ..} (or a bare
number) → UnitFloat; bool → Boolean; str → String; {"value": ..} or str →
Enumerated; nested dict → sub-descriptor (recurse); list → per-index. Unknown
keys are ignored, and only existing keys are edited (you cannot add new effect
fields this way). layer.descriptor_bytes(key) returns a block's raw bytes for
inspection.
p.delete_layer(i) # remove layer i
p.move_layer(from_i, to_i) # reorder (to_i = index in the post-removal list)
new_i = p.duplicate_layer(i) # copy layer i, inserted right after it
new_i = p.copy_layer_from(src, j, dest_index=-1) # copy layer j from another PSDFile
# wrap layers[from_index : from_index+count] in a group;
# returns the folder layer's index
f_i = p.add_folder("group name", from_index, count,
closed=False, blend_mode="pass", opacity=255)Notes:
add_folderinserts the two marker layers PSD uses for a group: a</Layer group>divider (LayerType.HIDDEN) below the contents and the folder layer (LayerType.FOLDER) above them, both with an empty (0×0) rect. Nest groups by wrapping an outer range that already contains inner ones.blend_modedefaults to"pass"(pass-through), matching Photoshop's new-group default;count=0creates an empty folder atfrom_index. Indices belowfrom_indexare unaffected, so building bottom-up and wrapping each group as soon as its contents are in place needs no index bookkeeping.move_layermoves one entry of the flat list. Moving aFOLDERlayer does not drag its divider and contents along — the group comes apart. Use the folder-aware moves below for whole groups.layer.parent_indexis refreshed after every structural edit (delete / move / duplicate / copy / add re-link the hierarchy, 0.9.0), so the tree you read back matches the edited list. Before 0.9.0 it kept the values computed at load time and went stale.duplicate_layer/copy_layer_fromassign the copy a freshlayer_id(max existing lyid + 1, like Photoshop) so layer IDs stay unique within the document; thelyidadditional-info block is rewritten on save.delete_layer/duplicate_layeron a single layer are exact. Deleting one half of a group's FOLDER/HIDDEN divider pair unbalances the group — delete whole groups (both dividers + contents) for clean nesting. (Unbalanced results still load; the hierarchy just looks odd.)copy_layer_fromcopies across files. The copied layer references the source's pixel/extra bytes lazily, so the sourcePSDFilemust stay open until the destination is saved (and, in fact, until it is garbage-collected). psdparse keeps a reference to the source automatically, so simply saving before discarding both is enough. Source and destination must share color mode and bit depth.- New-from-scratch documents (
create_blank) and text content editing (set_text) are covered in their own subsections below. - The stored composite (merged) image is not regenerated after edits — it stays as it was until Photoshop (or another editor) recomposites on open.
start, count = p.group_span(i) # the block layer i occupies
new_i = p.move_layer_sibling(i, up=True) # -> int, or None if it can't move
p.move_layer_range(start, count, to_index)group_span(i)returns(start, count). For aFOLDERthat is the whole[HIDDEN divider … FOLDER]block including nested groups; for anything else it is(i, 1).move_layer_sibling(i, up=True)swaps the layer with its next sibling at the same level.up=Trueis one step up in Photoshop's layer panel (later in the flat list). A folder travels with its contents and steps over a sibling folder in one move; the move never crosses into another folder. Returns the layer's new index, orNonewhen it is already at the end of its level (nothing moves in that case).move_layer_range(from_index, count, to_index)is the low-level block move;to_indexis an index in the list before removal. Moving a range onto itself is a no-op. RaisesIndexErroron bad arguments.
# move a folder (with everything inside it) to the bottom of the document
start, count = p.group_span(folder_index)
p.move_layer_range(start, count, 0)Replace an existing layer's pixels, or add a whole new image layer, from BGRA
bytes (the same interleave layer_image returns). 8-bit RGB documents only.
# replace layer i's pixels (left/top kept; width/height updated)
p.set_layer_pixels(i, bgra_bytes, width, height)
# add a new image layer; returns its index
new_i = p.add_layer("name", left, top, bgra_bytes, width, height,
blend_mode="norm", opacity=255, dest_index=-1)bgra_bytesmust be exactlywidth*height*4bytes (B, G, R, A per pixel).- Channels are PackBits(RLE)-encoded on
save(); decode round-trips exactly. add_layerwrites the name as both a Pascal string and a Unicodeluniblock, so non-ASCII names (incl. emoji) survive.set_layer_pixelskeeps an existing mask channel intact (only the colour channels are rebuilt). Replacing a masked layer at a different size leaves a stale mask rectangle, though — prefer same-size replacement, or follow withset_layer_mask_pixelsto reset the mask.
# set/replace the mask with a grayscale buffer (0 = hidden, 255 = shown),
# positioned at (left, top). Creates the mask if the layer had none:
p.set_layer_mask_pixels(i, gray_bytes, top, left, width, height)gray_bytesiswidth*heightbytes (one per pixel). This sets both the mask pixels and the mask rectangle (geometry). Colour channels are preserved.- Mask value attributes (disabled / density / feather / default colour) are
edited separately with
set_layer_mask(...). 8-bit documents only.
Replace a text layer's body text. This rewrites the embedded Adobe EngineData
(re-serialized byte-for-byte in Photoshop's format) plus the Txt descriptor
string, and re-serializes the TySh block keeping the warp/bounds intact.
p.set_text(i, "新しいテキスト\r二行目") # \r separates lines
p.save("out.psd")- Non-ASCII and emoji are supported (stored as UTF-16 in EngineData).
- A trailing newline (
\r) is added if missing (Photoshop's convention). set_textcollapses styling to the first run's style (single style run over the new text). To keep per-run styling, edit runs individually withset_run_style(below) instead of changing the text.- Only the content changes; the layer's transform, font set and bounds are kept. Raises for non-text layers.
- The document-wide
Txt2block is kept in step — without this the edit is invisible in Photoshop. See Txt2.
Edit an existing run's style in place (text and run lengths unchanged):
# run indices match layer.text["runs"]
p.set_run_style(i, run_index=0, size_px=48.0, color=(1.0, 0.0, 0.0)) # 48px, red
p.set_run_style(i, run_index=1, tracking=100, bold=True, underline=True)
p.set_run_style(i, run_index=1, font="Arial")- Any subset of:
font(str),size_px(float),color((r,g,b) or (r,g,b,a), each 0..1),tracking/kerning(int),bold/italic/underline(bool). - Keys are added to the run if it inherited them from the default style sheet.
fontis matched against the document's font set (p.text_fonts(i)) and appended to it when the name isn't there yet. The name is the PostScript-ish font-set name Photoshop stores ("NotoSansJP-Regular","Arial"), not a display name — Photoshop resolves it when it opens the file, so a name the machine doesn't have falls back to a substitute font.
p.text_fonts(i) # -> ['NotoSansJP-Regular', 'HGPKyokashotai', 'AdobeInvisFont', ...]Photoshop CS3 and later keep the whole document's text engine state in a Txt2
block at the end of the layer & mask section, and read it in preference to
each layer's TySh. Rewriting TySh alone therefore leaves the edit invisible
in Photoshop: it shows the old text, not merely a stale raster. psdparse keeps
the two in step for you.
p.set_text(i, "new body")
p.text_engine_texts() # -> ['new body\r', ...] in TextIndex order
p.has_text_engine_data() # -> True
p.text_engine_dropped # -> False (nothing had to be given up)The default policy mirrors what it can and drops the block when it cannot:
p.set_text_engine_policy(0) # SYNC (default)
p.set_text_engine_policy(1) # REMOVE — always drop it, TySh wins
p.set_text_engine_policy(2) # KEEP — leave it alone (edits stay invisible)
p.drop_text_engine_data() # drop it now- Body text and run lengths are mirrored. Growing, shrinking and changing the number of paragraphs are all fine; Photoshop recomputes its own layout cache.
- Style, alignment, text box and placement are not. Mirroring them would mean
rebuilding
Txt2's own style sheets (numeric key aliases), so those edits drop the block instead — Photoshop then readsTySh, which is correct. Ask withp.text_engine_droppedafterwards. set_rich_text(..., formatting_unchanged=True)is the escape hatch for callers that always pass absolute styles (so "no field given" doesn't mean "keep what was there"): they cannot signal a text-only edit through the presence of style fields, so they declare it.p.layer_text_index(i)is the layer's position withinTxt2(itsTyShTextIndex).
Note that Photoshop does not re-render a text layer when it opens a file, so
the layer's raster stays as it was until Photoshop redraws it. Only Photoshop can
produce that picture; psdtext ships a tools/update-text-layers.jsx that makes it
redraw every text layer.
set_text collapses the whole layer to one style run. To change the text and
keep (or rebuild) per-part formatting, use set_rich_text:
p.set_rich_text(i, "赤い字\r青い字",
runs=[{"length": 4, "color": (1.0, 0.0, 0.0), "size_px": 40.0},
{"length": 4, "color": (0.0, 0.0, 1.0), "bold": True}],
paragraphs=[{"length": 4, "justification": 0},
{"length": 4, "justification": 2}])runs— a list of dicts, each withlength(UTF-16 code units, required) plus any of theset_run_stylestyle keys (font,size_px,color,tracking,kerning,bold,italic,underline).- Every run starts from the original first run as a template; only the keys you give are overridden, so unspecified formatting keeps the layer's look.
- If the run lengths don't add up to the text length, the last run absorbs
the difference (extended or truncated); zero-length runs are dropped.
Omitting
runs(or passing[]) collapses to a single run, likeset_text. paragraphs— a list of{"length": int, "justification": int}(0=left, 1=right, 2=center), optionally with the paragraph keys ofset_paragraph_stylebelow; same length-absorbing rule. Omitting it collapses to a single paragraph. Run dicts likewise accept the keyword-only keys ofset_run_style(leading,baseline_shift,strikethrough, …).- A trailing
\ris appended if missing (Photoshop's convention), solengthaccounting should include it — or just let the last run absorb it. - Lengths are UTF-16 code units, so astral characters (emoji) count as 2:
len(part.encode("utf-16-le")) // 2.
Alignment alone, without touching text or runs:
p.set_justification(i, 2) # every paragraph -> center
p.set_justification(i, 0, para_index=1) # only the 2nd paragraph -> leftOther paragraph values (px unless noted; only the ones you pass change):
p.set_paragraph_style(i, 0, first_line_indent=12, start_indent=4, end_indent=6,
space_before=8, space_after=10,
auto_leading=1.5, # multiplier used by auto leading
hyphenate=True, justification=2)
p.set_paragraph_style(i, space_after=6) # para_index=-1 (default): every paragraphCharacter values beyond the original set are keyword-only on set_run_style:
p.set_run_style(i, 0, leading=40) # px; turns auto leading off
p.set_run_style(i, 0, leading="auto") # back to auto
p.set_run_style(i, 0, baseline_shift=-3, strikethrough=True, font_caps=2,
font_baseline=1, horizontal_scale=1.25, vertical_scale=0.9,
ligatures=False)After any text edit, layer.text is re-read from the rewritten data, so it
shows the new values without reloading the file.
p.text_transform(i) # -> (xx, xy, yx, yy, tx, ty), same as text["transform"]
p.set_text_transform(i, (1, 0, 0, 1, 111.0, 222.0))
p.move_text_layer(i, dx, dy)
p.text_bounds(i) # -> (left, top, right, bottom)
p.set_text_bounds(i, 0, 0, 300, 200)move_text_layer(i, dx, dy)is the one to use for plain translation: it shifts the transform'stx/tyand the layer rectangle (and the mask rectangle, if any), so the PSD's baked raster travels with the text instead of being left behind.set_text_transformonly rewrites theTyShblock.text_bounds/set_text_boundsare the descriptor'sbounds— the flow box, in the transform's local coordinates (addtx/tyfor canvas coordinates). Only paragraph (box) text actually re-flows into a new box; for point text Photoshop rebuilds the box from the glyphs on open. The storedboundingBoxis clamped into the new box so the layer still displays sanely.- psdparse does not re-render text — the layer's pixels are whatever Photoshop last baked. Moving or re-flowing shows up properly once Photoshop reopens the file.
Build a PSD without loading one first:
p = psdparse.PSDFile()
p.create_blank(1024, 768) # blank 8-bit RGB, white composite
p.add_layer("background", 0, 0, bg_bgra, 1024, 768)
p.add_layer("sprite", 100, 100, sprite_bgra, 200, 150, "norm", 255)
p.save("new.psd")create_blank(width, height, mode=COLOR_MODE_RGB)— 8-bit RGB only. Resets the object to an empty document with a white stored composite.- Add content with
add_layer(...); a document with zero layers is also valid. - As with edited files, the stored composite is not rendered from the layers — it stays white until an editor recomposites on open.
The stored composite (merged) image is not re-rendered after edits. If you
composite the layers yourself (e.g. with Pillow — see examples/),
write the result back as the PSD's preview with:
p.set_merged_image(bgra_bytes) # canvas-sized BGRA (header.width*header.height*4)If you cannot recomposite — text layers can only be rendered by Photoshop — blank it instead of shipping a picture that is no longer true:
p.set_merged_image_solid() # white by default; RLE-compressed, so it stays
# small on any canvasThis is what Photoshop itself writes with "Maximize PSD compatibility" off. Photoshop composites from the layers, so what it displays is unaffected.
save(path) returns True/False. Do not save over a file that is currently
loaded (by this or any live PSDFile): load() memory-maps the file
read-only, so the write is refused and save() returns False (the original is
never corrupted). Always save to a fresh path, then swap the files yourself if
you want to replace the original.
# Merge one layer from file B into file A, on top:
a = psdparse.PSDFile(); a.load("A.psd")
b = psdparse.PSDFile(); b.load("B.psd")
a.copy_layer_from(b, 0) # append B's layer 0
a.save("merged.psd") # b is kept alive until hereRead-only accessors on PSDFile for whole-document metadata. Each returns
None (or an empty list) when the PSD lacks that resource.
p.guides # dict|None : {"horizontal_grid", "vertical_grid", "guides":[{"location","direction"}]}
p.paths # list[dict]: saved paths (2000-2997) and the work path (1025) — see Paths below
p.annotations # list[dict]: notes ('Anno'): kind, open, icon_rect, popup_rect, color, author, name, mod_date, text
p.alpha_channels # list[dict]: extra channels after the color channels (alpha / spot / merged transparency): plane, name, kind, color_space, color, opacity
p.merged_channel(plane) # bytes: one merged-image channel as 8-bit gray (width*height)
p.patterns # list[dict]: document patterns (Patt/Pat2/Pat3): id, name, mode, width, height
p.pattern_image(i_or_id) # (bgra_bytes, width, height) | None
p.linked_files # list[dict]: smart-object source files (lnk2/lnk3/lnkD/lnkE): kind, uuid, name, file_type, size, has_data
p.linked_file_data(i_or_uuid) # bytes|None: the embedded file (or the cached copy of an external one)
p.slices # dict|None : {"group_name", "bounding":{...}, "slices":[{...}]}
p.layer_comps # list[dict]: [{"id","name","comment","record_visibility","record_position","record_appearance"}]
p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparency_index"} for indexed-color PSDs
p.global_layer_mask # dict|None : {"overlay_color_space", "color":(c1,c2,c3,c4), "opacity", "kind"}p.layer_comps lists the document's layer comps (saved layer-state snapshots).
Which layers each comp shows is on the layers: layer.comp_states maps a
comp id to that layer's state in the comp:
{ comp_id: {"enabled": True, "offset_x": 0, "offset_y": 0}, ... }enabled is whether the layer is visible in that comp; a layer the comp doesn't
mention isn't in the dict (fall back to its current visibility). To render a comp
(visibility only — position/appearance overrides aren't applied):
for comp in p.layer_comps:
show = {i for i, l in enumerate(p.layers)
if (l.comp_states[comp["id"]]["enabled"]
if comp["id"] in l.comp_states else l.visible)}
# composite `show` with Pillow — see examples/variations.py (composite_comp)examples/variations.py --comps renders every comp this way.
Most image resources are exposed as their raw on-disk bytes; decoding (EXIF tags, rendering the thumbnail, parsing the ICC profile) is left to the caller.
p.image_resource_ids # list[int] : IDs of every resource present
p.image_resource(id) # bytes|None : raw bytes of the resource with that ID
p.icc_profile # bytes|None : ICC profile (resource 1039)
p.exif # bytes|None : EXIF block (1058)
p.xmp # str|None : XMP packet (1060), UTF-8 XML
p.thumbnail # dict|None : {"format","width","height","bits","resource_id","data"}-
thumbnail— resource 1036 (RGB) or legacy 1033 (BGR). Whenformat == "jpeg",datais JFIF JPEG bytes ready forPIL.Image.open:import io from PIL import Image th = p.thumbnail if th and th["format"] == "jpeg": Image.open(io.BytesIO(th["data"])).save("thumb.png")
-
xmpdecodes as UTF-8str; if a file's packet is not valid UTF-8, read the raw bytes withp.image_resource(1060)instead.
layer.adjustment is None or a dict with "type", "key" (the block's 4cc)
and the values below. Values are as stored (integers unless noted).
| type | values |
|---|---|
levels |
records: rows [input_black, input_white, output_black, output_white, gamma] (gamma as float). Row 0 is the composite, then one row per channel (29 or more rows) |
curves |
channels: channel index per curve (0 = composite); points: per curve a list of (input, output) pairs, or maps (256 values each) for map-style curves |
hue_saturation |
colorize, colorization [hue, saturation, lightness], master [hue, saturation, lightness], ranges: 6 rows [range1..4, hue, saturation, lightness] |
color_balance |
shadows / midtones / highlights [cyan_red, magenta_green, yellow_blue], preserve_luminosity |
brightness_contrast |
brightness, contrast, mean, lab_only; newer files also carry the real values in descriptor (CgEd) |
selective_color |
method (0 relative / 1 absolute), records: 10 rows [cyan, magenta, yellow, black] (row 0 unused, then reds, yellows, greens, cyans, blues, magentas, whites, neutrals, blacks) |
threshold / posterize |
level / levels |
invert |
— |
channel_mixer |
monochrome, channels: rows [red, green, blue, extra, constant] per output channel |
photo_filter |
version; color_space + color (version 2) or xyz (version 3); density, preserve_luminosity |
exposure |
exposure, offset, gamma (floats) |
gradient_map |
name, reversed, dithered, color_stops rows [location, midpoint, kind, c0, c1, c2, c3], transparency_stops rows [location, midpoint, opacity], plus interpolation, method (version 3), noise settings |
vibrance / black_white / color_lookup |
descriptor (the block's descriptor as a dict) |
"incomplete": True is added when a binary block ended early (the values read so
far are kept). Read-only for now; saving keeps the original bytes.
layer.vector_mask["path"] and each PSDFile.paths[i]["path"] share one shape:
{"subpaths": [{"closed": True, "operation": 1, "index": 0,
"knots": [{"anchor": (x, y), "preceding": (x, y), "leaving": (x, y),
"linked": True}, ...]}, ...],
"initial_fill": 0, # initial fill rule record, None if absent
"clipboard": None} # or {"top", "left", "bottom", "right", "resolution"}Points are (x, y) in document pixels (stored as 8.24 fixed-point fractions of
the canvas). preceding is the control point on the way into the anchor,
leaving the one on the way out. operation is how a subpath combines with
the ones before it: -1/1 combine, 2 subtract, 3 intersect, 0 exclude.
PSDFile.paths entries are {"id", "kind": "saved"|"work", "name": bytes, "unicode_name": str|None, "path"} — name is the raw Pascal resource name (system
encoding, e.g. Shift-JIS), unicode_name comes from the document's pths block.
Read-only for now; saving keeps the original bytes.
layer.shape gathers what a shape layer keeps besides its path (None when the
layer has none of vscg / vstk / vogk):
{"fill_enabled": True, "stroke_enabled": True,
"fill": {"kind": "solid" | "gradient" | "pattern", "descriptor": {...}}, # 'vscg', or None
"stroke": {"width": 4.0, # px ('pt' widths converted with the document dpi)
"alignment": "inside" | "center" | "outside",
"cap": "butt" | "round" | "square", "join": "miter" | "round" | "bevel",
"miter_limit": 100.0, # ratio of the line width
"dashes": [8.0, 4.0], "dash_offset": 0.0, # px (stored as multiples of the width)
"opacity": 1.0, "blend_mode": "normal",
"content_kind": "solid", "content": {...}}, # 'vstk', or None
"origins": [{"type": "rectangle" | "rounded_rectangle" | "line" | "ellipse" | None,
"type_id": 1, "index": 0, # index = the subpath's 'index'
"box": (l, t, r, b), "radii": (tl, tr, br, bl) | None,
"line": (x0, y0, x1, y1) | None, "line_weight": float | None,
"invalidated": False}], # 'vogk' live-shape origins
"path": {...}} # the vector mask path (same shape as above)p.shape_mask(i, part="both") -> (bytes, left, top, width, height) | NoneRasterizes the layer's path to 8-bit anti-aliased coverage: part="fill" (the
path area, inverted masks included), "stroke" (the vstk stroke with its
width, alignment, caps, joins and dashes) or "both". The rectangle holds the
path and its stroke in document pixels and may extend past the canvas. Mask
density / feather are not applied.
Paths from anywhere — layer.vector_mask["path"], PSDFile.paths[i]["path"],
or your own list of subpaths (knots may be plain (x, y) tuples for straight
segments; closed defaults to True, operation to -1) — can be flattened or
rasterized with module functions:
psdparse.flatten_path(path, tolerance=0.1)
# -> [{"closed", "operation", "points": [(x, y), ...]}, ...]
psdparse.rasterize_path(path, width, height, left=0, top=0) -> bytes
psdparse.stroke_path(path, width, height, left=0, top=0, line_width=1.0,
alignment="center", cap="butt", join="miter",
miter_limit=4.0, dashes=(), dash_offset=0.0) -> bytesThe raster is width x height bytes whose top-left sits at (left, top) in
path coordinates. Subpaths combine like Photoshop's shape operations
(-1 joins the previous subpath, 0 exclude, 1 combine, 2 subtract,
3 intersect) and initial_fill: 1 starts from a filled raster. inside /
outside alignment puts the whole width on one side of closed subpaths. The
same routines are in the C API (psdfx_flatten_subpath, psdfx_fill_path,
psdfx_stroke_path), and composite() / render_layer() use them to draw
shape strokes.
guides— grid spacing (in 1/32 px) and each guide'slocation(1/32 px from origin) anddirection("vertical"/"horizontal").slices— Photoshop slices (v6). Each slice hasid,name, bbox (left/top/right/bottom),url,target,message,alt_tag,cell_text, alignment, and an(r, g, b, a)colortuple.color_table— only present forCOLOR_MODE_INDEXEDPSDs;colorsis the palette andtransparency_indexis-1when there is no transparent entry.
Photoshop stores layer effects, fill-layer content and many other tagged blocks as its generic descriptor tree (the same OSType structure used throughout PSD). These accessors decode a block into nested Python dicts/lists so they can be read without a decoder per feature.
layer.effects # dict|None : object-based effects ('lfx2')
layer.fill # dict|None : {"type": "solid"|"gradient"|"pattern", "data": {...}}
layer.info_keys # list[str] : every additional-info 4cc key present on the layer
layer.descriptor(key, skip=-1) # dict|None : parse an arbitrary key as a descriptorValue mapping (descriptor item → Python):
| Descriptor type | Python |
|---|---|
| Integer / Double | int / float |
| Boolean | bool |
| String / Alias / Class | str |
| UnitFloat | {"value": float, "unit": str} (unit: percent, angle, pixels, …) |
| Enumerated | {"type": str, "value": str} |
| Descriptor (nested) | dict (keys are raw 4cc, may end in a space, e.g. "Scl ") |
| List | list |
RawData (tdta) |
bytes |
LargeInteger (comp) |
int |
UnitFloats (UnFl) |
{"values": list[float], "unit": str} |
ObjectArray (ObAr) |
dict (like a nested descriptor; its values are usually UnFl) |
| Reference / unknown | None |
fx = layer.effects
if fx:
print("effects on:", fx.get("masterFXSwitch"))
po = fx.get("patternFill") # a nested descriptor dict
if po:
print("pattern overlay opacity:", po["Opct"]["value"]) # -> 100.0Notes:
- Keys are the raw 4cc as stored (trailing spaces preserved) — index with
the exact string, e.g.
fx["Scl "],color["Rd "]. layer.effectsislfx2(object-based, Photoshop 6+). The older binarylrFXblock is not a descriptor and returnsNoneviadescriptor().descriptor(key, skip)is the generic escape hatch:skipis the number of version-prefix bytes before the descriptor (-1auto-detects for the known descriptor keys:lfx2= 8,SoCo/GdFl/PtFl/vstk/CgEd= 4,vscg/vogk= 8,SoLd= 12, otherwise 0). Useinfo_keysto discover which blocks a layer carries. The smart-object/vector defaults (SoLd/vstk/vscg/vogk) are set from the psd-tools layouts but not yet verified against a real smart-object sample — overrideskipif a parse looks wrong.- Decoding is lazy — the descriptor is parsed from the block's raw bytes on each access, so cache the result if you read it repeatedly.
psdparse.LayerType.NORMAL
psdparse.LayerType.HIDDEN
psdparse.LayerType.FOLDER
psdparse.LayerType.ADJUST
psdparse.LayerType.FILL
psdparse.LayerType.TEXTpsdparse.BlendMode.NORMAL
psdparse.BlendMode.MULTIPLY
psdparse.BlendMode.SCREEN
psdparse.BlendMode.OVERLAY
psdparse.BlendMode.PASS_THROUGH
# ... 28 values total. Use BlendMode.<name>.value to get the int.Module-level integer constants for direct comparison:
psdparse.COLOR_MODE_BITMAP, COLOR_MODE_GRAYSCALE, COLOR_MODE_INDEXED,
COLOR_MODE_RGB, COLOR_MODE_CMYK, COLOR_MODE_MULTICHANNEL,
COLOR_MODE_DUOTONE, COLOR_MODE_LAB
psdparse.LAYER_TYPE_NORMAL, LAYER_TYPE_HIDDEN, LAYER_TYPE_FOLDER,
LAYER_TYPE_ADJUST, LAYER_TYPE_FILLPixel extraction (merged_image() / layer_image()) supports Bitmap, Grayscale,
RGB, Indexed, CMYK (→RGB), Duotone (rendered as grayscale) and Lab
(standard D65 CIELAB→sRGB approximation — Photoshop uses D50, so highly saturated
colors differ slightly). Multichannel has no canonical RGB mapping and is not
rendered.
All *_image() methods return interleaved BGRA in little-endian byte order:
byte 0: B (blue)
byte 1: G (green)
byte 2: R (red)
byte 3: A (alpha)
This matches PIL's "BGRA" raw decoder:
from PIL import Image
img = Image.frombytes("RGBA", (w, h), bgra_bytes, "raw", "BGRA")
img.save("out.png")For NumPy:
import numpy as np
arr = np.frombuffer(bgra_bytes, dtype=np.uint8).reshape(h, w, 4)
# arr[..., [0,1,2,3]] is B, G, R, A- Invalid paths:
load()/load_streamed()returnFalse(no exception). - Invalid PSD data:
load*()returnsFalseand the object is left empty (is_loaded == False). - Out-of-range
layer_image(index): raisesIndexError. - Bad
modestring: raisesValueError. - Reading
nameon a non-UTF-8 byte sequence: raisesUnicodeDecodeError— see the tip above.
load()mmaps the file. The mapping is held by thePSDFileinstance; it is unmapped when the instance is destroyed or a new file is loaded.load_streamed()keeps astd::ifstreamalive inPSDFile. Closed on destruction / re-load.load_bytes()copies the input into an internal vector. The Pythonbytescan go out of scope safely.- Layer pixel decoding allocates a fresh BGRA buffer of
4 * w * hbytes per call. There's no caching — call once and hold the result if you need it twice.
import psdparse
from PIL import Image
p = psdparse.PSDFile()
assert p.load("file.psd")
for i, layer in enumerate(p.layers):
if layer.layer_type != psdparse.LayerType.NORMAL: continue
if layer.width == 0 or layer.height == 0: continue
name = layer.name_unicode or f"layer_{i}"
img = Image.frombytes(
"RGBA", (layer.width, layer.height),
p.layer_image(i, "masked"), "raw", "BGRA"
)
img.save(f"{i:03d}_{name}.png")See tools/psd_export.py for a more complete extraction tool that also dumps metadata as JSON.