Repository navigation
Releases
v0.69.1
What's Changed
- fix(deps): update module github.com/go-gfx/gfx to v0.36.1 by @tannevaled in #122
- ci: install qemu-user, and resolve the emulator this matrix names by @tannevaled in #123
- fix(deps): update module github.com/go-opentype/opentype to v0.16.1 by @tannevaled in #124
- chore(deps): update dependency go to v1.27.3 by @tannevaled in #121
- Bump go-gfx/gfx to v0.37.0 and jpeg2000 to v0.14.0 by @tannevaled in #127
Full Changelog: v0.69.0...v0.69.1
v0.69.0
v0.68.0
Release backfilled on 2026-10-26; tag created 2026-10-18.
Commits since v0.67.2:
- c3ea7ae fix(deps): update dependencies (#110)
- a80d887 A form XObject's incomplete /Resources no longer shadows the enclosing ones (#103)
- 3ad8079 Three timing tests take their two minima INTERLEAVED, not in blocks (#106)
- 7d8ddd3 README: name the decoders, because which one runs is the point (#107)
- 0755d96 Run on arm64 LINUX and on the BSDs, rather than only building for them (#111)
- 4e54b0b Images returns the inline ones, so 4 147 pictures of one population can be compared at all (#105)
- 3bb5dcf Benchmarks, because "would SIMD help" cannot be asked without a baseline (#112)
- bc1e496 ci: move the Go pin from 1.27.1 to 1.27.2 (#115)
- 2e04862 fix(deps): update dependencies (#114)
- 2efa233 Count the asks instead of timing them (#116)
- 3f9096d Bump go-images/jpeg2000 v0.13.3 -> v0.13.5 (#117)
- 83c9575 Bump go-images/jpeg2000 v0.13.5 -> v0.13.7 (#118)
v0.52.0 — a profile read 389 times a page, now read once
An ICC profile is now read once per object, not 389 times a page
§24 of the conformance baseline named a cluster: five French tax and administrative
forms losing to poppler by five to eight times. Profiling the worst — an 801 KB
form taking 735.9 ms — put 43% of the page in compress/flate. The shape said
many streams rather than one large one, and the count said why: colourSpace was
entered 389 times on one page, and two objects accounted for 235 of them. Fonts
were cached by object number, soft masks by key, and colour spaces not at all — so
every entry re-decompressed an ICC profile stream and re-parsed it.
| document | before | after | vs poppler | |
|---|---|---|---|---|
2047_2047_5488.pdf |
735.9 ms | 26.2 ms | 28× | 7.8× slower → 0.28× |
2042_2042_5475.pdf |
641.1 ms | 39.8 ms | 16× | → 0.44× |
2042_2042_5474.pdf |
465.7 ms | 31.8 ms | 15× | → 0.39× |
cerfa_12496.pdf |
695.5 ms | 37.0 ms | 19× | → 0.28× |
b31820797.pdf |
685.7 ms | 673.2 ms | 1.0× | control: no ICC space |
The control is the point: a page with no ICC space does not move, so the mechanism is
the explanation rather than a story about it.
The cache is keyed on the profile stream's object number, not the colour space
entry's. A referenced colour-space array may contain a name, and a name resolves
through the caller's /Resources; a profile depends on its own bytes and /N alone.
All 3 215 corpus documents render byte-identical to v0.51.0.
v0.51.0 — the memo a mask should not have taken away
Fixes a regression shipped in v0.49.0
A picture of one component at no more than eight bits has at most 256 values, so
what each becomes is worked out once instead of at every pixel. v0.49.0 gated that
memo on the predicate that decides whether the picture may additionally be held as
one byte a pixel and a palette — and that predicate refuses a picture with a soft
mask, because applying a mask writes pixels and a palette is shared between them.
A masked grey picture therefore lost its memo and converted every pixel through an
Indexed space over an ICC profile: 3.93 million conversions instead of 256.
| document | v0.48.0 | v0.49.0 / v0.50.0 | v0.51.0 |
|---|---|---|---|
cerfa_10832.pdf |
31.9 ms | 369.5 ms | 31.2 ms |
cerfa_11845.pdf |
31.7 ms | 380.9 ms | 33.4 ms |
cerfa_12496.pdf |
731.7 ms | 713.5 ms | 714.3 ms (control) |
86 pages of the measured corpus were at least twice as slow, across fourteen
populations — a scanned signature under a soft mask is what forms are made of.
Why three byte-identity sweeps missed it
The output was byte-identical throughout. A proof of pixels cannot see a page
that draws the same thing eleven times slower. It was found by timing the whole
corpus per page, which is what compare -timings exists for.
Also
mayPackOneByte no longer constructs the colour space to count its components —
for an Indexed space over ICC, constructing it parses the profile. The ceiling on
one picture is now applied in two stages: loosely in decodeBase without the colour
space, exactly in samples() once the component count is in hand. Both bound the
same 256 MB.
All 3 215 corpus documents render byte-identical to v0.50.0.
v0.50.0 — bytes that were already the picture
Faster on scanned pages, and 553 MB less on the heaviest
decodeJPX converted the JPEG 2000 decoder's *image.RGBA into a raster.Image,
allocating a second image the size of the first and copying through it. The
conversion is not gratuitous — image.RGBA is premultiplied and a raster.Image
is straight — but at an alpha of 255 the two are the same bytes, and a JPEG 2000
codestream carries no alpha. The buffer is now adopted when Opaque(), the stride
and the origin all agree, and converted when any of them does not.
Time
min-of-7, three rounds, one interleaved run:
| document | before | after | |
|---|---|---|---|
b31820797.pdf |
687.6 ms | 659.2 ms | −4.1% |
bulletin43peab.pdf |
612.8 ms | 596.8 ms | −2.6% |
jahresberichtder710meye.pdf |
749.8 ms | 736.7 ms | −1.8% |
Memory, both ways
Peak resident, min of three:
| document | before | after | |
|---|---|---|---|
jahresberichtder710meye |
360.5 MB | 395.6 MB | +10% |
bulletin43peab |
354.4 MB | 389.7 MB | +10% |
b31820797 |
2044.6 MB | 1491.6 MB | −27% |
Adoption keeps the decoder's buffer live rather than copying it and freeing the
original: a modest page holds a little more, a page with a large picture holds a
great deal less.
Unchanged output
All 3 215 corpus documents render byte-identical to v0.49.0.
v0.49.0 — a ceiling in bytes, and two pages that stopped coming back blank
Two pages that came back blank now draw, and faster than the judge
§24 of the conformance baseline found five pages returning from render in 4 to
15 ms against poppler's 1.6 to 2.8 seconds, with a third to a half of their pixels
wrong — blank pages, fast enough that a speed table read them as wins.
The cause was maxImagePixels: a ceiling of 64 Mpixel with four bytes a pixel
implied, so 256 MB. In pixels it refused what it had no reason to. A 400 dpi
bilevel newspaper scan is 7 779 × 10 699 — 83 Mpixel, past the ceiling — and it
holds in 83 MB when one byte a pixel is enough, which for a single component of at
most eight bits it is.
The ceiling is now expressed in bytes (256 MB, exactly what it always promised)
and a single-component picture is held as one byte a pixel plus a 256-entry palette.
Judged by the instrument, on the two pages it reaches
| before | after | |
|---|---|---|
| pixels differing (median) | 32.83% | 0.94% |
| worst | 44.89% | 3.05% |
| identical byte for byte | 41.32% | 56.54% |
| colour, worst square mean | 255.00 | 36.88 |
| time | 27 ms | 972 ms — 0.284× of poppler's |
A worst square mean of 255.00 is what a blank page scores against a printed one.
So: a 35-fold fidelity improvement, and still 3.5× faster than poppler.
Scope, plainly
Five pages were named and this reaches two. The other three are JPEG 2000 at
9 449 × 13 701, and the decoder builds a four-byte image internally before render
sees it. Memory on ordinary pages barely moves — the large images in that corpus
are JPEG 2000 and JBIG2, not single-component samples.
Unchanged elsewhere
3 213 of 3 215 corpus documents render byte-identical to v0.48.0; the two that
differ are the two pages above.
v0.48.0 — the entropy coder, and a grid we stopped reallocating
Faster on scanned pages, byte-identical output
Takes go-images/jpeg2000 v0.8.0 and v0.7.0's own predecessor work forward.
§24 of the conformance baseline named ia-biodiversity as the one population where
a typical page ties with poppler — median 1.004×, slower on 128 of 250. Its
worst page turned out not to be limited by the wavelet at all but by EBCOT
entropy decoding, and inside that, by computing the coding context from eight
neighbours rather than by the arithmetic decoder it feeds.
| document | before | after | |
|---|---|---|---|
jahresberichtder710meye.pdf |
801.7 ms | 773.5 ms | −3.5% |
bulletin43peab.pdf |
663.9 ms | 636.4 ms | −4.1% |
b31820797.pdf (wavelet-heavy) |
+0.5%, noise |
Also in this release, from v0.47.0's tail: mask() no longer allocates a coverage
grid for every mark made through a clip. That is −39% of all allocation on the
corpus's worst page (2 268 → 1 379 MB) and −4% to −8% on ordinary pages, with no
measurable change in time — stated plainly because it is what the measurement
said.
All 3 215 corpus documents render byte-identical to v0.47.0.
v0.47.0 — the 9/7 synthesis stops moving data it already had
Faster on the heaviest pages, byte-identical output
Takes go-images/jpeg2000 v0.7.0, which folds two redundant passes out of the
9/7 vertical wavelet synthesis — the inverse scaling into the subband read-out,
and the interleave into the scatter. Neither changes an arithmetic operation.
min-of-5, two interleaved rounds, against a ±1% noise floor:
| document | before | after | |
|---|---|---|---|
b31820797.pdf |
786.3 ms | 721.2 ms | −7.7% |
bulletinchicagov48nchicg.pdf |
657.7 ms | 636.7 ms | −4.4% |
irrigationrequir1340fort.pdf |
404.7 ms | 388.5 ms | −3.4% |
All 3 215 corpus documents render byte-identical to v0.46.0.
Also carries go-highway v0.0.12 and opentype v0.13.0, taken in v0.46.0's
wake: nothing was updating this repository's dependencies, and go-highway had
been eight released versions behind.
v0.46.0 — ceilings the overflow reached first
Two size ceilings that the overflow reached first
A page that could not be counted was converted anyway — on every architecture
Page converted the extent to an int and judged it afterwards:
w := int(math.Ceil((box[2] - box[0]) * s))
...
if w*h > opt.maxPixels() {A MediaBox is a number in somebody's file and the scale is the caller's, so
that product is an arbitrary float. Converting a float too large for an int
is not defined in Go, so the overflow happened before the ceiling could refuse
anything. A page with a MediaBox of 1e300 panicked in fill() — on arm64,
not only on 32-bit. The extent is now judged as a float, before it becomes an
int.
A codestream ceiling that a 32-bit int walked through
affordDecoded refused an oversized codestream by multiplying its sides in an
int. 65535 × 65535 = 4 294 836 225 wraps to −131 071 in an int32, which
is not greater than anything: the guard passed, the decoder was handed the bytes,
and it asked for four gigabytes on a machine that cannot express the allocation.
A 1 KB file crashed a 32-bit build. Its sibling afford had already been written
in int64 and says why in a comment; this one had not.
decodeBase multiplied the same way and is fixed the same way.
A time bound that carried one machine's speed
TestAPageMayBeGivenOnlySoLong allowed a fixed five seconds. The deadline is
looked at once every timeCheckEvery operations, so the overshoot is a quantity
in operations; five seconds is that quantity converted at the speed of the
machine that wrote the test. riscv64, s390x and 386 took 5.4 s, 8.3 s and 11.3 s
for the same correct code.
It now draws the same page twice — once unbounded — and requires the bounded draw
to return in under a third of that. Measured here it returns in a ninth. Comparing
the page against itself makes every fixed cost cancel.
New: the tests run on six architectures
riscv64, loong64, ppc64le, s390x (big-endian), 386 and arm (32-bit
int). The CI previously cross-compiled for the 64-bit four and ran on none of
them. 386 runs natively rather than under qemu-i386, which loses the guest's
floating-point state across the signal Go delivers for asynchronous preemption:
5 failures in 8 identical lanes emulated, 0 in 8 native, 0 in 8 with
asyncpreemptoff=1.
Unchanged output
All 3 215 corpus documents render byte-identical to v0.45.0.