Design procreate import - orlandomenke/Lightbox GitHub Wiki
BrushsetReader reads .brushset and .brush files. This note says what the
format is, where that was learned, what is carried across and what is not, and
which numbers are choices.
A file the owner supplied, 2026-10-12: one .brushset of ten brushes, opened
and read. Nothing was learned from Procreate's binary, from another importer's
source or from leaked material. Two public formats sit underneath it and were
read from their public descriptions:
-
zip, through the runtime's
System.IO.Compression. -
Apple's binary property list (
bplist00), from the layout comment at the head of Apple's open-sourceCFBinaryPList.c: header, objects, offset table, 32-byte trailer; a marker byte per object, type in the high nibble.
The settings file is a keyed archive: a property list holding an object table
($objects) and a $top entry naming the root, with strings held by reference
and entry 0 the string $null. That arrangement was read off the owner's file,
where it is visible as plain keys; it is the one Apple's public Foundation
documentation describes for NSKeyedArchiver.
The brush settings themselves are self-describing: a dictionary of named values
(paintSize, plotSpacing, shapeInverted ...). The names were read from the
file. Their meanings and scales were not published by anybody and are inferred
here, which is the limit of this importer and is why the manual calls the size
an estimate.
No new dependency. No brush pack is in the repository (L4): the tests build their archives byte by byte.
The sample is somebody else's work, and stays out. It is a third party's
brush pack that the owner downloaded; several of its brushes sit on Procreate's
stock shapes. It is reached through LIGHTBOX_PROCREATE_SAMPLE on the owner's
machine and must not be committed as a fixture. For the same reason (L2) no
built-in preset may be tuned by copying its numbers: the follow-on work maps
what a setting means, checked against a brush that uses it, and ships no
brush derived from the pack.
| Entry | What it is |
|---|---|
<folder>/Brush.archive |
One brush's settings: a keyed archive (a binary property list holding an object table and a root reference). In a .brush it is at the archive's root. |
<folder>/Shape.png |
Optional. The tip, greyscale, white is paint. 2048 px in the owner's file. |
<folder>/Grain.png |
Optional. The texture the tip is drawn through. Not read. |
<folder>/QuickLook/Thumbnail.png |
A preview. Not read. |
brushset.plist |
Sets only. An XML property list: the set's name and the folders in the artist's order. |
A brush that uses one of Procreate's stock shapes has no Shape.png; its
settings name an image inside the app (bundledShapePath). Seven of the ten
brushes in the owner's file are like this. Those images are not in the file and
are not ours to ship, so such a brush arrives with no tip.
ImportedBrush is the same five things for every format, and this reader fills
them and nothing else:
| Ours | From | How |
|---|---|---|
| Name | name |
As written; the file's name when absent. |
| Tip | Shape.png |
Brightness becomes alpha (inverted when shapeInverted), longest side brought down to 1024. |
| Size |
paintSize x maxSize
|
Times PixelsPerSizeUnit (100), clamped 1 to 500. A choice, see below.
|
| Spacing | plotSpacing |
Read as a fraction of the diameter, floored at the engine's 0.02. |
| Opacity | maxOpacity |
Clamped 0.01 to 1. |
PixelsPerSizeUnit = 100 is this importer's choice and has not been measured
against Procreate. paintSize is the size slider (0 to 1) and maxSize is the
brush's own limit, which runs from 0.04 to 16 across the ten brushes. At 100 px a
unit they land at 4 px (HB Pencil) to 278 px (Airbrush Soft), which is the right
order for the brushes' names and nothing more than that. The way to replace it is
a side-by-side: one brush at a known size in Procreate on a canvas of known
pixels, measured.
paintOpacity, the opacity slider's last position, is deliberately not read:
it is where the artist left a slider, not a property of the brush.
Grain (Grain.png, textureScale, textureMovement), pressure, tilt and speed
response (dynamicsPressure*, dynamicsTilt*, dynamicsSpeed* and their
curves), jitter (dynamicsJitter*, plotJitter, shapeScatter,
shapeRandomise), taper (taper*, pencilTaper*), rotation (shapeRotation,
shapeAzimuth), blend mode, and the wet-mix values (dynamicsWetAccumulation,
dynamicsMix, dynamicsLoad, bleed, dilution).
Several have an obvious home in BrushSettings: scatter, the jitters, a grain
as a texture, pressure to size. They are left out because every one of them is a
number whose scale would be a guess, and in the one file there is to test
against most of them are zero, so a mapping could not be checked even by eye.
They are the next step, one at a time, each against a brush that uses it.
The wet-mix values describe Procreate's paint engine. They would be approximated by this app's colour mixing, never reproduced.
A brush pack is a file a stranger made. What the reader does about that:
- Nothing is written to disk. Entry names are matched, never used as paths. A name that climbs or nests deeper than one folder is not a brush.
- Inflation is counted, per entry, against a limit set by what the entry is for (1 MB settings, 1 MB manifest, 32 MB shape), whatever length the archive declares. Entries no brush needs are never opened.
- A shape must be a PNG, and its size is read from its header before a pixel is decoded; past 4096 px a side it is refused. The largest thing the reader ever holds is such a shape on a transparent background, decoded through RGBA: 64 MB, plus 16 MB of brightness, for the length of one brush.
- A file's tips share one budget: 32 million characters of encoded tip, which is 64 MB held, and 256 MB of shape inflated whether it produced a tip or not. Past either the remaining brushes arrive without their shapes. Two archive records may point at the same bytes, so a megabyte of noise can be named a thousand times.
- The property list reader builds each object once and pays for it. A reference costs the file a byte and would otherwise cost the reader whatever it points at. A second reference gets the value already built; a reference to a value still being built is a container that contains itself, refused there; and what is built is charged to a budget of 16 bytes per byte of file, which is what stops many table entries sharing one offset. Counts are checked against the bytes that would hold them, no container is sized from a count, nesting is limited to 24 and values to 100,000.
- The manifest is read forwards and refused past eight levels deep. Never through a document tree: an element's text is gathered by recursion there, and forty thousand nested elements, 1.7 KB zipped, overflow the stack. A stack overflow cannot be caught; the application goes, with whatever was unsaved. The DOCTYPE is skipped and never fetched: no resolver.
- Not cancellable inside one file. The import window's Stop is checked between files. Under these bounds the worst file found in review costs tens of seconds on a background thread, with the app usable throughout: about 0.65 s a megabyte-of-noise tip, until the tip budget closes.
-
A brush that cannot be read costs that brush, and a file with none left is
a
FormatExceptionwith a sentence in it, which the import window shows as a named unreadable file.
ProcreateBrushImportTests holds each of these with a file built to break it.
Found in review, not in design: the deep manifest and the shared blob were both live in the first cut, each reproduced by the sensitivity-guardian with a file of a few kilobytes that ended the process.