libpng 1.6.58.2 → 1.6.58.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +99 -0
- data/CLAUDE.md +109 -0
- data/README.adoc +32 -15
- data/TODO.roadmap/01-claude-md-update.md +27 -0
- data/TODO.roadmap/02-encode-standard-ractor-spec.md +24 -0
- data/TODO.roadmap/03-mini-portile2-bump.md +26 -0
- data/TODO.roadmap/04-changelog.md +34 -0
- data/TODO.roadmap/05-benchmarks.md +41 -0
- data/TODO.roadmap/06-malformed-input-tests.md +47 -0
- data/TODO.roadmap/07-interlace-option.md +40 -0
- data/TODO.roadmap/08-palette-support.md +54 -0
- data/TODO.roadmap/09-16-bit-encode.md +53 -0
- data/TODO.roadmap/10-libpng-1.6.59-bump.md +59 -0
- data/TODO.roadmap/11-read-side-metadata.md +59 -0
- data/TODO.roadmap/12-full-api-surface.md +61 -0
- data/TODO.roadmap/13-c-extension-setjmp.md +77 -0
- data/TODO.roadmap/14-progressive-decode.md +47 -0
- data/TODO.roadmap/15-file-io-variants.md +57 -0
- data/TODO.roadmap/16-architecture-refactor.md +97 -0
- data/TODO.roadmap/17-ruby-version-policy.md +46 -0
- data/TODO.roadmap/18-dependabot.md +52 -0
- data/TODO.roadmap/19-text-chunk-read.md +104 -0
- data/TODO.roadmap/20-color-metadata-read.md +100 -0
- data/TODO.roadmap/README.md +42 -0
- data/ext/extconf.rb +9 -2
- data/lib/libpng/binding.rb +57 -0
- data/lib/libpng/bytes_per_pixel.rb +43 -0
- data/lib/libpng/chunk_walker.rb +103 -0
- data/lib/libpng/decoded_image.rb +17 -0
- data/lib/libpng/error.rb +8 -0
- data/lib/libpng/recipe.rb +0 -1
- data/lib/libpng/simplified_decoder.rb +84 -0
- data/lib/libpng/simplified_encoder.rb +98 -0
- data/lib/libpng/standard_encoder.rb +219 -0
- data/lib/libpng/version.rb +1 -1
- data/lib/libpng.rb +99 -385
- data/libpng.gemspec +1 -1
- metadata +34 -3
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 10 - Libpng 1.6.59 bump when released
|
|
2
|
+
|
|
3
|
+
- **Priority**: Future
|
|
4
|
+
- **Status**: Planned (waiting on upstream)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The gem currently bundles libpng 1.6.58. Upstream `libpng16` branch is
|
|
9
|
+
at `1.6.59.git` (unreleased as of 2026-07-26). 1.6.59 will include:
|
|
10
|
+
|
|
11
|
+
- Memory safety fix: prevent double-free of `png_struct` members on
|
|
12
|
+
allocation failure (`cae47455`).
|
|
13
|
+
- Windows/MinGW import-library symlink fix (`78ae423b`) -- doesn't
|
|
14
|
+
affect us since we ship the .dll directly.
|
|
15
|
+
- CMake policy bump to 4.4 (`7ed557b4`).
|
|
16
|
+
- Various CI/license maintenance.
|
|
17
|
+
|
|
18
|
+
## Trigger
|
|
19
|
+
|
|
20
|
+
Tag `v1.6.59` on https://github.com/pnggroup/libpng.
|
|
21
|
+
|
|
22
|
+
## Approach
|
|
23
|
+
|
|
24
|
+
The release workflow already has a `bump-type=libpng` mode:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
gh workflow run release.yml --repo claricle/libpng-ruby \
|
|
28
|
+
--ref main \
|
|
29
|
+
-f bump-type=libpng \
|
|
30
|
+
-f libpng-version=1.6.59
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This bumps `LIBPNG_VERSION = "1.6.59"` in `lib/libpng/version.rb`,
|
|
34
|
+
resets `LIBPNG_RUBY_ITERATION` to 0, and triggers a 1.6.59.0 release.
|
|
35
|
+
|
|
36
|
+
Manual steps before triggering:
|
|
37
|
+
1. Update `LIBPNG_URL` and `LIBPNG_SHA256` in `lib/libpng/recipe.rb`
|
|
38
|
+
once the tarball is on sourceforge. Verify with:
|
|
39
|
+
`curl -sL <URL> | shasum -a 256`
|
|
40
|
+
2. Smoke-test locally: `bundle exec rake compile` (downloads new
|
|
41
|
+
source, builds, copies the .so/.dylib/.dll into `lib/libpng/`).
|
|
42
|
+
3. Run the full spec suite to catch any API regressions.
|
|
43
|
+
|
|
44
|
+
## Risks
|
|
45
|
+
|
|
46
|
+
- API surface: 1.6.59 is unlikely to add new public functions
|
|
47
|
+
(the diff between 1.6.58 and 1.6.59.git shows only bug fixes and
|
|
48
|
+
build improvements in `png.h`).
|
|
49
|
+
- Binary layout: `png_image` struct size and field offsets are
|
|
50
|
+
unchanged.
|
|
51
|
+
- Version string: `LIBPNG_VER_STRING_C` in `lib/libpng.rb` would need
|
|
52
|
+
to update to match the new binary, since `png_create_write_struct`
|
|
53
|
+
checks `user_png_ver` against the compiled-in version.
|
|
54
|
+
|
|
55
|
+
## Won't do
|
|
56
|
+
|
|
57
|
+
Don't ship a `1.6.59.git`-suffixed version. The `.git` suffix in our
|
|
58
|
+
`{LIBPNG_VERSION}.{ITERATION}` scheme would be confusing for users.
|
|
59
|
+
Wait for the actual 1.6.59 release.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 11 - Read-side metadata getters
|
|
2
|
+
|
|
3
|
+
- **Priority**: P2
|
|
4
|
+
- **Status**: Done
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`Libpng::DecodedImage` returned only `width`, `height`, `format`, and
|
|
9
|
+
`pixels`. Callers wanting bit_depth, color_type, or interlace method
|
|
10
|
+
had to walk the PNG themselves.
|
|
11
|
+
|
|
12
|
+
## Approach
|
|
13
|
+
|
|
14
|
+
Two pieces:
|
|
15
|
+
|
|
16
|
+
1. **Expanded Struct**: `Libpng::DecodedImage` now has `bit_depth`,
|
|
17
|
+
`color_type`, `interlace` keyword fields (in addition to the
|
|
18
|
+
existing four).
|
|
19
|
+
|
|
20
|
+
2. **`Libpng::ChunkWalker`**: new public class for walking PNG chunk
|
|
21
|
+
layout. Methods:
|
|
22
|
+
- `#each_chunk` -- yields `[type, data, offset]`
|
|
23
|
+
- `#strip_ancillary` -- returns a new binary String with only
|
|
24
|
+
IHDR/IDAT/IEND (used by SimplifiedEncoder when `strip_colorspace:
|
|
25
|
+
true`)
|
|
26
|
+
- `#ihdr_data` -- raw IHDR bytes
|
|
27
|
+
- `#ihdr_fields` -- parsed Hash: `:width, :height, :bit_depth,
|
|
28
|
+
:color_type, :compression, :filter, :interlace`
|
|
29
|
+
|
|
30
|
+
3. **`Libpng::SimplifiedDecoder`** uses `ChunkWalker#ihdr_fields` after
|
|
31
|
+
the libpng decode to populate the new Struct fields. Best-effort:
|
|
32
|
+
if the walker raises (e.g. corrupt PNG), the metadata stays nil and
|
|
33
|
+
the libpng-side error propagates.
|
|
34
|
+
|
|
35
|
+
## Refactor side-effect
|
|
36
|
+
|
|
37
|
+
The chunk-stripping logic in `Libpng::SimplifiedEncoder` was rewritten
|
|
38
|
+
to use `ChunkWalker#strip_ancillary` instead of inline code. DRY win:
|
|
39
|
+
one place that understands PNG chunk layout.
|
|
40
|
+
|
|
41
|
+
## Specs
|
|
42
|
+
|
|
43
|
+
`spec/decoded_image_metadata_spec.rb` verifies:
|
|
44
|
+
- IHDR metadata is populated for RGBA/RGB/GRAY decode
|
|
45
|
+
- Adam7 interlace is reflected after `encode_standard(interlace:
|
|
46
|
+
:adam7)` -> decode
|
|
47
|
+
- `ChunkWalker` parses IHDR fields correctly
|
|
48
|
+
- `ChunkWalker` raises `FormatError` on non-PNG input
|
|
49
|
+
|
|
50
|
+
## Delivered
|
|
51
|
+
|
|
52
|
+
PR #7 (this branch).
|
|
53
|
+
|
|
54
|
+
## Future work
|
|
55
|
+
|
|
56
|
+
- Expose text chunks (tEXt/zTXt/iTXt) -- see [TODO 19](19-text-chunk-read.md)
|
|
57
|
+
- Expose color metadata (gAMA/cHRM/sRGB/iCCP) -- see [TODO 20](20-color-metadata-read.md)
|
|
58
|
+
- Ancillary chunk preservation on decode (currently we expand to
|
|
59
|
+
pixels and drop everything else)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# 12 - Full libpng API surface binding
|
|
2
|
+
|
|
3
|
+
- **Priority**: Skip
|
|
4
|
+
- **Status**: Won't do
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
We expose 13 of libpng's 172 exported functions (~8%). The audit
|
|
9
|
+
(see PR #6 discussion) identified gaps in:
|
|
10
|
+
|
|
11
|
+
- Standard read API (10 functions)
|
|
12
|
+
- Write-side chunk setters (15+: PLTE, tRNS, gAMA, cHRM, sRGB, iCCP,
|
|
13
|
+
bKGD, pHYs, oFFs, tIME, sCAL, sPLT, hIST, text, unknown chunks)
|
|
14
|
+
- Info getters (~20)
|
|
15
|
+
- Read transformations (~20)
|
|
16
|
+
- Advanced compression controls
|
|
17
|
+
- Progressive/streaming decode
|
|
18
|
+
- File I/O variants
|
|
19
|
+
- Filter heuristics
|
|
20
|
+
- Interlacing helpers
|
|
21
|
+
- CRC error handling
|
|
22
|
+
- Memory/struct internals
|
|
23
|
+
|
|
24
|
+
## Why we won't do this
|
|
25
|
+
|
|
26
|
+
1. **Scope creep**: The gem's purpose is encode/decode with libemf2svg
|
|
27
|
+
parity, not a complete libpng binding.
|
|
28
|
+
2. **Maintenance cost**: Each function needs docs, tests, and version
|
|
29
|
+
compatibility checks across libpng releases.
|
|
30
|
+
3. **Ractor safety risk**: Many libpng functions are stateful
|
|
31
|
+
(`png_set_*` mutates `png_struct`). Exposing them naively could
|
|
32
|
+
introduce Ractor-safety violations.
|
|
33
|
+
4. **Alternatives exist**: Users needing low-level libpng access can
|
|
34
|
+
`attach_function` against the bundled `libpng16.so` directly
|
|
35
|
+
(it contains all 172 functions).
|
|
36
|
+
5. **YAGNI**: No user has requested specific functions beyond what
|
|
37
|
+
`encode_standard` provides.
|
|
38
|
+
|
|
39
|
+
## When this position might change
|
|
40
|
+
|
|
41
|
+
- A specific user need that can't be met by extending the existing
|
|
42
|
+
options-based API.
|
|
43
|
+
- A major libpng release that introduces a high-demand feature
|
|
44
|
+
(unlikely in the 1.6.x series).
|
|
45
|
+
- A formal request from emfsvg or another downstream consumer.
|
|
46
|
+
|
|
47
|
+
## Recommended path forward (if needed)
|
|
48
|
+
|
|
49
|
+
Instead of exposing `png_set_gAMA` etc. as individual Ruby methods,
|
|
50
|
+
add higher-level options to `encode_standard`:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
Libpng.encode_standard(width, height, pixels,
|
|
54
|
+
pixel_format: "RGBA",
|
|
55
|
+
gamma: 0.45455, # emits gAMA
|
|
56
|
+
chromaticities: {...}, # emits cHRM
|
|
57
|
+
srgb: 0, # emits sRGB
|
|
58
|
+
icc_profile: File.read("profile.icc")) # emits iCCP
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This keeps the API Ruby-idiomatic and limits the testing surface.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# 13 - C extension wrapper for setjmp
|
|
2
|
+
|
|
3
|
+
- **Priority**: Skip
|
|
4
|
+
- **Status**: Won't do
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`encode_standard` uses `png_set_error_fn` to register an FFI callback
|
|
9
|
+
that calls `rb_raise` instead of libpng's default `abort()`. libpng
|
|
10
|
+
expects error callbacks to `longjmp` back to a `setjmp` point set up
|
|
11
|
+
by the caller. Calling `rb_raise` from the FFI callback unwinds the
|
|
12
|
+
Ruby stack via Ruby's own `longjmp`-based exception machinery.
|
|
13
|
+
|
|
14
|
+
This works for input-validation errors (most cases) but has a subtle
|
|
15
|
+
caveat: libpng's internal state between the callback invocation and
|
|
16
|
+
the `setjmp` target is not cleaned up. Our `ensure` block calls
|
|
17
|
+
`png_destroy_write_struct` to release the C state, which is sufficient
|
|
18
|
+
in practice.
|
|
19
|
+
|
|
20
|
+
## Why we won't do this
|
|
21
|
+
|
|
22
|
+
A C extension could properly wrap the standard API with `setjmp`:
|
|
23
|
+
```c
|
|
24
|
+
if (setjmp(png_jmpbuf(png_ptr))) {
|
|
25
|
+
/* longjmp target -- copy error message, return error code */
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This would give clean error handling without Ruby stack unwinding
|
|
30
|
+
concerns.
|
|
31
|
+
|
|
32
|
+
But:
|
|
33
|
+
|
|
34
|
+
1. **Adds a build dependency**: A C extension means the gem requires a
|
|
35
|
+
C compiler at install time on every platform. We currently ship
|
|
36
|
+
pre-compiled binary gems for 9 platforms specifically to avoid
|
|
37
|
+
this. Adding a C extension would require shipping pre-compiled
|
|
38
|
+
extensions for each platform too -- a significant build/release
|
|
39
|
+
burden.
|
|
40
|
+
|
|
41
|
+
2. **Current approach works**: The rb_raise-from-callback pattern has
|
|
42
|
+
not caused any user-visible issues in testing or production use.
|
|
43
|
+
|
|
44
|
+
3. **Documented in README**: The caveat is called out in the
|
|
45
|
+
`encode_standard` documentation. Users who need bulletproof error
|
|
46
|
+
handling for adversarial input can validate before calling.
|
|
47
|
+
|
|
48
|
+
4. **Simplified API already handles it cleanly**: The `png_image_*`
|
|
49
|
+
API uses an internal `setjmp` and captures errors into the message
|
|
50
|
+
buffer. For decode paths (where adversarial input is most likely),
|
|
51
|
+
`Libpng.decode` is the right choice.
|
|
52
|
+
|
|
53
|
+
## When this position might change
|
|
54
|
+
|
|
55
|
+
- A real-world crash report attributed to the rb_raise-from-callback
|
|
56
|
+
pattern.
|
|
57
|
+
- A user needing to call `encode_standard` in a tight loop with
|
|
58
|
+
potentially-corrupt pixel buffers (where the half-freed png_struct
|
|
59
|
+
could accumulate state).
|
|
60
|
+
|
|
61
|
+
## Alternative under consideration
|
|
62
|
+
|
|
63
|
+
If we ever do add a C extension, it would be a tiny `.c` file (maybe
|
|
64
|
+
50 lines) exposing a single function:
|
|
65
|
+
|
|
66
|
+
```c
|
|
67
|
+
int libpng_encode_to_memory_safe(
|
|
68
|
+
uint32_t width, uint32_t height, int bit_depth, int color_type,
|
|
69
|
+
int interlace, int filter, int compression_level,
|
|
70
|
+
const void *pixels, int stride,
|
|
71
|
+
const void *palette, int palette_entries,
|
|
72
|
+
void **out, size_t *out_len,
|
|
73
|
+
char *err_msg, size_t err_msg_len);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This would be the only C function in the gem. FFI would bind it
|
|
77
|
+
directly. All libpng state would be confined inside the C function.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 14 - Progressive/streaming decode
|
|
2
|
+
|
|
3
|
+
- **Priority**: Skip
|
|
4
|
+
- **Status**: Won't do
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
libpng supports progressive (streaming) decode via
|
|
9
|
+
`png_set_progressive_read_fn` + `png_process_data`. This lets callers
|
|
10
|
+
feed bytes incrementally as they arrive (e.g. over a network socket)
|
|
11
|
+
rather than buffering the entire PNG first.
|
|
12
|
+
|
|
13
|
+
## Why we won't do this
|
|
14
|
+
|
|
15
|
+
1. **No use case yet**: The gem's consumers (emfsvg, image processing
|
|
16
|
+
tools) work with complete PNGs in memory.
|
|
17
|
+
|
|
18
|
+
2. **API complexity**: Progressive decode requires a stateful reader
|
|
19
|
+
object (`Libpng::ProgressiveReader`?) with `#feed(bytes)` and
|
|
20
|
+
`#finish` methods, plus callback hooks for row completion. This is
|
|
21
|
+
a significantly different API surface from the current
|
|
22
|
+
`Libpng.decode(png_bytes)`.
|
|
23
|
+
|
|
24
|
+
3. **Ruby I/O is usually buffered anyway**: Ruby's `IO.read`,
|
|
25
|
+
`Net::HTTP.get`, etc. all return complete strings. Streaming is the
|
|
26
|
+
exception, not the rule.
|
|
27
|
+
|
|
28
|
+
4. **Memory savings are minimal**: A typical PNG is 100KB-5MB.
|
|
29
|
+
Buffering the whole thing in a String is fine for almost all use
|
|
30
|
+
cases.
|
|
31
|
+
|
|
32
|
+
## When this position might change
|
|
33
|
+
|
|
34
|
+
- A user building a streaming image service (e.g. progressive JPEG-like
|
|
35
|
+
loading for web).
|
|
36
|
+
- A request from a framework like Rack that wants to decode PNG
|
|
37
|
+
uploads as they arrive.
|
|
38
|
+
|
|
39
|
+
## Alternative if needed
|
|
40
|
+
|
|
41
|
+
Use a tempfile + standard `Libpng.decode`:
|
|
42
|
+
```ruby
|
|
43
|
+
File.open(upload_path, 'wb') { |f| f.write(chunk) until done }
|
|
44
|
+
decoded = Libpng.decode(File.binread(upload_path))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Not streaming, but avoids holding the entire upload in Ruby memory.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 15 - File I/O variants
|
|
2
|
+
|
|
3
|
+
- **Priority**: Skip
|
|
4
|
+
- **Status**: Won't do
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
libpng exposes file-based I/O via `png_init_io(FILE*)`, plus the
|
|
9
|
+
simplified API has `png_image_begin_read_from_file` /
|
|
10
|
+
`png_image_write_to_file`. We currently only use the memory-based
|
|
11
|
+
variants (`_from_memory`, `_to_memory`, `png_set_write_fn` with
|
|
12
|
+
in-memory callback).
|
|
13
|
+
|
|
14
|
+
## Why we won't do this
|
|
15
|
+
|
|
16
|
+
1. **`FILE*` lifetime is awkward across FFI**: A Ruby `File` object
|
|
17
|
+
wraps a `FILE*`. Passing it to libpng via FFI requires either:
|
|
18
|
+
- Reaching into Ruby's internal struct layout (fragile, version-
|
|
19
|
+
dependent)
|
|
20
|
+
- Opening a separate `FILE*` via `fopen(3)` and managing its
|
|
21
|
+
lifetime manually (error-prone)
|
|
22
|
+
|
|
23
|
+
2. **Memory-stream output is the right default**: It avoids disk I/O
|
|
24
|
+
entirely, lets callers decide where to persist (or not), and works
|
|
25
|
+
identically across all 10 platforms.
|
|
26
|
+
|
|
27
|
+
3. **Ruby already has good file I/O**:
|
|
28
|
+
```ruby
|
|
29
|
+
png = Libpng.encode(width, height, pixels, pixel_format: "RGBA")
|
|
30
|
+
File.binwrite("out.png", png)
|
|
31
|
+
```
|
|
32
|
+
is idiomatic Ruby. Adding `Libpng.encode_to_file` would be a
|
|
33
|
+
duplicate API for marginal benefit.
|
|
34
|
+
|
|
35
|
+
4. **Simplified API file variants are POSIX-only**:
|
|
36
|
+
`png_image_begin_read_from_file` takes a `const char *filename`,
|
|
37
|
+
which doesn't handle Windows wide-char paths cleanly. The memory
|
|
38
|
+
variants sidestep this entirely.
|
|
39
|
+
|
|
40
|
+
## When this position might change
|
|
41
|
+
|
|
42
|
+
- A user with truly huge images (multi-GB) where holding the encoded
|
|
43
|
+
PNG in memory before writing to disk is wasteful.
|
|
44
|
+
- A performance benchmark showing `File.binwrite` is a real bottleneck
|
|
45
|
+
(unlikely -- disk I/O is fast and `File.binwrite` uses `write(2)`
|
|
46
|
+
directly).
|
|
47
|
+
|
|
48
|
+
## Alternative under consideration
|
|
49
|
+
|
|
50
|
+
If streaming-to-file becomes important, the right design is:
|
|
51
|
+
```ruby
|
|
52
|
+
Libpng.encode_standard(width, height, pixels, output: io_object)
|
|
53
|
+
```
|
|
54
|
+
where `io_object` is anything responding to `#<<` (String, File,
|
|
55
|
+
StringIO, etc.). The internal FFI write callback would call
|
|
56
|
+
`io_object << data.read_bytes(len)`. This is more flexible than a
|
|
57
|
+
file-only variant and requires no new libpng functions.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# 16 - Architecture refactor: autoload + OOP
|
|
2
|
+
|
|
3
|
+
- **Priority**: P0
|
|
4
|
+
- **Status**: Done
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`lib/libpng.rb` was a 466-line monolith mixing:
|
|
9
|
+
- Module definition
|
|
10
|
+
- FFI library setup
|
|
11
|
+
- 13 `attach_function` calls
|
|
12
|
+
- ~40 constants (FORMAT_*, COLOR_TYPE_*, FILTER_*, INTERLACE_*, etc.)
|
|
13
|
+
- `png_image` struct layout constants
|
|
14
|
+
- `encode`, `decode`, `encode_standard` methods (each 50-100 lines)
|
|
15
|
+
- 5 private helper methods
|
|
16
|
+
|
|
17
|
+
Plus the file used `require_relative 'libpng/version'`, violating the
|
|
18
|
+
project-wide rule against `require_relative` for internal library code.
|
|
19
|
+
|
|
20
|
+
## Approach
|
|
21
|
+
|
|
22
|
+
Split into MECE per-concern files under `lib/libpng/`, loaded via
|
|
23
|
+
`autoload` from `lib/libpng.rb`:
|
|
24
|
+
|
|
25
|
+
| File | Responsibility |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `error.rb` | `Libpng::Error` |
|
|
28
|
+
| `decoded_image.rb` | `Libpng::DecodedImage` Struct |
|
|
29
|
+
| `chunk_walker.rb` | `Libpng::ChunkWalker` (PNG chunk walking utility class) |
|
|
30
|
+
| `bytes_per_pixel.rb` | `Libpng::BytesPerPixel` (pure-data lookup) |
|
|
31
|
+
| `simplified_encoder.rb` | `Libpng::SimplifiedEncoder` (encode via simplified API) |
|
|
32
|
+
| `simplified_decoder.rb` | `Libpng::SimplifiedDecoder` (decode + metadata extraction) |
|
|
33
|
+
| `standard_encoder.rb` | `Libpng::StandardEncoder` (encode_standard with all options) |
|
|
34
|
+
|
|
35
|
+
Each encoder/decoder is a class instantiated per call:
|
|
36
|
+
```ruby
|
|
37
|
+
class Libpng::SimplifiedEncoder
|
|
38
|
+
def initialize(width, height, pixels, **opts)
|
|
39
|
+
# validate + store
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call
|
|
43
|
+
# do the work, return result
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The public API stays as module methods for backward compat:
|
|
49
|
+
```ruby
|
|
50
|
+
def self.encode(width, height, pixels, **opts)
|
|
51
|
+
SimplifiedEncoder.new(width, height, pixels, **opts).call
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why per-call instances
|
|
56
|
+
|
|
57
|
+
- **OCP**: New encode options (e.g. `gamma:`) extend the encoder class
|
|
58
|
+
without touching `Libpng.encode`.
|
|
59
|
+
- **Testability**: Each encoder is independently testable.
|
|
60
|
+
- **MECE**: Validation lives in the encoder, FFI calls live in the
|
|
61
|
+
encoder, dispatch lives in the module. Three concerns, three places.
|
|
62
|
+
- **Ractor-safe by construction**: Instances are local to one call,
|
|
63
|
+
never shared.
|
|
64
|
+
|
|
65
|
+
The per-call allocation cost (~1 object) is negligible compared to the
|
|
66
|
+
libpng C call.
|
|
67
|
+
|
|
68
|
+
## Why autoload instead of eager
|
|
69
|
+
|
|
70
|
+
- Faster `require 'libpng'` for users who only need one API
|
|
71
|
+
- Project rule: never `require_relative` for internal library code
|
|
72
|
+
- Lazy loading of rarely-used classes (e.g. `Recipe` is only needed
|
|
73
|
+
during source-gem install)
|
|
74
|
+
|
|
75
|
+
## What changed in ext/extconf.rb
|
|
76
|
+
|
|
77
|
+
Before:
|
|
78
|
+
```ruby
|
|
79
|
+
$LOAD_PATH << File.expand_path('../lib', __dir__)
|
|
80
|
+
require 'libpng/recipe' # violation: require with path
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
After:
|
|
84
|
+
```ruby
|
|
85
|
+
$LOAD_PATH << File.expand_path('../lib', __dir__)
|
|
86
|
+
require 'libpng' # triggers autoload setup
|
|
87
|
+
recipe = Libpng::Recipe.new # accesses Recipe constant, triggers autoload
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Specs
|
|
91
|
+
|
|
92
|
+
All existing specs still pass without modification (the public API is
|
|
93
|
+
unchanged). 81 examples, 0 failures.
|
|
94
|
+
|
|
95
|
+
## Delivered
|
|
96
|
+
|
|
97
|
+
PR #7 (this branch).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 17 - Ruby version support policy
|
|
2
|
+
|
|
3
|
+
- **Priority**: Future
|
|
4
|
+
- **Status**: Planned
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The gemspec declares `required_ruby_version = '>= 2.7.0'`. Ruby 2.7
|
|
9
|
+
reached EOL in March 2023. Ruby 3.0 reached EOL in March 2024. Ruby
|
|
10
|
+
3.1 is in security maintenance. Ruby 3.2, 3.3, 3.4 are current.
|
|
11
|
+
|
|
12
|
+
Ractors require Ruby 3.0+. Our Ractor specs guard with
|
|
13
|
+
`return unless defined?(Ractor)` so they no-op on 2.7, but the
|
|
14
|
+
`return` at file top-level might warn on some Ruby versions.
|
|
15
|
+
|
|
16
|
+
## Proposal
|
|
17
|
+
|
|
18
|
+
Bump `required_ruby_version` to `>= 3.1.0` in a future minor release.
|
|
19
|
+
|
|
20
|
+
Rationale:
|
|
21
|
+
- Drops EOL versions (2.7, 3.0) -- no security backports
|
|
22
|
+
- Keeps 3.1 (security maintenance) and all current versions (3.2, 3.3, 3.4)
|
|
23
|
+
- Aligns with `mini_portile2 ~> 2.8` which also requires 3.1+
|
|
24
|
+
- Removes the `return unless defined?(Ractor)` guard from spec files
|
|
25
|
+
(the gem can assume Ractor exists)
|
|
26
|
+
|
|
27
|
+
## Risks
|
|
28
|
+
|
|
29
|
+
- Some users may still be on 2.7 (legacy deployments). They'd be
|
|
30
|
+
stuck on the last 2.7-compatible release.
|
|
31
|
+
- RubyInstaller ARM64 builds start at 3.4.1, so Windows ARM64 users
|
|
32
|
+
are unaffected.
|
|
33
|
+
|
|
34
|
+
## Approach
|
|
35
|
+
|
|
36
|
+
1. Wait one release cycle after announcing the deprecation.
|
|
37
|
+
2. Bump `required_ruby_version` in `libpng.gemspec`.
|
|
38
|
+
3. Update the README Ruby version badge.
|
|
39
|
+
4. Remove the Ractor guard in `spec/ractor_spec.rb` and
|
|
40
|
+
`spec/ractor_standard_spec.rb`.
|
|
41
|
+
|
|
42
|
+
## Trigger
|
|
43
|
+
|
|
44
|
+
Either:
|
|
45
|
+
- ruby-setup-ruby drops 2.7 and 3.0 from its version matrix, OR
|
|
46
|
+
- A security feature in libpng requires Ruby 3.1+ APIs.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# 18 - Dependabot for automated dependency bumps
|
|
2
|
+
|
|
3
|
+
- **Priority**: Future
|
|
4
|
+
- **Status**: Planned
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The gem has runtime dependencies on `ffi` and `mini_portile2`, and dev
|
|
9
|
+
dependencies on `rake`, `rspec`, and `rubocop`. These are bumped
|
|
10
|
+
manually when someone notices a new version. Dependabot would automate
|
|
11
|
+
this and open PRs when new versions are released.
|
|
12
|
+
|
|
13
|
+
## Proposal
|
|
14
|
+
|
|
15
|
+
Add `.github/dependabot.yml`:
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
version: 2
|
|
19
|
+
updates:
|
|
20
|
+
- package-ecosystem: "bundler"
|
|
21
|
+
directory: "/"
|
|
22
|
+
schedule:
|
|
23
|
+
interval: "weekly"
|
|
24
|
+
open-pull-requests-limit: 5
|
|
25
|
+
groups:
|
|
26
|
+
dev-deps:
|
|
27
|
+
dependency-type: "development"
|
|
28
|
+
runtime-deps:
|
|
29
|
+
dependency-type: "production"
|
|
30
|
+
|
|
31
|
+
- package-ecosystem: "github-actions"
|
|
32
|
+
directory: "/"
|
|
33
|
+
schedule:
|
|
34
|
+
interval: "weekly"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Two groups:
|
|
38
|
+
- **dev-deps**: rake, rspec, rubocop -- batch into one PR per week
|
|
39
|
+
- **runtime-deps**: ffi, mini_portile2 -- separate PRs (more careful
|
|
40
|
+
review since these affect end users)
|
|
41
|
+
|
|
42
|
+
Plus GitHub Actions version bumps (checkout, upload-artifact, etc.).
|
|
43
|
+
|
|
44
|
+
## Risks
|
|
45
|
+
|
|
46
|
+
- Slightly noisier PR queue.
|
|
47
|
+
- Some bumps may break CI (e.g. rubocop new cops). Auto-merge is not
|
|
48
|
+
appropriate; each PR needs human review.
|
|
49
|
+
|
|
50
|
+
## Trigger
|
|
51
|
+
|
|
52
|
+
When the project stabilizes after the current refactor wave.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# 19 - Text chunk (tEXt/zTXt/iTXt) read support
|
|
2
|
+
|
|
3
|
+
- **Priority**: P2
|
|
4
|
+
- **Status**: Planned
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
PNG images often carry metadata in text chunks:
|
|
9
|
+
- `tEXt`: Latin-1 key/value pairs
|
|
10
|
+
- `zTXt`: zlib-compressed Latin-1
|
|
11
|
+
- `iTXt`: UTF-8 with optional compression, optional language tag
|
|
12
|
+
|
|
13
|
+
Common keys: `Title`, `Author`, `Description`, `Copyright`,
|
|
14
|
+
`Software`, `Creation Time`.
|
|
15
|
+
|
|
16
|
+
Our `ChunkWalker` already iterates all chunks. We just need to parse
|
|
17
|
+
the text chunk format and expose it on `DecodedImage`.
|
|
18
|
+
|
|
19
|
+
## Proposal
|
|
20
|
+
|
|
21
|
+
Extend `Libpng::ChunkWalker` with text-chunk parsing, and extend
|
|
22
|
+
`DecodedImage` to carry a `text` field.
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
decoded = Libpng.decode(File.binread("photo.png"), pixel_format: "RGB")
|
|
26
|
+
decoded.text # => { "Title" => "Sunset", "Author" => "Jane", ... }
|
|
27
|
+
decoded.text["Title"] # => "Sunset"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Text values are UTF-8 strings (converted from Latin-1 for tEXt/zTXt;
|
|
31
|
+
already UTF-8 for iTXt). Keys are ASCII.
|
|
32
|
+
|
|
33
|
+
## Implementation
|
|
34
|
+
|
|
35
|
+
Add to `chunk_walker.rb`:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
def text_chunks
|
|
39
|
+
result = {}
|
|
40
|
+
each_chunk do |type, data, _|
|
|
41
|
+
case type
|
|
42
|
+
when 'tEXt' then parse_text(data, result)
|
|
43
|
+
when 'zTXt' then parse_ztxt(data, result)
|
|
44
|
+
when 'iTXt' then parse_itxt(data, result)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_text(data, result)
|
|
53
|
+
null_idx = data.index("\x00")
|
|
54
|
+
return if null_idx.nil?
|
|
55
|
+
key = data[0, null_idx]
|
|
56
|
+
val = data[(null_idx + 1)..]
|
|
57
|
+
result[key] = val.force_encoding('ISO-8859-1').encode('UTF-8')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_ztxt(data, result)
|
|
61
|
+
null_idx = data.index("\x00")
|
|
62
|
+
return if null_idx.nil?
|
|
63
|
+
key = data[0, null_idx]
|
|
64
|
+
# data[null_idx+1] is compression method (0 = zlib)
|
|
65
|
+
compressed = data[(null_idx + 2)..]
|
|
66
|
+
val = Zlib.inflate(compressed)
|
|
67
|
+
result[key] = val.force_encoding('ISO-8859-1').encode('UTF-8')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_itxt(data, result)
|
|
71
|
+
# iTXt is more complex: keyword\0 compression_flag compression_method
|
|
72
|
+
# language_tag\0 translated_keyword\0 text
|
|
73
|
+
# ... parse accordingly
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then in `simplified_decoder.rb`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
metadata = extract_metadata
|
|
81
|
+
text = ChunkWalker.new(@png).text_chunks rescue {}
|
|
82
|
+
return DecodedImage.new(..., text: text)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
And in `decoded_image.rb`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
DecodedImage = Struct.new(:width, :height, :format, :pixels,
|
|
89
|
+
:bit_depth, :color_type, :interlace, :text,
|
|
90
|
+
keyword_init: true)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Specs
|
|
94
|
+
|
|
95
|
+
- Round-trip: encode a PNG with tEXt chunks (via libpng's standard API,
|
|
96
|
+
once we expose `png_set_text`), decode, verify text matches.
|
|
97
|
+
- zTXt decompression works.
|
|
98
|
+
- iTXt UTF-8 round-trip.
|
|
99
|
+
- Malformed text chunk doesn't break decode (best-effort).
|
|
100
|
+
|
|
101
|
+
## Out of scope
|
|
102
|
+
|
|
103
|
+
- Writing text chunks (`png_set_text`) -- separate TODO if needed.
|
|
104
|
+
- International text chunk ordering / precedence rules.
|