RecursiveAI.OneImage.Psd (namespace OneImage.Psd) reads and writes Photoshop documents. It has two entry points on one parser:
- The merged image is an ordinary
Imageformat.PsdFormatis registered in anImageConfiguration(it is not in the default configuration, because the default cannot reference a separate package), andImage.Load,Identify,Thumbnailand the rest then work on.psdand.psblike any other file. Saving anImageto PSD writes a flat document. - The layers:
PsdFile.Loadreturns aPsdFilewhoseDocumentis anOneImage.Layers.Document(pixel, group, fill and adjustment layers with masks, blend modes and clipping).PsdFile.Savewrites it back. Everything the document model cannot express stays in the file model as opaque blocks and is written back unchanged.
var config = new ImageConfiguration().Add(PsdFormat.Instance); Image.Load("poster.psd", new LoadOptions { Configuration = config }).Resize(width: 800).Save("poster.webp"); using PsdFile file = PsdFile.Load("poster.psd"); file.Document.Render().Save("flattened.png"); file.Save("edited.psd"); // PSB when the document is wider or taller than 30,000 px
Known limitations
- Edited layer styles are written from the model. Gradient and pattern overlays, bevel and emboss, gradient and pattern strokes and gradient glows are modelled and written like the other effects; items the model does not carry (noise gradients, perceptual gradients, the bevel contour and texture, dissolve and darker/lighter color modes, unknown keys) are kept from the block that was read, not edited. Written files are checked against independent readers (ImageMagick, and psd-tools used as a black box), not against Photoshop itself. Effects come back grouped by kind, so their order across kinds is not preserved. See Layer effects.
- Fills with no PSD equivalent are written as pixels. Linear and radial gradients become gradient fill settings and bitmap patterns or hatches pattern fill settings (RGB documents, 8 or 16 bits); any other brush, and fills in gray or 32-bit documents, are written as a pixel layer of what they draw. See Writing.
- Bevel and emboss, and the aligned gradients and patterns of effects, are approximations. Bevels follow a diffuse-lighting model of the shape (smooth bevels from a blurred copy, chisel from the edge distance) and match Photoshop within a few levels on the sample files, not exactly. The bevel contour, texture and anti-aliased gloss are not modelled; linear dodge and linear burn highlights and shadows are clamped before the opacity mix (Photoshop clamps after), which changes saturated channels. A pattern overlay on a text layer starts the tiling in a different place than Photoshop. The shape burst gradient of a stroke and stroke emboss (the bevel follows the shape's own edge) are not checked against stored pixels.
- Text, smart objects, vector strokes and the layer effects that are not drawn render from their stored pixels. Multichannel documents are refused. Other fidelity gaps are listed under What is covered.
What is covered
Multichannel documents are refused with a typed error, both as a merged image and through PsdFile.Load. Every other color mode (bitmap, gray, duotone, indexed, RGB, CMYK, Lab) loads.
Modelled, and round-tripped through PsdFile.Save: pixel layers with their offsets, groups (pass-through or isolated), the blend modes the layer model has, opacity, fill opacity, visibility, clipping to the layer below, lock flags, raster masks (area, default value, density, feather, disabled flag), vector masks (rasterized to a raster mask), solid color fill layers, gradient and pattern fill layers (drawn from their settings, see Gradient and pattern fills), and the adjustment layers levels, curves, hue/saturation, brightness/contrast, exposure, invert, threshold and posterize. These render with the library's own engine.
Layer effects (lmfx/lfx2) are read into an OneImage.Layers.LayerStyle, drawn when the document is composited, and written back from the model when the style is created or edited; see Layer effects.
Kept only as their stored pixels plus untouched data blocks: text layers, smart objects, the layer effects that are not drawn (listed under Layer effects; a file with only the legacy lrFX block is not parsed), vector strokes, and gradient or pattern fills that cannot be drawn (the fallbacks listed under Gradient and pattern fills). They render from the raster Photoshop stored with the layer, are not redrawn and cannot be edited here, and their blocks are written back unchanged. Dissolve and the darker/lighter color blend modes are also kept as keys but render as Normal, Darken and Lighten.
Known fidelity gaps against Photoshop: brightness/contrast in Photoshop's current (non-legacy) mode is a fitted approximation (see below); hard mix and vivid light differ slightly; vector-mask path operations (combine, subtract, intersect) use the non-zero rule only; levels, curves, hue/saturation, brightness/contrast and exposure layers in CMYK and Lab documents are kept but not applied; the base layer's fill opacity in a clipping group applies to the whole group. Figures are in Fidelity of the layer render.
Reading the merged image
The merged image is read in the file's own depth: gray, RGB and indexed at 8, 16 and 32 bits (indexed and bitmap deliver 8-bit RGB and gray), bitmap, duotone as gray, CMYK and Lab converted to RGB through the color engine (8-bit, Lab 16-bit stays 16-bit). Raw, run-length, ZIP and ZIP-with-prediction channels are read, 8, 16 and 32 bits. Multichannel documents are refused with a typed error.
- A composite with transparency is stored by Photoshop blended with white. By default
PsdFormatremoves the white matte so the result is straight alpha;new PsdFormat(unmatte: false)returns the stored samples. - A file without a composite (or with a stub of raw data too short for the image) is rendered from its layers instead.
- The composite is bit-exact on the hand-built files of the test suite for every depth, mode and compression. Against ImageMagick's reader on the corpus below, 215 of 313 files are identical; the others are mostly files with transparency (ImageMagick keeps the matte), CMYK and Lab (different color conversion), and 16/32-bit (a Q8 build quantizes).
Layers: what maps to what
| In the file | In the document | Notes |
|---|---|---|
| Pixel layer | PixelLayer at its offset | Layer bounds are kept; an empty or inverted rectangle is an empty layer. |
| Group (open or closed folder) | GroupLayer | Pass-through (pass) or isolated with its own blend mode; open/closed state is kept in PsdLayerInfo.GroupOpen. Nesting is capped by PsdLimits.MaxNesting. |
| Blend modes | BlendMode | All the Photoshop modes the model has. Dissolve and darker/lighter color have no equivalent: the key is kept and written back, and rendering uses Normal, Darken and Lighten. |
Opacity, fill opacity (iOpa), visibility, clipping | Opacity, FillOpacity, Visible, ClipToBelow | Opacity is stored in 8 bits, so values are quantized to 1/255 on write. The hidden flag follows real files (set means hidden). |
Locks (lspf, flags) | Locks | |
| Layer id, unicode name | PsdLayerInfo.LayerId, Layer.Name | The Pascal name is written in Latin-1 with ? for other characters; luni carries the real name. |
| Raster mask | LayerMask | Area, default (outside) value, density, feather, disabled flag. Mask parameters are read in either position Photoshop uses. |
Vector mask (vmsk/vsms) | A raster mask | The path is rasterized with the non-zero rule at the layer's size. With both kinds, the two are multiplied. The written mask carries the flag that says it already includes the path. |
Solid color fill (SoCo) | FillLayer | Written as a document-size layer plus SoCo. |
| Levels, curves, hue/saturation, brightness/contrast, exposure, invert, threshold, posterize | AdjustmentLayer | Per-channel levels and curves apply before the master set. Posterize, curves and brightness/contrast use Photoshop's formulas, described under Adjustment formulas. Levels, curves, hue/saturation, brightness/contrast and exposure layers of CMYK and Lab documents are kept as placeholders (the pixels were converted to RGB, and these act on ink or Lab channels), and written as an identity levels layer, since their parameters mean nothing in an RGB file. Invert, threshold and posterize apply as usual. |
Gradient and pattern fills (GdFl, PtFl), shape layers with such fills | FillLayer with a gradient or pattern brush | Drawn from the settings; the original block is kept and written back while the brush is untouched, and a new or replaced gradient or pattern brush is written as new settings (see Writing fills). Fills outside the supported subset, or read with PsdReadOptions.DrawFills = false, are pixel layers of the stored pixels. |
Layer effects (lmfx preferred, else lfx2) | Layer.Style | Drop shadow, inner shadow, outer and inner glow, solid stroke, color overlay, satin are drawn; an untouched style is written back byte for byte, a created or edited one is written as a new block that keeps what the model has no field for. See Layer effects. |
Text, smart objects, effects that are not drawn, strokes (vstk) | Pixel layer + kept blocks | Drawn from the stored pixels only; the blocks are written back untouched. |
| Image resources, ICC profile, document-level blocks | PsdFile.Resources, SourceProfile, DocumentBlocks | Kept and written back. Document-level odd-length blocks keep their unpadded length so they write back byte for byte. |
| Layer info of 16 and 32-bit files | Lr16 / Lr32 blocks | Written like Photoshop does. |
Not stored in the file: the document Background (it is a render-time color, so a merged image written with a white background reads back as layers over transparency), and anything the layer model does not have (smart-object contents, vector strokes as editable data, effects the style model has no kind for).
Defaults of the model: 8-bit gray files load as GrayAlpha8 documents, other files as RGB (Rgba8, Rgba16, RgbaF32 by depth), CMYK always as 8-bit RGB. Colors of CMYK/Lab layers go through the color engine, so layers of CMYK files are not bit-for-bit the file's samples.
Writing
PsdFile.Save writes layers with run-length coding (default) or raw channels, 8, 16 and 32 bits, a merged image rendered from the layers (with an alpha channel when the background is not opaque), the sRGB profile, and a PSB when the document is wider or taller than 30,000 px or PsdWriteOptions.Large says so (a PSB holds up to 300,000 px). A layer with no PSD equivalent (a custom adjustment) throws ImageOperationException instead of being dropped; a fill layer whose brush has no gradient or pattern equivalent is written as a pixel layer of what it draws (see Writing fills), and a style is written as an effects block (see Layer effects). A non-seekable stream is written through a temporary file.
Writing the same loaded file twice gives the same bytes, and a document read back from a file renders to the same pixels as the document that was written (opacities are quantized to 8 bits first).
Adjustment formulas
These three were derived from the composites Photoshop stored in the sample files (each file applies one setting to a strip of a photograph and a color ramp, so the stored composite against our render of the layers below gives the transfer function exactly), not from any other implementation. The public Image methods keep their defaults; the formulas are options (PosterizeMode.Buckets, CurveInterpolation.Natural, BrightnessContrastMode.PhotoshopLegacy / PhotoshopSmooth) that the PSD reader selects.
- Posterize cuts the range into
levelsequal bins and rounds each bin down. On 8-bit samples a valuevgoes to bink = floor(v * levels / 256)and the output isfloor(k * 255 / (levels - 1)), so 3 levels give 0, 127, 255 with the edges at 86 and 171, and 13 levels give 0, 21, 42, 63, 85 and so on (rounding down, not to nearest). 16-bit and float samples usefloor(v * levels)andk / (levels - 1). Exact against the stored 8-bit files (0 error); 16-bit files differ by about 0.1-0.3 levels in the mean, at bin edges. - Curves interpolate the control points with a natural cubic spline (zero curvature at both ends) in 0..255 coordinates, flat before the first and after the last point, with the result clamped; the per-channel curves act first and the composite curve after them. Points that are not in output order give a non-monotone curve, as in Photoshop. Mean error against the stored composites is under 0.01 levels (maximum 1). The public
Curveskeeps its monotone interpolation by default. - Brightness/contrast, legacy mode (a
britblock without aCgEddescriptor, or a descriptor withuseLegacyset): with brightnessbin levels (-150..150) and contrastc(-100..100),c8 = trunc(c * 2.55); forc >= 0the slope isk = 255 / (255 - c8)andout = k * (v + b - 127) + 127; forc < 0it isk = (255 + c8) / 255andout = k * (v - 127) + 127 + b; results are rounded and clamped. Exact on the sample files except one level at a few values. - Brightness/contrast, current mode (Photoshop CS3 and later keep the real settings in a
CgEddescriptor next to a zeroedbritblock; older readers saw no adjustment at all). Photoshop bends the tone curve instead of shifting it, and the exact curve is not documented.PhotoshopSmoothis an approximation fitted to four stored settings: a quadratic bowx + 1.7 * (b / 150) * x * (1 - x)followed by a soft contrast slope about 0.5 (the legacy slope to the power 0.2). It brings the sample files from about 37 levels of mean error to about 8; individual layers range from 5 to about 25 levels. Treat it as an approximation.
A model-built adjustment written to a PSD file always uses the file's formulas: a posterize layer is written as post (buckets), a curve as curv (read back as a natural spline) and brightness/contrast as the legacy brit block, so build documents meant for PSD with the matching mode. A loaded layer is written back from its own stored block, byte for byte.
Gradient and pattern fills
Fill layers (GdFl, PtFl) and shape layers filled with a gradient or pattern are drawn by the library as FillLayers with a gradient or pattern brush, so they scale, move and composite like any layer. PsdReadOptions.DrawFills (default true) turns this off: the layers then load as pixel layers of the stored raster, as before. An unchanged fill is written back as its original block plus the brush rendered over the document; a replaced brush is written as described under Writing fills.
The rules were derived from the rasters and composites Photoshop stored in the sample files, not from any other implementation.
- Gradients: linear, radial, angle, reflected and diamond styles with angle, scale, offset (percent of the box), reverse, color and opacity stops, midpoints and smoothness. Position is evaluated at integer pixel coordinates relative to the box center. The gradient line is the chord through the box center at the angle, of length
scale * min(w / |cos|, h / |sin|); radial, reflected and diamond use half of it as the radius, and the angle style starts at the angle and runs clockwise. Between stops the position is bent by the midpoint, then eased bysmoothnesstoward a smoothstep. Colors interpolate in encoded values, or in linear light when the fill says so. Stop colors in RGB, gray, CMYK and Lab are converted through the color engine. - Patterns: tiles come from the document's pattern resources (
Patt,Pat2,Pat3; RGB or gray, 8 or 16 bits, raw or run-length coded). The tile origin is the document origin for fill layers and one pixel right of the box corner for shape layers. - Box: shape layers use the vector path bounds; fill layers use the layer rectangle, else the raster mask rectangle, else the document; "align with layer" off means the document.
- Stored colors win: where Photoshop wrote a raster for the layer, the drawn fill takes its colors from it and keeps its own alpha, so the common case matches Photoshop exactly and the drawing only supplies what was not stored (an enlarged or edited layer).
- Falls back to the stored pixels for noise gradients, perceptual interpolation, stop colors that are foreground/background or HSB, boxes with a side under 16 px, patterns with a scale other than 100%, an angle or a phase, missing or unsupported patterns (not RGB or gray, or with alpha), and descriptors that are truncated or out of range. Photoshop snaps gradient direction and length to whole pixels; that is not modelled.
Against the stored rasters without the stored-color rule, patterns are exact, gradients typically differ by 0.1 to 1.2 levels in the mean (worst case 6.8, a 60 degree reflected gradient on 32 px), and gray and CMYK fills by 3 to 6 levels. Midpoint ownership with three or more stops is checked on one file only.
Writing fills
A fill layer whose brush is still the one read from the file keeps its settings block (see above). A new or replaced brush is written like this (FillWriter; the brush is read through its language-neutral Describe()):
- Solid color: a
SoCoblock, as before. - Linear and radial gradients (
Brush.LinearGradient,Brush.RadialGradient) become aGdFlblock when the stops lie in 0..1, the repetition isNone, the document is RGB at 8 or 16 bits (not gray, not 32-bit float), the document is at least 16 px on each side, and translucent stops either are not interpolated premultiplied or share one color. The block holds a custom gradient (CstS) with the stops at their 0..4096 positions, midpoints 50 and an opacity stop at each color stop, smoothness 0 (a straight blend between stops, which is what the brush does), "align with layer" off (the box is the document), the angle, a scale oflength / chordin percent (see the geometry above; accepted from 0.1 % to 10,000 %, while Photoshop's own dialog offers 10 % to 150 %) and the offset of the gradient's middle from the document's middle. The brush measures at pixel centers and the settings at integer coordinates, so the middle moves by half a pixel; radial gradients are written at angle 0 and scale2 * radius / width. Drawn back from the settings, such a gradient differs from the brush by at most 2 levels (test), except at a hard edge between two equal positions, which moves by up to a pixel. - Bitmap patterns and hatches (
Brush.Pattern,Brush.Hatch) become aPtFlblock (pattern at 100 %, angle 0, not aligned) plus a pattern entry in the document's pattern resources (Pattfor 8-bit documents,Pat2for 16-bit, added to the block the document already has): header, name, id, and a virtual memory array list with three raw planes, in the layout of the one example in the sample files. The id is a GUID-shaped string made from a hash of the pixels, so the same tile is stored once however many layers use it, and a saved file is reproducible. Both colors must be opaque and the brush must have no transform; the tile is drawn from the document origin, which is what the brush does, and drawn back from the resource it is identical to the brush. - Everything else is written as pixels: repeating or reflecting gradients, elliptic and path gradients, stops outside 0..1, image brushes, recolor brushes, custom brushes, patterns with a transparent color or a transform, patterns in 32-bit or gray documents, gradients in gray documents or small ones. The layer becomes an ordinary pixel layer named like the fill, covering the document, holding what the brush draws (it loads back as a
PixelLayer). Its blend mode, opacity, mask and style are written as for any layer.
In every case the raster Photoshop stores beside the settings is the brush drawn over the document, so other readers (and a load with DrawFills = false) show the fill, and a load returns the same render and writes the same bytes again.
Independent readers, run as black boxes on files written this way: ImageMagick reads the merged image (equal to the render) and every layer with its name, offset and stored raster; psd-tools 1.26.0 reads the settings (kind gradient fill or pattern fill, the pattern from the resources) and draws radial gradients within 3 levels and hatches exactly. It draws linear gradients with another geometry (angled: a different gradient length; axis aligned: it applies an ease between stops although smoothness is 0), and the geometry used here is the one fitted to Photoshop's own rasters, so that difference is not changed.
Layer effects
The effects block (lmfx, else lfx2) of a pixel, shape or fill layer is read into a LayerStyle and drawn at composite time from the layer's own shape, so moving or editing the layer redraws them. The master effects switch, a layer's "effects visible" flag (lrFX common state) and each effect's enabled flag are respected; disabled effects are read as disabled effects (up to the same number again as the drawn limit), draw nothing, and are written back. Effects that use the global light take the document's light angle (image resource 1037).
Drawn: drop shadow, inner shadow, outer glow, inner glow (edge or center source, range, or a gradient instead of the color), a stroke (outside, inside, center; solid, gradient or pattern), color overlay, gradient overlay, pattern overlay, bevel and emboss and satin, each with its blend mode, opacity, color, size, distance, spread/choke and contour. Several effects of one kind (the "multi" lists of newer Photoshop) are drawn in file order. Shadows and glows use a blur of 0.41 x size as the Gaussian sigma; spread and choke grow or shrink the shape by that fraction of the size before blurring.
Not drawn (counted in PsdLayerInfo.EffectsNotDrawn, kept and written back): gradients that are noise or use perceptual interpolation, a pattern that is not in the document or is not RGB or gray, effects whose blend mode is dissolve or darker/lighter color, effects with a value outside the model's range (size above 250, distance above 30,000, a contour that is not a clean increasing curve of at most 256 points), effects beyond PsdLimits.MaxEffectsPerLayer (32), effects on groups and adjustment layers. The random grain of a shadow or glow is dropped and counted in EffectsApproximated, as is a layer whose own vector stroke (vstk) Photoshop draws inside the effects.
Rules the renderer follows (derived from the stored composites of the sample files):
- The shape is the layer's alpha times its mask. Fill layers and layers with a vector mask use the mask coverage alone (a gradient's transparency is fill, not shape).
- An outside stroke is drawn below the layer's pixels with antialiased edge pixels fully covered; an inside stroke replaces the pixels it covers; a centered one does both halves.
- A color overlay recolors the layer's pixels and leaves their alpha alone, so fill opacity scales it; shadows, glows, satin and strokes do not follow fill opacity. With fill 0 a drop shadow shows only outside the shape (knockout).
- A shadow moves by (-cos a, sin a) x distance, y down.
- Drawing order, bottom to top: drop shadows, outer glows, outer parts of bevels and embosses, the pixels, color overlays, gradient overlays, pattern overlays, satin, inner glows, inner shadows, inner parts of bevels and embosses, strokes.
- Gradient overlays and gradient strokes use the layout of the gradient fills (position taken at a pixel's integer coordinates, length
scale x chord of the box along the angle) on the shape's bounds: the outline of a vector mask, else the rectangle around the visible pixels (LayerStyle.AlignBox, set by the reader), or the whole document when not aligned. Checked on the Gradient Overlay layer oflayer_effects.psd(error 0.1 to 0.3 levels inside the letters). A gradient glow maps the glow's falloff onto the gradient with the first stop at the shape (checked on one layer oflayer_params.psd: the edge pixel differs by 2 levels). A shape burst stroke maps the distance from the edge over the stroke size. - Pattern overlays tile the pattern one pixel right of the shape's bounds and at its top edge (fitted on shape layers; on the text layer of
layer_effects.psdthe tiling starts elsewhere, which was found by scanning for the best offset (47, 75 mod 80) but not explained, so a text layer keeps the rule above and shows the wrong phase). Scale resamples with nearest-neighbor. - Bevel and emboss: the shape becomes a height field of
depth x size x profile. Smooth bevels use the shape blurred with sigma 0.41 x size (so the slope stays continuous in thin parts: a 163 px disc with size 100 matches the stored shading in red and green within 2 levels), chisel bevels use the signed edge distance as a straight ramp (chisel soft blurs it by 1 px), pillow emboss makes the edge a valley, andsoftenblurs the field. The light is(cos alt cos angle, -cos alt sin angle, sin alt); with the surface normal from the field's gradient, lighting above the flat valuesin altis highlight, below it shadow, each scaled so a surface facing the light squarely (or fully away) reaches 100 %, then through the gloss contour, then the highlight or shadow color with its mode and opacity. An inner bevel draws inside the shape only, an outer bevel outside only, emboss and pillow both sides (the outside part under the layer's pixels). Steep slopes that face sideways are darker than the flat middle, as with any diffuse light.
Satin and contours are implemented from Photoshop's description but not verified against stored pixels (no sample file isolates them). Corners of outside strokes are about 0.07 px off, and the glow range and spread/choke rules were fitted on few files.
Saving (EffectsWriter):
- Untouched: a layer whose
Styleis still the object read from the file keeps its effects blocks (lfx2/lmfxand the legacylrFX) byte for byte. - Created or edited: any other style (including a new but equal one, or
null) is written as a new effects block, version 0 with a version 16 descriptor, with the items Photoshop writes for each kind (enab,present,showInDialog, mode, color asRGBC, opacity in percent,uglgfalse with the explicit angle, distance/choke/size in pixels,Nose0, a contour as aShpCcurve on 0..255,layerConceals, glow range and source, stroke style, satin inversion). A kind with one effect uses its single key (DrSh,IrSh,OrGl,SoFi,FrFX,IrGl,ChFX); several effects of a kind use the list key (dropShadowMulti,innerShadowMulti,solidFillMulti,frameFXMulti). Percent values and angles are written as the decimal the float prints as, so reading the block back gives the same float; angles beyond +-360 are written as the same direction within +-360. The legacylrFXblock, which would keep showing the old effects, is left out of an edited layer, and an edited block replaces bothlfx2andlmfxwhere a layer has them. - What is kept of the block that was read: every root item of its descriptor keeps its place and bytes, except the items that became model effects (those are rewritten from the model, which is why they follow the model's order within a kind). So
Scl, gradient and pattern overlays, bevel and emboss, gradient strokes and glows, an enabled effect that could not be read (dissolve blend, a value out of range), disabled effects the model could not hold, and any unknown key survive an edit of the effects the model does carry. An effect object that is still the very one read from the file is written back as its original item, including grain, anti-aliasing, glow technique and contour names. An effect that is replaced by a new object (effects are immutable, so any edit is one) is written from the model alone: details the model has no field for are not carried over to it.numModifyingFX, when present, is set to the number of enabled items. - Not kept: a block whose master switch is off (or that the legacy block hides) is dropped when a style is set, so effects that were never visible do not appear; the legacy
lrFX;ShapeFromMask(it follows the layer's vector mask on reading). Photoshop stores one outer glow, inner glow and satin per layer: a style with more than one of those kinds throwsImageOperationException(a disabled one that came from the file gives way to an added one). Reading the block back returns the effects grouped by kind in the reader's fixed order, so the order between kinds can differ from the model's (it does not affect the drawing). A style on a group or an adjustment layer is written too. - Written files, second write: the loaded style is the object read from the block that was written, so saving it again writes that block back and the file is byte-identical.
The item names and structure were taken from the effects blocks of the sample files; Photoshop itself was not available to open the result, so lrFX omission and the single-versus-list choice are unverified there.
Fidelity of the gradient, pattern and bevel effects (mean error against the stored composite, levels 0-255; before is with these effects drawn from the stored pixels only): clipping-mask2 24.5 to 0.8, effect-enums 8.5 to 1.5, shape-fx2 43.1 to 6.5, stroke-composite 12.9 to 2.0, stroke-effects 11.7 to 5.0, layer_comps 9.0 to 3.8, layer_effects 4.6 to 2.2, layer_params 4.6 to 1.7, patterns 13.3 to 3.5, fill_adjustments 69.8 to 27.1 (its other layers are adjustments that are not drawn). Eight more files count as fully modelled (178 instead of 170); the 170 that were modelled are unchanged to the last digit (median 0.111), and the median over the 178 is 0.1345 because the new files, which carry bevels and approximated patterns, sit between 0.8 and 6.5. Beyond that the files fall short for known reasons: linear burn and dodge clamp (see the limitation above), the text pattern phase, and the corners of strokes on polygons.
Fidelity of the first effects (mean error against the stored composite, levels 0-255): 10 files became fully modelled, among them antialiased-stroke-edge 17.4 to 0.14, center-stroke-sizes 20.7 to 0.04, feathered-stroke 28.8 to 0.07, group-clips-child-stroke 34.4 to 0.11, inset-stroke-sizes 14.8 to 0.00, outside-stroke 36.0 to 0.16, shape-stroke-raster-mask 25.6 to 0.13 and stroke-effect-transparent-shape 40.4 to 0.00. Files that also use effects that are not drawn improved without being exact: layer_effects 108 to 4.6, stroke-effects 43 to 12, blend-modes 8.0 to 2.2. EffectCorpusTests pins the stroke files at 0.5 levels or less.
Fidelity of the layer render
Rendering a loaded layer tree and comparing with the file's own stored composite (corpus of 316 PSD/PSB test files from the psd-tools project, MIT licensed, fetched by tools/fetch-psd-tests.sh, never committed): 315 load; the one that does not is a multichannel file. Of the 152 files with layers and no feature we do not draw (the set before fills were drawn), the median mean error is 0.134 levels and is unchanged with fills drawn; 160 files are fully modelled now (the 8 added are fill files: gradient-fill went from 8.3 to 0.0 levels, vector-mask2 from 71.9 to 1.0), with a median of 0.108 (106 files at or under 0.5, 115 at or under 2, 128 at or under 10; it was 0.275 before the Photoshop adjustment formulas). Over all 315 loadable files the median went from 0.353 to 0.276. The 19 files with posterize, curves or brightness/contrast layers (or the adjustment compositions that use them) have a median of 0.005 (was 10.5): posterize, curves and legacy brightness/contrast layers are now exact or within a level. The rest differ for known reasons:
- stored composites that are stale or are Photoshop's "not saved with a composite" placeholder (
layers-minimal/*,masks/2.psd,note.psd); - text, smart objects, vector strokes and the layer effects that are not drawn or only approximated (see Layer effects) (gradient and pattern fills outside the supported subset also render from stored pixels);
- brightness/contrast in Photoshop's current mode is approximated (mean error 8 levels on the sample files); hard-mix and vivid-light differ slightly;
- path operations on vector masks (combine, subtract, intersect) use the non-zero rule only;
- adjustments in CMYK and Lab documents.
Limits and safety
Everything is parsed in safe code. PsdLimits bounds layers (20,000), group nesting (64), one block (64 MiB), all blocks (256 MiB), decoded channel bytes in total (16 GiB) and per channel (1 GiB), and descriptor depth (32) and items (65,536), and enabled effects per layer (32); ImageLimits bounds pixels, dimensions and the expansion ratio of ZIP channels. Every size is checked against the header before anything is allocated, nesting is followed with explicit stacks, and a 300,000 x 300,000 layer rectangle in a few hundred bytes of file fails on its header. tests/OneImage.Psd.Tests/HostileFileTests.cs has a crafted file for each limit, and EffectsTests.cs has crafted effect blocks (too many effects, oversized sizes and distances, huge contours, deep nesting, truncated and garbage descriptors); PaintStyleTests.cs has the same for gradient, pattern and bevel items (stop counts over 256, angles, scales, depths and sizes out of range, a missing, oversized or lying pattern, unknown gradient kinds, truncated and garbage items), where each is skipped and counted or is a typed limit error, and a save, load and second-write test for every new effect kind; the psd target ran 10 minutes again on the final code (2,716 executions from a large, slow corpus, no crashes or hangs); the psd fuzz target (see fuzz/README.md) ran 10 minutes (61,770 executions) clean; after layer effects it ran 10 minutes again (38,454 executions from the sample files and effect seeds; it found a hue/saturation block with an out-of-range value that threw an argument exception, now kept without being reproduced). When the writing of styles and fills was added the target also edits what it read (a style with an extra effect on every layer, a new gradient on every fill layer) and writes and reads the result again. Its first 10-minute run (26,000 executions) found a contour point that is not a number, which made the contour constructor throw an argument exception instead of skipping the effect (fixed; AContourPointThatIsNotACleanIncreasingCurveSkipsTheEffect and testdata/psd/fuzz-contour-point-not-a-number.psd pin it); after the fix a 10-minute run (8,300 executions on larger seeds) was clean.
Speed
A 4000 x 3000 PSD with three full-size layers (run-length coded, 36 MB), BenchmarkDotNet short job, .NET 10.0.13, AMD Ryzen 9 7950X3D (32 threads) shared with other jobs (load average 4.9 before the run, 9.9 after), Magick.NET Q8 as the competitor (ImageSharp has no PSD reader). Our default per-call degree of 4 applies.
| Task | Ours | Magick.NET | Allocated (ours / Magick) |
|---|---|---|---|
Merged image to Rgba32 | 42 ms | 79 ms | 2.6 MB / 3 KB (managed only) |
| All layers loaded and flattened | 233 ms | 651 ms (decodes the layers, does not composite them) | 132 MB / 8 KB (managed only) |
The loaded host makes the absolute numbers noisy (the error column of the merged row is 26 ms); the ratios held across the 5 iterations. Magick's allocation is native memory and is not counted. Run with dotnet run -c Release --project bench/OneImage.Benchmarks -- --filter '*PsdReadBenchmarks*'.