Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ jobs:
matrix:
include:
# windows-2022, not windows-latest: the msvc preset's generator is "Visual Studio 17 2022".
- { os: windows-2022, preset: msvc }
# msvc also builds and runs examples/vips_composite.c on libvips' prebuilt Windows build
# (fetched, pinned; MSVC is what Platemaker builds with).
- { os: windows-2022, preset: msvc, vips_example: ON }
- { os: windows-latest, preset: mingw }
- { os: ubuntu-latest, preset: linux-gcc }
# linux-gcc also reads psd-tools' 315 files (22 MB, pinned by hash): every comparison
# with psd-tools, Photoshop's ZIP files through zlib among them.
- { os: ubuntu-latest, preset: linux-gcc, psd_tools_files: ON }
- { os: ubuntu-latest, preset: linux-clang }
runs-on: ${{ matrix.os }}
steps:
Expand Down Expand Up @@ -74,7 +78,7 @@ jobs:
- name: Configure, build, test
if: matrix.preset != 'mingw'
run: |
cmake --preset ${{ matrix.preset }} -DMINIPSD_REQUIRE_PSD_TOOLS=ON "-DPython3_EXECUTABLE=${{ steps.python.outputs.python-path }}"
cmake --preset ${{ matrix.preset }} -DMINIPSD_REQUIRE_PSD_TOOLS=ON -DMINIPSD_PSD_TOOLS_FILES=${{ matrix.psd_tools_files || 'OFF' }} -DMINIPSD_VIPS_EXAMPLE=${{ matrix.vips_example || 'OFF' }} "-DPython3_EXECUTABLE=${{ steps.python.outputs.python-path }}"
cmake --build --preset ${{ matrix.preset }}
ctest --preset ${{ matrix.preset }}

Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Procreate open as layered documents.

# Status

**release: 0.2.0**
**release: 0.3.0**
The API is [include/minipsd.h](include/minipsd.h); the implementation is in
[include/minipsd/](include/minipsd/).

Expand All @@ -35,7 +35,7 @@ The API is [include/minipsd.h](include/minipsd.h); the implementation is in
#include "minipsd.h"

minipsd_layer layers[2] = {
{ .name = "Template", .rgba = rgba, .opacity = 255 }, /* bottom, the full canvas */
{ .name = "Template", .pixels = rgba, .opacity = 255 }, /* bottom, the full canvas */
{ .name = "Layer", .opacity = 255 }, /* empty layer above it */
};
minipsd_doc doc = { .width = width, .height = height, .dpi = 300.0, .layers = layers, .layer_count = 2 };
Expand All @@ -56,7 +56,9 @@ for a file (UTF-8 paths on Windows), a buffer and a non-blocking pipe are in

## What it writes

An RGB document with transparency, 8 or 16 bits a channel: layers anywhere on or off the canvas (by default the
A document in any colour mode at the depths Photoshop has it (RGB, grey, CMYK, Lab, duotone and
multichannel at 8 and 16 bits, RGB and grey at 32, indexed at 8, bitmap at 1), no colour
converted: layers anywhere on or off the canvas (by default the
full canvas), or empty, with UTF-8 names, opacity and visibility; the merged composite image,
yours or composed as a best effort; the resolution; an optional ICC profile; groups, layer
masks, blend modes, clipping, and blocks and resources kept from a file (text, effects), as
Expand All @@ -65,15 +67,18 @@ Channels are PackBits RLE or raw, or ZIP through your deflate (zlib's `compress2
implements none). Each side is limited to 30 000 px, or 300 000 px written as a
PSB (Photoshop's large document format).

Not written (yet): 32-bit, other colour modes. The full contract is in [docs/SPECIFICATION.md](docs/SPECIFICATION.md).
The writer composes RGB only: other modes and 32 bits bring their composite. The full contract
is in [docs/SPECIFICATION.md](docs/SPECIFICATION.md).

## What it reads

Any PSD or PSB: its size, colour mode, depth, dpi, ICC profile, resources, and every layer
with its name, rectangle, opacity, visibility, blend mode and raw additional data. Pixels of
with its name, rectangle, opacity, visibility, blend mode and raw additional data. Metadata
parsed into plain values: resources found by id and named by Adobe's table, the resolution,
version info (whether the composite is real), guides, alpha channel names, and a layer's id,
colour label, fill opacity and locks. Pixels of
every colour mode and depth (1, 8, 16, 32 bits) decode as stored, layers and composite, into
memory of any layout; 8- and 16-bit RGB files also whole into a document that `minipsd_write`
takes back. ZIP-compressed channels (Photoshop's 16- and 32-bit layers)
memory of any layout; and whole into a document that `minipsd_write` takes back. ZIP-compressed channels (Photoshop's 16- and 32-bit layers)
decode through the caller's inflate (zlib's `uncompress`, libdeflate or miniz: minipsd implements
no deflate), and are copied as they are when such a file is read and written back. A file can be read from its start only, and untrusted
files are bounded by caller limits. How it does on 315 files from
Expand Down
19 changes: 16 additions & 3 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
Changes between releases, for users of minipsd. Versioning follows the pre-1.0 rules in
[TODO.md](TODO.md): breaking changes bump the MINOR, additions and fixes the PATCH.

## [Unreleased]
## [0.3.0] — 2026-10-23

**Breaking:** `minipsd_write`, `minipsd_writer_workspace_size`, `minipsd_writer_init` and
`minipsd_doc_check` take a `const minipsd_write_opts *` after the document. To migrate, pass
`NULL`: the document is written as before.

**Breaking:** `minipsd_layer.rgba` is `minipsd_layer.pixels`: the mode's colour channels then
transparency, which in RGB is the same R, G, B, A as before. To migrate, rename the field; a
positional initialiser needs no change.

### Added
- The reader, first part: `minipsd_reader_init` describes a PSD held in memory (size, depth,
colour mode, dpi, ICC profile, layer count), from the whole file or a prefix
Expand All @@ -26,6 +30,9 @@ Changes between releases, for users of minipsd. Versioning follows the pre-1.0 r
composite's transparency with a global `Mtrn`, `Mt16` or `Mt32` block rather than a negative
layer count; both count.
- `examples/read_file.c`: a PSD described layer by layer, every layer and the composite decoded.
- `examples/vips_composite.c`: layers flattened by libvips (`vips_composite`, blend modes mapped)
and written with that composite, then read back; built with `MINIPSD_VIPS_EXAMPLE`, which on
Windows fetches libvips' prebuilt build. A "With libvips" chapter in the manual.
- `examples/hello_write.c` and `examples/hello_read.c`: the shortest programs that write and read
a PSD, which open a rewritten [MANUAL.md](MANUAL.md): call-order diagrams, a file read in
parts, memory, return codes and configuration.
Expand All @@ -36,7 +43,13 @@ Changes between releases, for users of minipsd. Versioning follows the pre-1.0 r
`minipsd_doc`. The writer writes that image, matted against white unless marked as already
matted, and composes nothing. Without one the layers are composed as before, now documented as
a best effort.
- `minipsd_read`: a whole 8- or 16-bit RGB file into a `minipsd_doc` that `minipsd_write` takes as it
- Every colour mode and depth written: `color_mode` and `color_mode_data` at the end of
`minipsd_doc` (`MINIPSD_MODE_*`, Adobe's numbers; 0 is RGB unless `depth` is 1, bitmap). Grey,
CMYK, Lab, duotone and multichannel at 8 and 16 bits, RGB and grey at 32 (`float` samples, the
layers in `Lr32`), indexed at 8 with its palette, bitmap at 1 (a byte a pixel, packed by the
writer); bitmap, indexed and multichannel without layers. No colour is converted; every mode
but RGB, and 32 bits, brings its composite, and a supplied one is written as given.
- `minipsd_read`: a whole file of any mode and depth the writer takes into a `minipsd_doc` that `minipsd_write` takes as it
is, every byte copied into one block so the file can be freed at once; `minipsd_doc_free`;
`minipsd_read_size` and `minipsd_read_into` for the caller's own memory. `lost`
(`MINIPSD_LOST_*`) says what the document could not carry: extra channels.
Expand Down Expand Up @@ -113,7 +126,7 @@ Changes between releases, for users of minipsd. Versioning follows the pre-1.0 r
still wins.
- A stored layer without a transparency channel is written without one, as its file has it,
instead of with an opaque one.
- A layer's all-zero rectangle means the full canvas only with `rgba`: stored channels take the
- A layer's all-zero rectangle means the full canvas only with `pixels`: stored channels take the
rectangle as it is.
- A layer's names kept from a file: `name_pascal` at the end of `minipsd_layer` holds its legacy
name as stored, and while `name` still says what the file's names say, the legacy name and the
Expand Down
4 changes: 3 additions & 1 deletion docs/CHEATSHEET.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ consumers, so there is nothing to ship from a build.
| `linux-gcc` | Linux / WSL | GCC + Ninja |
| `linux-clang` | Linux / WSL | Clang + Ninja |
| `linux-asan` | Linux / WSL | GCC + AddressSanitizer and UBSan (no round trip) |
| `linux-tsan` | Linux / WSL | Clang + ThreadSanitizer; on new kernels it needs `sudo sysctl vm.mmap_rnd_bits=28` |
| `linux-tidy` | Linux / WSL | Clang + clang-tidy on the tests and the header |
| `linux-coverage` | Linux / WSL | GCC + `--coverage`; report with gcovr below |
| `linux-fuzz` | Linux / WSL | Clang + libFuzzer, ASan and UBSan; local only, see [Fuzzing](#fuzzing) |
Expand Down Expand Up @@ -152,7 +153,8 @@ paths also check the SBOM fragment's version and that it lists the headers.
| `MINIPSD_BUILD_TESTS` | ON only as the top-level project | the tests above |
| `MINIPSD_INSTALL` | ON only as the top-level project | install rules |
| `MINIPSD_REQUIRE_PSD_TOOLS` | OFF (CI: ON) | fail the configure instead of skipping the round trip |
| `MINIPSD_PSD_TOOLS_FILES` | OFF (CI: OFF) | fetch psd-tools' 279 test PSDs (v1.25.0, about 22 MB, pinned by hash) and read every one |
| `MINIPSD_VIPS_EXAMPLE` | OFF (CI: ON in `msvc`) | build and run `examples/vips_composite.c`: on Windows fetch libvips' prebuilt "web" build (8.18.7, 11 MB, pinned by hash), elsewhere find libvips with pkg-config; with `tests/fixtures/Test-g-example.psd` present, also on that file |
| `MINIPSD_PSD_TOOLS_FILES` | OFF (CI: ON in `linux-gcc`) | fetch psd-tools' 315 test files, 278 PSD and 37 PSB (v1.25.0, about 22 MB, pinned by hash) and read every one |
| `MINIPSD_TEST_FIXTURES` | ON (`linux-coverage`: OFF) | read the local PSDs in `tests/fixtures/` too; off, a local coverage run measures what CI's does |
| `MINIPSD_CLANG_TIDY` | empty (`linux-tidy`: `clang-tidy`) | run clang-tidy on the test targets |

Expand Down
21 changes: 12 additions & 9 deletions docs/CODE-ARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ int minipsd_reader_init (minipsd_reader **r, const void *data, size_t size,
minipsd_info *info);
uint64_t minipsd_reader_needed(const minipsd_reader *r); /* after MINIPSD_ERR_SHORT */

int minipsd_reader_next_resource(minipsd_reader *r, minipsd_resource *res); /* MORE: got one; OK: no more */
int minipsd_reader_next_resource(const minipsd_reader *r, size_t *pos, minipsd_resource *res); /* MORE / OK */
int minipsd_reader_next_layer (minipsd_reader *r, minipsd_layer_view *layer); /* MORE: got one; OK: no more */
int minipsd_layer_name (const minipsd_layer_view *layer, char *utf8, size_t cap, size_t *needed);
int minipsd_layer_next_block (const minipsd_layer_view *layer, size_t *pos, minipsd_block *block); /* MORE / OK */
Expand Down Expand Up @@ -337,7 +337,7 @@ typedef struct minipsd_target { minipsd_plane channel[MINIPSD_PLANES]; } minipsd

### Pixel conventions (`X3`)

- **Straight (not premultiplied) alpha.** It is what `minipsd_layer.rgba` takes, so a decoded
- **Straight (not premultiplied) alpha.** It is what `minipsd_layer.pixels` takes, so a decoded
layer can be written back unchanged (`E4`). A caller that wants premultiplied does the
multiplication; a flag can be added later without breaking anything.
- **A layer is decoded as stored.** Its opacity and hidden flag stay in the view and are not
Expand Down Expand Up @@ -547,13 +547,13 @@ int composite_matted; /* non-zero: composite is already matted, wri

| Field | Zero means | Notes |
|---|---|---|
| `int32_t top, left, bottom, right` | all four zero: the full canvas (with `rgba`; stored channels take the rectangle as it is) | the fields of `minipsd_layer_view`, in its order. `rgba` holds `(right - left) * (bottom - top) * 4` bytes. A rectangle may reach past the canvas; an inverted one, or an area without `rgba`, is `MINIPSD_ERR_ARG`; a side above `MINIPSD_MAX_SIDE` is `MINIPSD_ERR_SIZE` (RLE row counts are 16 bits). Row tables and channel sizes follow the rectangle; the composed composite blends at the offset and clips to the canvas |
| `int32_t top, left, bottom, right` | all four zero: the full canvas (with `pixels`; stored channels take the rectangle as it is) | the fields of `minipsd_layer_view`, in its order. `pixels` holds `(right - left) * (bottom - top) * 4` bytes. A rectangle may reach past the canvas; an inverted one, or an area without `pixels`, is `MINIPSD_ERR_ARG`; a side above `MINIPSD_MAX_SIDE` is `MINIPSD_ERR_SIZE` (RLE row counts are 16 bits). Row tables and channel sizes follow the rectangle; the composed composite blends at the offset and clips to the canvas |
| `char blend_mode[4]` | `norm` | written as given |
| `uint8_t clipping` | not clipped | written as given |
| `uint8_t flags` | none | the record's other flag bits (0x01 transparency locked, 0x08 and 0x10 pixel data irrelevant), written as given; 0x02 comes from `hidden` |
| `minipsd_bytes name_pascal` | the writer's own names | the legacy name kept from a file (`minipsd_read` sets it); while `name` says what the kept names say (a kept `luni`, else this one as Latin-1), both are written as they are, otherwise the writer's own |
| `minipsd_bytes additional_info` | no extra blocks | the layer's additional-information blocks as one blob, in the format of the reader's `additional_info` view; written after the writer's own blocks. `minipsd_doc_check` walks them: a malformed run is `MINIPSD_ERR_ARG` |
| `const minipsd_bytes *stored` | encode `rgba` | four channel blobs (R, G, B, A, as `minipsd_layer_view.channel`), each the stored data with its compression marker and row counts; transparency of size 0 is none. The writer copies each as one piece instead of encoding `rgba`, and writes no transparency channel where none is stored; the sizing pass takes their sizes. `minipsd_doc_check` checks each against the rectangle (raw rows, or RLE counts adding up within the blob; ZIP passes as it is); a layer with `stored` and no `rgba` needs a supplied `composite`, since nothing could compose it. Name, opacity, visibility, `blend_mode`, clipping and `additional_info` still come from the layer |
| `const minipsd_bytes *stored` | encode `pixels` | four channel blobs (R, G, B, A, as `minipsd_layer_view.channel`), each the stored data with its compression marker and row counts; transparency of size 0 is none. The writer copies each as one piece instead of encoding `pixels`, and writes no transparency channel where none is stored; the sizing pass takes their sizes. `minipsd_doc_check` checks each against the rectangle (raw rows, or RLE counts adding up within the blob; ZIP passes as it is); a layer with `stored` and no `pixels` needs a supplied `composite`, since nothing could compose it. Name, opacity, visibility, `blend_mode`, clipping and `additional_info` still come from the layer |

`minipsd_doc`, at the end, after `composite` and `composite_matted`:

Expand Down Expand Up @@ -626,7 +626,7 @@ typedef struct minipsd_read_opts {
`MINIPSD_ERR_UNSUPPORTED`, since the writer could not take it.
- **`opts->stored`** decodes nothing: each layer keeps its compressed channels, a fraction of its
pixels' size. The caller decodes the one layer it edits through the reader, from the same
file, sets that layer's `rgba` and clears its `stored`, then writes. Untouched layers are never
file, sets that layer's `pixels` and clears its `stored`, then writes. Untouched layers are never
decoded nor encoded again, and the file's own composite is copied, neither rendered nor
decoded (`E1`), alpha and spot channels with it (the resources naming them are kept). ZIP
channels pass this way without an inflate; a layer without a transparency channel is written
Expand All @@ -641,8 +641,10 @@ typedef struct minipsd_read_opts {
parts, and a second stream of another size is `MINIPSD_ERR_WRITE`. The composite stays RLE.
- **Limits:** `minipsd_limits` as in the reader; `max_decoded_bytes` bounds the sum of the call's
decoded pixels. A block larger than `SIZE_MAX` is `MINIPSD_ERR_SIZE`.
- **What it accepts:** 8- and 16-bit RGB, as the decoder; the document has the file's `depth`.
Anything else is `MINIPSD_ERR_UNSUPPORTED`. Layers read from `Lr16` are the document's, so
- **What it accepts:** what the writer takes back: the document has the file's `color_mode`,
`color_mode_data` and `depth`, and a file the writer would refuse (a mode at a depth it does not
write, layers in a flat mode) is `MINIPSD_ERR_UNSUPPORTED`, checked first with the writer's own
`mpsd__doc_mode_ok` on the file's header. Layers read from `Lr16` or `Lr32` are the document's, so
`layer_tail` keeps that block empty, where it was, and the writer puts them back in it.
- **No allocator passed at run time.** The caller's block and `MINIPSD_MALLOC` cover both cases;
a `{alloc, free, user}` variant is an addition when a caller needs one.
Expand Down Expand Up @@ -687,6 +689,7 @@ feature is serialization: written and read as data. The composite composed as a
| **Layer groups (`lsct`)** | done: `section_divider_type` (`MINIPSD_SECTION_*`); zero = layer | done: the `lsct` block, the group's mode in it; a kept one of the same type as it is; nesting checked | done: `section_divider_type` in the view | — | the composed composite draws groups flat |
| **Layer masks** | done: `mask` (`minipsd_mask`: rectangle, colour, flags, pixels or stored, kept real mask and rest) | done: mask data, channels -2 and -3 | done: the view's mask, `minipsd_decode_mask` | — | the composed composite ignores masks |
| **16-bit channels** | done: `depth` on `minipsd_doc`; zero = 8 | done: 2-byte samples, big-endian, in the layer info | done: `depth` in `minipsd_decode_opts`, layers from `Lr16`, `minipsd_read` | done: `bytes` (`mpsd__at`, samples), `packbits` over a row's bytes, `composite` against 65535 | ZIP channels decode through the caller's inflate (S6.9), or pass as stored |
| **Typed views** | — (output only: `minipsd_resolution_info`, `minipsd_version_info`, `minipsd_grid_and_guides`, `minipsd_guide`, `minipsd_block_settings`) | — | `views.h`: a resource or a layer view in, a struct out; `find_resource`, `next_resource` with the caller's `pos` | `format`, `utf` | the writer keeps resources and blocks as bytes; nothing to write |
| **PSB (version 2)** | done: `format` on `minipsd_doc` (`MINIPSD_PSD`, `MINIPSD_PSB`; zero = PSD): the layout of everything in the document, set by `minipsd_read`; the output format a write option, zero = the document's (decided over two document fields and over a converting copy: one field never describes a wish, and a translating writer stays zero-copy, as `tiffsave`'s `bigtiff` does) | done: 8-byte lengths, 4-byte RLE counts; `minipsd_write_opts.format` on the write calls, kept bytes translated while streaming (RLE count tables, long keys' lengths), by a policy per long key in `format.h` | done: both versions, `format` in the info and the view, `minipsd_read` sets the document's | `format`: widths, long keys, blocks | sides up to 300 000 px |

## Distribution of a multi-file library
Expand Down Expand Up @@ -771,9 +774,9 @@ Taken at review, 2026-10-14.

Taken at the S4.1 review, 2026-10-15 (the reader):

7. **The writer's layer source does not share the pixel descriptor.** `minipsd_layer.rgba` stays.
7. **The writer's layer source does not share the pixel descriptor.** `minipsd_layer.pixels` stays.
A strided or row-by-row source (`W6`, `W7`) would be a field added at the end of
`minipsd_layer`, with the same shape but `const` pointers and `rgba` as the default when it
`minipsd_layer`, with the same shape but `const` pointers and `pixels` as the default when it
is zero: the reader writes through the pointers and the writer only reads, so one type for
both would need a cast.
8. **Short input is `MINIPSD_ERR_SHORT` plus `minipsd_reader_needed`**, not `MINIPSD_MORE`:
Expand Down
Loading
Loading