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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Builds and tests minipsd on every supported toolchain: the unit tests (C99 + C++20), the C++17
# compile check, the psd-tools round trip (required here, optional locally) and the consumer tests.
# Then, on Linux, the same tests under sanitizers, clang-tidy and coverage (job "analysis").
# release.yml calls this workflow before it publishes anything.
# release.yml publishes only a commit this workflow has passed on master.
# Branches run nothing until a pull request is ready for review (drafts are skipped); after that,
# each push to it runs again and cancels the run it replaces.
name: CI
Expand All @@ -12,7 +12,6 @@ on:
pull_request:
types: [ opened, synchronize, reopened, ready_for_review ]
workflow_dispatch:
workflow_call:

permissions:
contents: read
Expand Down
28 changes: 22 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Release: a pushed tag X.Y.Z (bare semver) runs the full CI, checks the tag against the header
# version and the changelog, and publishes minipsd.h (include/ joined into one file by
# Release: a pushed tag X.Y.Z (bare semver) checks that CI already passed on its commit on master,
# checks the tag against the header version and the changelog, and publishes minipsd.h (include/ joined into one file by
# tools/amalgamate.cmake) + minipsd.h.sha256 as a GitHub Release with a build-provenance attestation. "Run workflow" (workflow_dispatch) does everything except publish,
# so it is a dry run. See docs/GITHUB-RELEASE.md.
name: Release
Expand All @@ -11,18 +11,34 @@ on:

permissions:
contents: write # create the Release
actions: read # find CI's runs on the commit
id-token: write # sign the attestation
attestations: write # store it

jobs:
ci:
uses: ./.github/workflows/ci.yml

release:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # master's history, to find the commit in it

# CI runs on every push to master, so the commit is tested already: the release checks
# that rather than testing it again.
- name: Check the commit is on master and CI passed on it
env:
GH_TOKEN: ${{ github.token }}
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
echo "::error::$GITHUB_SHA is not on master: a release is made from what master has"
exit 1
fi
passed=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --commit "$GITHUB_SHA" \
--status success --json databaseId --jq length)
if [ "$passed" -eq 0 ]; then
echo "::error::no successful CI run on $GITHUB_SHA: wait for CI to finish, then re-run this job"
exit 1
fi

- name: Check the tag, the header version and the changelog agree
run: |
Expand Down
9 changes: 6 additions & 3 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.3.0**
**release: 0.3.1**
The API is [include/minipsd.h](include/minipsd.h); the implementation is in
[include/minipsd/](include/minipsd/).

Expand Down Expand Up @@ -62,7 +62,8 @@ 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
they are.
they are; guides, alpha channel names, version info and a layer's id, colour label, fill and locks
made from plain values.
Channels are PackBits RLE or raw, or ZIP through your deflate (zlib's `compress2`: minipsd
implements none). Each side is limited to 30 000 px, or 300 000 px written as a
PSB (Photoshop's large document format).
Expand Down Expand Up @@ -95,9 +96,11 @@ Define before the implementation include:

| Macro | Effect |
|---|---|
| `MINIPSD_STATIC` | make every function `static` (nothing exported) |
| `MINIPSD_STATIC` | make every function `static` (nothing exported); those a file does not call do not warn |
| `MINIPSD_MALLOC` / `MINIPSD_FREE` | replace `malloc` / `free` |
| `MINIPSD_NO_MALLOC` | leave out every allocation (`minipsd_write`); the pull writer stays |
| `MINIPSD_NO_READER` | leave out the reader, the typed views and `minipsd_read`: a program that only writes |
| `MINIPSD_NO_WRITER` | leave out the writer, the typed setters and `minipsd_read`: a program that only reads |

## Documentation

Expand Down
23 changes: 23 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
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.

## [0.3.1] — 2026-10-24

### Added
- `MINIPSD_NO_READER` and `MINIPSD_NO_WRITER`: a build with only the writer or only the reader;
`minipsd_read` needs both.
- Typed setters, the mirror of the typed views: a struct in, the bytes of a resource out, into the
caller's buffer, measured first: `minipsd_resource_make_version_info`, `_grid_and_guides`,
`_alpha_names` (1045 or 1006, from UTF-8), `_alpha_identifiers`, `_transparency_index`, and
`minipsd_utf8_to_utf16be`. Resolution info is `dpi`: `minipsd_resource_make_resolution_info`
returns the new `MINIPSD_ERR_USE_DOC_FIELD`. `minipsd_layer_make_block_settings` sets a layer's
id, colour label, fill, locks and knockout in the blocks it has: each replaced where it is,
added only when missing, the others kept. Size constants (`MINIPSD_GRID_AND_GUIDES_SIZE(n)`,
`MINIPSD_BLOCK_SETTINGS_MAX`, ...) make one call with a fixed buffer enough.
- `minipsd_blocks_settings`: a layer's settings from its blocks alone, such as a document's
`minipsd_layer.additional_info`.
- The pixel aspect ratio (resource 1064): `minipsd_info.pixel_aspect_ratio` (1.0 without one), to
find or refuse non-square pixels; `minipsd_doc.pixel_aspect_ratio` writes one (0: none of the
writer's own), and `minipsd_read` keeps the file's. `MINIPSD_RES_PIXEL_ASPECT_RATIO`.

### Fixed
- Under `MINIPSD_STATIC`, the functions a file does not call no longer warn (`-Wunused-function`
in gcc and clang, C4505 in MSVC): each is marked possibly unused.

## [0.3.0] — 2026-10-23

**Breaking:** `minipsd_write`, `minipsd_writer_workspace_size`, `minipsd_writer_init` and
Expand Down
13 changes: 10 additions & 3 deletions docs/CODE-ARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ include/
base.h return-code texts
writer.h the encoder (pull), plus the 0.1-compatible minipsd_write (push)
views.h typed views: a kept resource or a layer's blocks parsed into plain values
builders.h typed setters: the mirror, plain values made into a resource's or blocks' bytes
reader.h the decoder: views into the caller's bytes
read.h minipsd_read: a whole file into one standalone document, on the reader
detail/
Expand All @@ -103,6 +104,7 @@ include/
utf.h UTF-8 <-> UTF-16 for layer names and other text
packbits.h PackBits encode, decode, size bound; strided source and target
composite.h blending one row "over", white matte and un-matte
floats.h floating-point values compared within a tolerance, finite checks; no libm
format.h the file's layout: each structure's fields and sizes, and what PSD and PSB
differ in (length and row-count widths, long keys, blocks)
examples/ the I/O implementations; built and run in CI, never installed
Expand Down Expand Up @@ -141,10 +143,15 @@ Rules for the split:
- `MINIPSD_IMPLEMENTATION` is defined once, as today.
- Each implementation file is guarded by its own `MPSD__<MODULE>_DONE`, so it is built once
however often `minipsd.h` is included.
- `MINIPSD_STATIC` keeps working unchanged.
- `MINIPSD_STATIC` keeps working; its functions are marked possibly unused, since a file calls
only some.
- **No unused internals.** The full implementation, compiled with extern linkage, leaves no
`static` function unused: that is a warning, and warnings are errors.
- **Optional subsystems hide behind macros in `minipsd.h`**, the way `MINIPSD_NO_MALLOC` does.
`static` function unused: that is a warning, and warnings are errors. Without the reader or
the writer (`MINIPSD_NO_READER`, `_NO_WRITER`) the helpers in `detail/` are marked possibly
unused (`MPSD__SHARED`), so this check still holds for the full build. `minipsd.h` includes
`detail/` before either part, so the single file keeps them whichever part is left out.
- **Optional subsystems hide behind macros in `minipsd.h`**, the way `MINIPSD_NO_MALLOC`,
`MINIPSD_NO_READER` and `MINIPSD_NO_WRITER` do.
- **Promotion is additive.** `packbits` and `composite` stay internal until a consumer needs
them. Making one public means adding a declaration to `minipsd.h`, a PATCH change, never a
rename of anything existing.
Expand Down
5 changes: 5 additions & 0 deletions docs/CODING_STYLE_C.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ together, under a table of the structure's fields and sizes, in the module the r
share. An offset read once, right where the table shows it, may stay a number. Plain arithmetic
and alignment (`pad(n, 4)`, `* 2` for UTF-16 units) are not facts to name.

Floating-point values are never compared with `==` or `!=`: within a tolerance, a named epsilon,
through `detail/floats.h` (`mpsd__floats_equal`), which also says whether a value is finite
(`mpsd__floats_positive`, `mpsd__floats_not_negative`), all without libm. The tests do the same
(`EXPECT_FLOAT_EQ`, `EXPECT_DOUBLE_EQ`). Byte identity of copied data is not a float comparison.

## Naming

| Kind | Form |
Expand Down
2 changes: 1 addition & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ output is covered by the golden test and the `psd-tools` round trip (see the spe
[Verification](SPECIFICATION.md#verification)); this page is about everyone else's files.

This page is generated by [tools/compat_report.py](../tools/compat_report.py) with minipsd
0.3.0, over 315 files of psd-tools v1.25.0.
0.3.1, over 315 files of psd-tools v1.25.0.

## Credits

Expand Down
6 changes: 4 additions & 2 deletions docs/GITHUB-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ that tag (FetchContent, submodule), so a tag is never moved once pushed.

Pushing the tag starts the [`Release`](../.github/workflows/release.yml) workflow:

1. the full [`CI`](../.github/workflows/ci.yml) on every toolchain, including the `psd-tools`
round trip;
1. a check that the tagged commit is on `master` and that [`CI`](../.github/workflows/ci.yml)
(every toolchain, the `psd-tools` round trip) passed on it: CI runs on every push to
`master`, so the release does not test it again. A tag pushed before that run ends fails
here; re-run the job once CI is green;
2. a check that the tag equals the `MINIPSD_VERSION_*` macros in `include/minipsd.h` and that
`docs/CHANGELOG.md` has a section for that version;
3. `minipsd.h` as a single file: `include/` joined by `tools/amalgamate.cmake`;
Expand Down
66 changes: 63 additions & 3 deletions docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ complete programs here are in [examples/](../examples/), which CI builds and run
| [Round trip](#round-trip) | a file read into a document, changed, written back; what is kept, what is lost |
| [Memory and ownership](#memory-and-ownership) | what you allocate, how long it must live |
| [Return codes](#return-codes) | `MINIPSD_OK`, `MINIPSD_MORE`, errors |
| [Configuration](#configuration) | `MINIPSD_STATIC`, the allocator, a build without allocation |
| [Configuration](#configuration) | `MINIPSD_STATIC`, the allocator, a build without allocation, the reader or the writer |

---

Expand Down Expand Up @@ -300,6 +300,30 @@ doc.composite = flattened; /* width * height * 4, straight alpha; NULL: compos
Without one, the writer composes the visible layers with "over" at their opacity: enough for a
preview, with no guarantee for anything else.

Guides, the alpha channels' names, version info, and a layer's id, colour label, fill and locks go
in as resources and blocks. A typed setter makes their bytes from the same structs the typed
views read ([Metadata](#metadata)), into your buffer; the size constants make one call enough:

```c
static const minipsd_guide guides[] = { { 100 * 32, 0 }, { 50 * 32, 1 } }; /* x = 100, y = 50: pixels times 32 */
const minipsd_grid_and_guides grid = { .version = 1, .horizontal_grid = 576, .vertical_grid = 576 };
uint8_t guide_bytes[MINIPSD_GRID_AND_GUIDES_SIZE(2)];
size_t n;
minipsd_resource_make_grid_and_guides(&grid, guides, 2, guide_bytes, sizeof guide_bytes, &n);
const minipsd_resource resources[] = { { .id = MINIPSD_RES_GRID_AND_GUIDES, .data = { guide_bytes, n } } };
doc.resources = resources;
doc.resource_count = 1;

const minipsd_block_settings blue = { .present = MINIPSD_PRESENT_SHEET_COLOR, .sheet_color = 5 };
uint8_t label[MINIPSD_BLOCK_SETTINGS_MAX];
minipsd_layer_make_block_settings(NULL, doc.format, &blue, label, sizeof label, &n); /* a new layer: no blocks yet */
logo.additional_info = (minipsd_bytes){ label, n };
```

The resolution is `dpi`: `minipsd_resource_make_resolution_info` makes nothing and says so
(`MINIPSD_ERR_USE_DOC_FIELD`). Version info's names are UTF-16BE, as the view gives them;
`minipsd_utf8_to_utf16be` makes them from text.

The document, its pixels and the workspace must stay valid, and unchanged, until the last byte
is out: the writer sized everything from them at the start.

Expand Down Expand Up @@ -564,7 +588,17 @@ visible.

### Metadata

`info` has what most programs need: `dpi` and the ICC profile. For more, find a resource by its
`info` has what most programs need: `dpi`, the ICC profile, and `pixel_aspect_ratio`, a pixel's
width over its height (1.0 when the file says nothing). A program that handles only square pixels
can refuse the others; compare within a tolerance, never with `!=`:

```c
if (fabs(info.pixel_aspect_ratio - 1.0) > 1e-6)
return refuse("non-square pixels");
```

A document writes one when its `pixel_aspect_ratio` is above 0; `minipsd_read` sets it from the
file. For more, find a resource by its
id and let a typed view parse it; none of them needs the reader again, so a document's
`resources` work too:

Expand Down Expand Up @@ -645,6 +679,30 @@ The document lives in one block: from `MINIPSD_MALLOC`, freed by `minipsd_doc_fr
memory of yours with `minipsd_read_size` and `minipsd_read_into`. Nothing in it points into the
file, so the file's bytes can go as soon as the call returns.

To change a setting the file already has, set it in the layer's blocks. The setter replaces the
block where it is, since readers take the first block of a key, and copies every other one (text,
effects) as it was. The document's layers are `const`, so edit a copy of the array:

```c
minipsd_layer *layers = malloc(doc->layer_count * sizeof *layers);
const minipsd_block_settings red = { .present = MINIPSD_PRESENT_SHEET_COLOR, .sheet_color = 1 };
const size_t room = doc->layers[0].additional_info.size + MINIPSD_BLOCK_SETTINGS_MAX;
uint8_t *blocks = malloc(room);
size_t n;
memcpy(layers, doc->layers, doc->layer_count * sizeof *layers);
minipsd_layer_make_block_settings(&layers[0].additional_info, doc->format, &red, blocks, room, &n);
layers[0].additional_info = (minipsd_bytes){ blocks, n }; /* the bottom layer's label: red */
doc->layers = layers;
rc = minipsd_write(doc, NULL, to_file, f);
free(blocks);
free(layers);
minipsd_doc_free(doc);
```

A resource the file has is replaced the same way: copy `doc->resources`, put the made one in place
of the entry of that id, and point `doc->resources` at the copy. `minipsd_blocks_settings` reads a
document layer's settings from its blocks, as `minipsd_layer_block_settings` reads a file's.

**What comes back.** Every layer with its rectangle, name, opacity, visibility, blend mode,
clipping and flags, its pixels, its mask, its "Blend If" ranges and its place in the groups; the blocks a layer carries
(text, effects, vector masks, smart objects) and the file's resources and global blocks, byte for byte, so a program
Expand Down Expand Up @@ -778,6 +836,8 @@ Define before the `#include` in the implementation file:

| Macro | Effect |
|---|---|
| `MINIPSD_STATIC` | every function `static`, private to that file: several copies never clash |
| `MINIPSD_STATIC` | every function `static`, private to that file: several copies never clash, and those you do not call do not warn |
| `MINIPSD_MALLOC` / `MINIPSD_FREE` | replace `malloc` / `free`; define both |
| `MINIPSD_NO_MALLOC` | no allocation at all, and no `<stdlib.h>`: `minipsd_write` goes, the pull writer and the reader stay |
| `MINIPSD_NO_READER` | no reader, typed views or `minipsd_read`: less code for a program that only writes |
| `MINIPSD_NO_WRITER` | no writer, typed setters or `minipsd_read`: less code for a program that only reads |
Loading
Loading