Skip to content

ImageScript 3.0: pure TypeScript, no WebAssembly or native binaries - #46

Merged
matmen merged 31 commits into
masterfrom
v3
Oct 14, 2026
Merged

matmen merged 31 commits into
masterfrom
v3

Conversation

@matmen

@matmen matmen commented Oct 14, 2026

Copy link
Copy Markdown
Owner

ImageScript 3.0: every codec and renderer rewritten in TypeScript. No more WebAssembly, native binaries or npm dependencies.

The Rust sources of the old WebAssembly modules are lost, and the native addons bundled GPL code, so none of that could be rebuilt or maintained. v3 rewrites everything in plain TypeScript:

  • Size: the npm package shrinks from 3 MB to about 100 kB.
  • Runtimes: runs anywhere Node.js 22.13+ runs (Alpine/musl, ARM, bundled serverless functions), as well as in Deno, Bun and browsers.
  • JSR: @matmen/imagescript is now the same package as on npm and is published with every release.

The full list of changes, with migration notes, is in CHANGELOG.md.

Breaking changes (short version)

  • Node.js 22.13+; ES modules only, require() keeps working through require(esm)
  • all coordinates are 0-based (getPixelAt, setPixelAt, iterators, fill, drawBox, drawCircle, ...)
  • removed: encodeWEBP(), invertSaturation(), invertHue(), the imagescript/v2 API
  • resizing defaults to bilinear; circles and rounded corners are antialiased
  • TextLayout alignment option names are fixed; the old values are still accepted
  • JPEGs, TIFFs and PNGs are rotated as their Exif/XMP orientation says (opt out with applyOrientation: false)
  • invertValue() inverts the HSV value; the old behavior is now invertLightness()

New

  • blur(), sharpen(), brightness(), contrast(), gamma(), greyscale(), sepia()
  • composite() with opacity and blend modes
  • outlined text
  • bilinear, bicubic and area resizing
  • kerning
  • smallest-color-type PNG encoding

Codecs

Codec Supports
PNG all color types and bit depths, interlacing
JPEG baseline and progressive; decoding bit-exact with libjpeg-turbo
GIF encode and decode
TIFF strips and tiles, LZW/deflate/PackBits/JPEG, BigTIFF
Fonts TrueType and OpenType/CFF
SVG shapes, paths, gradients, strokes, clip paths, masks, CSS

Testing

  • Test suite: compares decoded pixels with reference images and runs in CI on Node 22.13, 22, 24 and 26 on Linux, macOS and Windows.
  • Reference suites: PngSuite, pygif, libjpeg-turbo, libtiff pics and the resvg test suite.
  • Pixel comparison against Pillow:
    • 2,107 files: the test images of Pillow, image-rs, libjpeg-turbo and jpeg-js, plus 190 photos, PNGs, GIFs and TIFFs from Wikimedia Commons.
    • No crashes or timeouts.
    • Each remaining difference has a known cause: a Pillow quirk (GIF background disposal, grey tRNS, signed and 12-bit TIFF scaling) or a format that isn't supported (CCITT fax and old-style JPEG TIFFs, arithmetic-coded JPEGs).

Releasing

Publishing a GitHub release v<version>:

  1. checks the tag, semver, changelog and jsr.json, and that the version isn't on npm or JSR yet
  2. runs the test matrix
  3. publishes to npm (trusted publishing with provenance)
  4. publishes to JSR (OIDC)
  5. deploys the typedoc docs to GitHub Pages

Prereleases go to the npm dist-tag next/beta/...

Closes #12, closes #18, closes #23, closes #29, closes #30, closes #33, closes #34, closes #37, closes #38, closes #40, closes #41, closes #44, closes #45

Probably fixes #42 (the JPEG decoder was rewritten; the file from the report isn't attached)

matmen and others added 30 commits October 13, 2026 18:14
The new decoder in formats/png.js supports all color types and bit depths
(1-16), tRNS transparency (including grey/RGB color keys, which png.wasm
ignored), adam7 interlacing and chunk checksum validation. It passes the
PngSuite: output matches Pillow except where Pillow itself is wrong (16-bit
grey clipping, unscaled tRNS for low bit depth grey).

zlib (fflate) and crc32 are split out of the png/node.js bundle into
formats/ so the GIF and TIFF codecs can reuse them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
GIF.encode no longer uses the native codecs addon (and with it
libimagequant), GIF.decode no longer uses gif.wasm.

- formats/quantize.js: exact palette when a frame has few enough colors,
  otherwise weighted median cut + k-means refinement with floyd-steinberg
  dithering. quality scales the palette size from 8 colors (1) to 256 (90+)
- formats/gif.js: LZW encoder/decoder, local color tables, transparency,
  disposal modes, interlacing, NETSCAPE/ANIMEXTS loop counts
- GIF.decode now reads the loop count instead of always returning -1,
  clips frames to the logical screen and tolerates truncated files
- encoded output decodes pixel-identical in Pillow
- the decoder passes 72/84 of the pygif test suite; the rest expect images
  without delay to be merged into one frame (browsers and ImageScript keep
  one frame per image), malformed files to yield no frames (we decode
  leniently, like browsers) or GIF87a files to loop forever

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.decode no longer uses jpeg.wasm and Image.encodeJPEG no longer uses
the native codecs addon.

Decoder: baseline, extended and progressive huffman JPEGs, any sampling
factors, restart intervals, greyscale/YCbCr/RGB/CMYK/YCCK (Adobe inverted
CMYK included). IDCT, fancy upsampling and color conversion follow libjpeg,
so the output is bit-exact with libjpeg-turbo (Pillow) on every test image,
where jpeg.wasm was off by up to 7. Arithmetic coded, lossless and 12-bit
JPEGs are rejected with a clear error.

Encoder: baseline with IJG quality scaling, 4:2:0 up to quality 90 and
4:4:4 above, optimized huffman tables. Files are ~20% smaller than the
native encoder's at equal PSNR, encoding is ~2x slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.decode no longer uses tiff.wasm, which failed on every file of the
libtiff test images and on everything Pillow writes ("memory access out of
bounds"). Big endian TIFFs weren't even detected as TIFF.

formats/tiff.js decodes the first image of classic and BigTIFF files in
both byte orders: strips and tiles (including pre-6.0 tiles stored in the
strip tags), chunky and planar data, no/LZW (new and old style)/deflate/
PackBits/JPEG compression, horizontal and floating point predictors,
1-32 bit integer and 32/64 bit float samples, greyscale, palette, RGB, CMYK
and YCbCr (including subsampled) with associated or unassociated alpha.
Floats in [0, 1] or [0, 255] are used as is, other ranges are normalized.
CCITT fax, old-style JPEG, ThunderScan and LogLuv are rejected with an
error naming the compression.

Output matches Pillow on the libtiff test images and a generated corpus,
except where Pillow clips 16-bit greyscale (verified against the raw
samples instead) and off-by-one rounding for YCbCr and premultiplied alpha.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.renderText no longer uses font.wasm.

- formats/font.js parses TrueType (glyf, including composite glyphs) and
  OpenType CFF fonts (including CID-keyed fonts) and the first font of a
  collection, with cmap formats 0, 4, 6 and 12
- formats/text.js rasterizes outlines with a signed area coverage
  rasterizer (the font-rs algorithm fontdue is based on) and lays out text

Layout metrics match the previous renderer (baseline at ceil(ascender),
line height ceil(ascender - descender + lineGap), ceiled advances), so
images keep their size and text its position; glyph coverage differs by
less than 1/255 on average. Fonts are parsed lazily, rendering with an
11MB CJK font takes ~2ms instead of ~1.4s.

TextLayout options that were silently ignored now work: horizontal
alignment (verticalAlign: center/right), wrapStyle 'char', and vertical
alignment (horizontalAlign: middle/bottom) choosing which lines remain
when maxHeight cuts lines off. Newlines no longer widen a line as if they
were a glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.renderSVG no longer uses svg.wasm (resvg 0.13).

formats/svg.js renders static SVG: shapes, paths (including arcs),
transforms, nested svg/use/symbol with viewport clipping, presentation
attributes, style attributes and <style> sheets (type, class, id,
attribute and :first-child selectors with descendant/child/sibling
combinators, !important), currentColor, linear and radial gradients,
strokes with joins, caps and dashes, nonzero/evenodd fills, group opacity,
clip paths and luminance/alpha masks, conditional processing (switch,
systemLanguage) and DOCTYPE entities. Text, images, filters, patterns and
markers are not rendered.

Coverage is computed with 16 sub-scanlines per pixel and exact horizontal
span coverage. On resvg's own test suite (951 cases for shapes, painting,
paint servers, structure and masking) it matches 687 reference images, the
old resvg build 682, although patterns and images (64 of the old passes)
are left out. On the repo's test SVGs the output differs from resvg only
in anti-aliasing at the edges. The old build crashed on the new
tests/svgs/features.svg.

Rendering is slower than the wasm build for large outputs (~130ms vs ~25ms
at 2048px), 2-25ms for icon sizes up to 512px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Every codec and renderer is now plain JavaScript, so the compiled blobs go:
wasm/ (gif.wasm, jpeg.wasm, svg.wasm, tiff.wasm) and codecs/ (the
@imagescript/codecs native addons for 5 platforms plus its wasm stubs).

- the experimental v2 API (v2/index.js) uses the formats/ codecs instead
  of the blobs, same interface
- GIF frame compositing is shared between GIF.decode and v2 Animation
- WebP encoding is dropped: Image.encodeWEBP and v2 encode('webp') now
  reject with an error pointing to PNG/JPEG (breaking change)
- README describes the codecs as pure JavaScript

The npm package shrinks from 3.0 MB (71 files) to 152 kB (43 files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
The image operations no longer go through the v2 framebuffer bundle,
which had several bugs:

- rotate(90) and rotate(270) scrambled non-square images, leaving black
  gaps (#29)
- free rotation dropped the first row and column of the source and wrote
  one pixel past every output row
- composite() ignored the destination alpha, so semi-transparent pixels
  drawn onto transparent ones turned dark (white at 50% became grey)
- cropCircle(max) ignored max

rotate() is documented as counterclockwise, which it always was.
tests/rotate.js wrote its targets before comparing against them, so it
could never fail; it now only writes them with OVERWRITE_TEST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
The v2 API was an unfinished, undocumented port with its own framebuffer
bundle; ImageScript no longer uses any of it. png/ (the old PNG codec
sources) and bench/ (benchmarks against external wasm packages) only
existed for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Bugs:
- crop() read past the row (and from the end of the buffer for negative
  offsets) when the region exceeded the image; it is now clipped and
  throws if it doesn't overlap the image
- drawBox() threw when the box was below the image
- averageColor() divided unweighted color sums by the alpha sum, which
  was wrong (and could overflow) for semi-transparent images
- dominantColor() never reached its fallback; transparent pixels are
  now ignored
- Image.gradient() returned signed colors, unlike rgbaToColor()
- encode(metadata) treated the metadata as the compression level
- Frame.resize() returned a new frame with default duration, offsets and
  disposal mode; Frame.clone() returned an Image
- GIF.slice() without an end threw, map/filter threw (Symbol.species)
- GIF.resize() stretched partial frames to the full size and produced
  fractional offsets; frames are now scaled proportionally
- fisheye() mixed 1- and 0-based indices, shifting the result and
  wrapping the last row
- TextLayout's verticalAlign set the horizontal alignment and vice versa;
  the names are fixed, old values are still accepted under either name

ImageScript.d.ts was not valid TypeScript (async and generators in an
ambient context, Buffer without @types/node, incompatible overrides),
which skipLibCheck hid. It is rewritten to match the API, tsconfig no
longer skips it and CI type-checks it.

tests/regressions.js covers all of the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Pixel access, iteration, fill callbacks, drawBox and drawCircle used
1-based coordinates while crop and composite used 0-based offsets. All
of them are 0-based now, (0, 0) being the top left pixel (breaking).

roundCorners() is rewritten as well: corners were asymmetric by a pixel
and fractional radii (like the default min(width, height) / 4 for odd
sizes) left the right and bottom corners untouched.

The changed targets are the old ones shifted by one pixel, except for
readme.png which also has rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
The library is now written in TypeScript (strict, noImplicitOverride) and
built to dist/ as ES modules with generated type declarations, replacing
the hand-written ImageScript.d.ts. CommonJS consumers keep using
require('imagescript') through require(esm), which needs Node 22.12; the
engines field and CI matrix (22, 24, 26) follow that. Node 20 is EOL.

- src/ mirrors the old layout: codecs/ (png, gif, jpeg, tiff, zlib, crc,
  quantize), render/ (font, text, svg), the public classes in separate
  modules and index.ts as the entry point
- no var anywhere (zlib was the last fflate-style code)
- no runtime globals besides ES2022: UTF-8 handling has its own helpers
  instead of TextEncoder/TextDecoder
- PNG and TIFF inflate through DecompressionStream (native zlib) when the
  runtime has it, falling back to the JS inflate otherwise; 2048x2048 PNG
  decoding goes from 202ms to 119ms
- the font cache is keyed by the font data object instead of its
  ArrayBuffer and offset, which pooled Node buffers could reuse for a
  different font
- tests run against the build; all PNG/GIF/JPEG targets are byte-identical
  to the JS version, and the reference corpora (PngSuite, pygif, resvg,
  libjpeg-turbo and libtiff images) give the same results as before
- CHANGELOG.md lists the 3.0 breaking changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
median cut works on typed arrays with a stable merge sort instead of
sorting point objects, and nearest color lookups search outwards along
the palette's widest axis instead of scanning every entry. the output
is byte-identical; gif encoding is 1.2-2.3x faster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
- hsl conversions round instead of truncating and wrap negative hues,
  so lightness(1)/saturation(1) keep the image and hueShift(-300) works
- composite() and rotate() rounded every channel up (+0.5 on a clamped
  array, which rounds by itself)
- cover()/contain() could miss the requested size by a pixel
- node Buffers are unwrapped to plain Uint8Arrays, their slice() doesn't
  copy: decoding an uncompressed tiff with a predictor modified the
  input. the font cache is keyed by memory region so it still hits
- nested svg entities could exhaust the heap with a 1 kB file, the
  expansion is now capped
- inflate threw on stored blocks past the expected size
- the test runner's timeout never fired
- invert(), dominantColor() and rotate() avoid per-pixel allocations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
…and png color types

- resize modes RESIZE_BILINEAR (new default), RESIZE_BICUBIC and
  RESIZE_AREA: separable convolution in premultiplied alpha with the
  filter widened by the downscale factor (like Pillow), so shrinking
  doesn't alias. nearest neighbor samples pixel centers
- drawCircle, cropCircle and roundCorners antialias their edges;
  cropCircle was off by half a pixel and its feathering faded the whole
  circle, it now softens the outer share of the radius
- dominantColor returns the mean of the largest median cut / k-means
  cluster instead of an HSL histogram bucket corner (#fb0000 for red),
  and only discards colors as black/white when all channels are
  extreme (navy was dropped as black)
- text is placed with subpixel precision (quarter pixel glyph bitmaps)
  and kerned via GPOS pair adjustments or the kern table; positions
  match harfbuzz for all test fonts
- png encoding picks the smallest lossless color type (palette with
  1-8 bit indices, grey/rgb, alpha only when needed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
every row gets the filter type with the smallest sum of absolute
differences (libpng's heuristic). palette images and level 0 stay
unfiltered, like libpng recommends. at the default level photos get
about 25% smaller (698 -> 523 kB for a 638x638 photo, 66 -> 79 ms)
and smooth gradients up to 97% smaller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
performance (output unchanged unless noted):
- png compression and decompression use node:zlib through
  process.getBuiltinModule when available (node, bun, deno), with the
  JS deflater/DecompressionStream as fallback. CompressionStream was
  measured and not used: it is fixed to zlib level 6 and twice as slow
  as fflate level 1 on filtered photos. 4 MP encode 640 -> 270 ms
- png rows are paeth filtered instead of picking a filter per row:
  half the cost, sizes within 0.1%
- png unfilter handles edges in separate loops, converters step through
  the output instead of calling a closure per pixel
- hsl functions convert in place without allocations, wrap hues without
  float modulo and cache results of recent colors: 340 -> ~100 ms
- rotate inlines its bilinear taps: 970 -> 250 ms
- composite copies opaque pixels as 32-bit words

api:
- invertLightness(), invertValue() is deprecated
- encodePNG() as an alias of encode()
- invertHue() shifts to the complementary hue instead of mirroring it
- gif frame durations are rounded to centiseconds instead of truncated
- parameter names: red/green/blue(factor), renderText(font, size),
  fisheye(strength)

tests compare decoded pixels instead of encoded bytes, so they don't
depend on the runtime's compressor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
instead of deprecating it as an alias of invertLightness: the
brightest channel v becomes 255 - v and the others scale along, so hue
and hsv saturation are kept and black turns white. the old hsl
lightness behavior is invertLightness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
- jpeg (exif app1) and tiff orientation tags are applied when decoding,
  verified against Pillow's exif_transpose for all eight orientations;
  Image.decode(data, {applyOrientation: false}) opts out
- rgbaToColor/rgbToColor round and clamp instead of wrapping
- Frame.disposalMode returns names like the constants it is set with,
  Frame.DISPOSAL_ANY added
- assigning image.bitmap keeps the internal views in sync (they kept
  pointing at the old pixels), and validates the size
- options objects for cropCircle, dominantColor and rotate
- ImageType checks accept any binary data
- node >= 22.13: 22.12 prints an ExperimentalWarning on require(esm);
  ci tests the minimum version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
new:
- blur(radius): gaussian with radius = standard deviation; three
  running-sum box passes for radius >= 2 (cost independent of the
  radius), a real kernel below. premultiplied, so transparent pixels
  don't bleed. within a few levels of Pillow's GaussianBlur
- sharpen(amount, radius): unsharp mask, matches Pillow's UnsharpMask
  within a few levels
- brightness, contrast, gamma (lookup tables), greyscale (601 luma,
  identical to Pillow's convert('L')) and sepia
- composite(source, x, y, {opacity, mode}) with the css mix-blend-mode
  set plus add (w3c compositing model; within 1 of Pillow's ImageChops)
- renderText(..., {strokeWidth, strokeColor}): outline from an exact
  euclidean distance transform, independent of the stroke width

removed: invertSaturation(), invertHue() (use hueShift(180)) and the
encodeWEBP() stub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
docs are built with typedoc (npm run docs) and deployed by a workflow on
every published release. pages of the old jsdoc site redirect to their
new location, anchors included. every exported symbol is documented now
(typedoc validates it), overloads carry their own docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
a published release with the tag v<version> is checked by
scripts/release-check.js (tag matches package.json, valid semver, newer
than the published versions of its major, changelog section, prerelease
flag matches), tested with the full node matrix, published through npm
trusted publishing with provenance and then the docs are deployed.
prereleases go to their own dist-tag (beta, rc, next), fixes for older
majors to v<major>-latest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
…larations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
about 2.4x the throughput of the byte-wise loop, which mostly shows in
uncompressed PNG encodes and in the checksum verification when decoding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
the Exif parser moves out of the JPEG codec so both share it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
jsr.json publishes the TypeScript sources as @matmen/imagescript, its version
is kept in sync by the build. The release workflow publishes to JSR after npm
through GitHub OIDC, the release check makes sure the versions match and JSR
doesn't have the version yet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
@matmen matmen added this to the v3 milestone Oct 14, 2026
@matmen matmen self-assigned this Oct 14, 2026
the slice-by-16 crc32 comes from Arjun Barrett's gist, parts of the JPEG
codec are translated from libjpeg (IJG) and follow pdf.js' decoder, the
glyph rasterizer is translated from font-rs. THIRD_PARTY_NOTICES.md lists
them with their licenses (full Apache-2.0 text and libjpeg's README in
licenses/), the derived files say so in /*! */ headers that survive the
build, which used to strip fflate's notice from dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
@matmen
matmen merged commit ea5fbbf into master Oct 14, 2026
13 checks passed
@matmen
matmen deleted the v3 branch October 14, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment