module

ContributorMural::SilhouetteTracer

Turns an image file into a SilhouetteMask.

Crystal has no image decoders, and adding one per format would mean a dependency per format. librsvg already reads all of them — it is the PNG rasterizer this action ships with — so the image is wrapped in a one-element SVG and rendered, and the only thing this has to decode is what comes back: cairo's 8-bit RGBA PNG, which is a zlib stream and five scanline filters.

Up to three passes. With ink: auto the image is first stretched over the whole canvas to decide what counts as ink: stretched, every transparent pixel is the image's own, where letterboxed it could be the canvas showing through. Then it is fitted into the canvas to find where the shape is, and last just that box is rendered, sized so every cell of the lattice lands on exactly SAMPLES × SAMPLES pixels. A tall, narrow logo is sampled as finely as a square one, and deciding a cell is a plain average rather than an interpolation.

Constants

CANVAS = 512
CUTOUT_SHARE = 0.01

How much of a stretched image may be transparent before it is treated as a cut-out. Anti-aliasing and a stray pixel cost a sliver; a logo on a transparent background leaves whole corners.

EDGE = 0.5

A pixel counts toward the shape's bounding box from here, or from threshold when that is lower. Half-covered anti-aliasing at the rim should not widen the box by a pixel either side — but a threshold lowered to keep a thin stroke has to keep its outer edge in the box too.

FLOOR = 0.1

Ink below this is paper: JPEG noise, a faint tint, anti-aliasing.

MAX_BYTES = (7 * 1024) * 1024

The image travels to librsvg as a base64 data URI in an XML attribute, and libxml2 refuses attribute values past ten million characters. Base64 costs a third on top, which puts the real ceiling at 7.5 MB.

PNG_SIGNATURE = Bytes[137, 80, 78, 71, 13, 10, 26, 10]
ROOT_TAG = /\A\x{FEFF}?(?:\s|<\?.*?\?>|<!--.*?-->|<!DOCTYPE[^\[>]*(?:\[.*?\])?\s*>)*\K<svg(?=[\s\/>])(?:[^>"']|"[^"]*"|'[^']*')*>/m

An SVG with a width and height but no viewBox has no aspect ratio for meet to keep: librsvg stretches it over the whole canvas instead. Its own width and height are what a viewBox would have said, so say it — when they are plain numbers. A size in other units is left alone.

The root tag is the first one past the prolog, not the first <svg anywhere: a comment or DOCTYPE that mentions one came first and got the viewBox instead. Quote-aware, so a > inside an attribute value does not end the tag early.

SAMPLES = 8

Class methods

decode(png : Bytes) : Tuple(Int32, Int32, Bytes)

{width, height, RGBA bytes}. Only as much PNG as librsvg writes: 8-bit RGBA or RGB, not interlaced. RGB is widened to opaque RGBA.

Source
load(workspace : String, config : SilhouetteConfig, rasterizer : Rasterizer) : SilhouetteMask
Source
trace(bytes : Bytes, config : SilhouetteConfig, rasterizer : Rasterizer, name : String = "image") : SilhouetteMask
Source