The PNG your PDF throws away
“A scanned page is a 4 MB PNG, the one-page PDF built from it comes out larger than the PNG, and spending an afternoon optimising the PNG changes the PDF by nothing at all.”
Take a scanned page, 2550 by 2000 pixels, saved as a PNG. Build a one-page PDF that shows nothing but that image. The PDF comes out bigger than the PNG. So you go and optimise the PNG: a different filter strategy, a different encoder. The PNG gets 476 KB smaller. You rebuild the PDF.
The PDF is exactly the same size as before. Not close to it. The same size, and the image stream inside it is the same bytes, down to the last one.
That is not a bug in anybody's code. It is what "embed this image" means in most PDF libraries, and it is worth understanding, because one of the two pages I measured below can be stored at 21 KB or at 15 MB, same pixels, depending only on which of four things the writer does with it.
A PNG is already a PDF image stream
The useful fact is that PNG compression and one of PDF's stream filters are the same mechanism, which is not an accident: PDF's are called the PNG predictors, and the decoders below name them after PNG in their own function names.
On the PNG side, from the W3C PNG specification source:
The sequence of filtered scanlines is compressed and the resulting data stream is split into IDAT chunks. The concatenation of the contents of all the IDAT chunks makes up a zlib datastream. This datastream decompresses to filtered image data.
"Filtered" has a specific meaning. A filter is a per-row transformation that makes the row more compressible by storing differences against its neighbours rather than raw values, and the spec is explicit about where the bookkeeping goes:
PNG standardizes one filter method and several filter types that may be used to prepare image data for compression. It transforms the byte sequence into an equal length sequence of bytes preceded by a filter type byte
There are five filter types: 0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth. An encoder may pick a different one per row, and most do. The compression underneath is deflate:
Only PNG compression method 0 is defined by this International Standard. [...] PNG compression method 0 is deflate compression
So a PNG's pixel payload is: deflate, wrapped in zlib, of a byte sequence in which every row is preceded by one byte saying how that row was filtered.
Now the PDF side. A PDF image can use the /FlateDecode filter, which is also zlib,
and /FlateDecode takes an optional /DecodeParms dictionary with a /Predictor
entry. Predictor values of 10 and above mean the PNG predictors, and the per-row
filter type travels in band exactly as it does in a PNG. Here is MuPDF's decoder
deciding that:
int ispng = state->predictor >= 10;
...
n = fz_read(ctx, state->chain, state->in, state->stride + ispng);
...
fz_predict_png(ctx, state, state->out, state->in + 1, n - 1, state->in[0]);
One extra byte per row when the predictor is 10 or above (stride + ispng), and that
byte is passed as the filter type (state->in[0]) while the row's data starts after
it (state->in + 1). pdf.js, an independent implementation, does the same thing:
if (predictor !== 2 && (predictor < 10 || predictor > 15)) {
throw new FormatError(`Unsupported predictor: ${predictor}`);
}
this.readBlock = predictor === 2 ? this.readBlockTiff : this.readBlockPng;
and inside readBlockPng:
const predictor = this.str.getByte();
const rawBytes = this.str.getBytes(rowBytes);
A byte read per row, then the row. That is the PNG layout, read by a PDF reader.
Which means a non-interlaced PNG without transparency already contains a valid PDF image stream. You do not have to build one. You concatenate its IDAT chunks, drop the bytes into the stream, and declare what they are:
/Filter /FlateDecode
/DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 2550 >>
/Colors, /BitsPerComponent and /Columns are how the reader works out the row
length, because the predictor needs to know where one row ends. pdf.js computes it in
two lines, with the defaults the spec gives them:
const colors = this.colors = params.get("Colors") || 1;
const bits = this.bits = params.get("BPC", "BitsPerComponent") || 8;
const columns = this.columns = params.get("Columns") || 1;
this.pixBytes = colors * bits + 7 >> 3;
this.rowBytes = columns * colors * bits + 7 >> 3;
Get /Columns wrong and the row length is wrong, so every row after the first is
reconstructed against the wrong neighbours and the error accumulates down the page. I
tried it. Declaring /Columns 2549 for a 2550-pixel-wide image put an error in front
of the user in none of the three readers I had to hand, and a correct page in none of
them either. poppler rendered a corrupted raster without a word. MuPDF rendered one too,
printing warnings to its console (warning: padding truncated image, and on some files
unknown png predictor) and carrying on. pdf.js, in both 4.10.38 and 6.4.299, reached a
row whose filter byte was not one of the five valid types, logged Unable to decode image to the console, and left the image off the page altogether. Where the raster was
drawn, it went dark from the first column of every row rather than shearing neatly, so
there is not even a recognisable version of the page to prompt someone to look twice.
Where it was not, the page is simply blank.
What "embed" usually does instead
pdf-lib is a reasonable example because its source is short and plain. embedPng
hands the bytes to a PngEmbedder, and the embedder's first move is to parse the
file and split it into channels: it reads png.rgbChannel and png.alphaChannel,
builds an /SMask as a separate grayscale XObject when there is alpha, and passes
the colour samples to context.flateStream(...).
Nothing of the original file survives that. The row filters are undone, the zlib stream is discarded, and the raw samples are deflated again from scratch. You can see the result in the image dictionary pdf-lib writes:
/Type /XObject
/Subtype /Image
/BitsPerComponent 8
/Width 2550
/Height 2000
/ColorSpace /DeviceRGB
/Filter /FlateDecode
/Length 4276293
There is no /DecodeParms. No predictor, so no row filtering: the stream is plain
zlib over raw pixel samples.
That explains the thing from the opening paragraph. If the output is zlib over the decoded samples, then the output is a function of the samples alone. The PNG you fed in is an input to the decode step and to nothing else.
Measured
One synthetic scan of a text page with scanner noise, 2550 by 2000, RGB, 8 bits per channel: 15,300,000 bytes of raw samples. Then three PNGs of those identical pixels, from two different encoders, each put into a PDF both ways:
| PNG encoder and settings | PNG file | PDF via embedPng |
PDF via IDAT copy |
|---|---|---|---|
| Mine, adaptive filters, zlib level 9 | 3,963,890 | 4,277,242 | 3,964,623 |
| Mine, no filtering, zlib level 9 | 3,488,187 | 4,277,242 | 3,488,920 |
| ImageMagick (libpng), PNG24, level 9 | 4,032,009 | 4,277,242 | 4,031,197 |
The middle column does not move. Not approximately: the image stream inside all three of those PDFs is 4,276,293 bytes and hashes to the same MD5. Three PNG files, differing by up to 543 KB, produce one byte-identical PDF image stream.
And that stream is not mysterious. zlib.compress(raw_samples, 6) on this box returns
4,276,293 bytes, which is the number above. Level 6 is zlib's default. So the library is
doing precisely what the dictionary says: inflate the PNG, deflate the samples at the
default level, write no predictor.
The right-hand column is the IDAT copy. It is the PNG's IDAT bytes plus 790 more, which
is the whole rest of the PDF: catalogue, page tree, content stream, cross-reference
table. Against the PNG files themselves it lands within a few hundred bytes either way:
for the libpng file the PDF actually came out 812 bytes smaller
than the PNG, because the PNG was carrying cHRM, bKGD and tIME chunks and their
framing, and a PDF image stream has no use for those.
Against the libpng PNG, which is what you would get from a normal encoder, the decode path costs 6.1%. Against the best of the three PNGs it costs 22.6%.
The same page, stored badly
6% is not what sends people to a bug tracker. This is:
| Storage choice | One-page PDF |
|---|---|
Raw samples, no /Filter at all |
15,300,694 |
/FlateDecode, no predictor, level 6 (what embedPng wrote) |
31,865 |
/FlateDecode, no predictor, level 9 |
26,214 |
/FlateDecode /Predictor 15, copied from the PNG's IDAT |
21,168 |
That is a flat page rather than a noisy scan, same dimensions, and the spread is 723 to 1. The first row is a PDF writer that put the decoded bitmap in the file and applied no filter whatsoever. It is a real shape: finlynq/business-finlynq#167 reports a document inbox rejecting uploads over 2 MiB, and describes the file that triggered it in three sentences worth quoting together: "A one-page PDF produced by jsPDF was approximately 6.17 MB." / "It contained one uncompressed RGB image." / "Lossless Flate compression of that stream and the page content reduced it to approximately 105 KB."
Nothing about the image changed there. No resolution was dropped and no quality knob was turned. A stream that had been stored verbatim got a filter applied to it, and roughly 98% of the file went away.
The second shape is the decode-and-re-encode one, and it is what
AquilaXk/easyconvert#545 is
about: embedding scanned pages into an OCR PDF through a library whose embedPng
"decodes the PNG and re-encodes it" and "adds an alpha SMask when needed". Two things
about that report deserve care. It does not name the library, though the API names it
uses (embedPng, embedJpg, PDFRawStream) are pdf-lib's, so reading it as pdf-lib is a
short step, and it is mine rather than theirs. And its figures, a "4.0 MB PDF from a 2.2 MB PNG",
are labelled in the issue itself as "[not re-measured; baseline to be measured
first]", under a heading reading "Original report measurements". So they are a figure
carried over from whoever first complained, which the maintainer wrote down and
explicitly declined to vouch for. I could not reproduce a doubling: the worst ratio I
measured was 1.55 times the PNG, on the flat page. The mechanism in that report is
real and the arithmetic in it is unconfirmed, which is exactly how its own author
filed it.
The fix merged as #609 is the IDAT
copy, on the server path. The commit that implements it,
3ce0493,
says so directly: "A non-interlaced PNG without alpha or transparency now goes into the
image XObject as its own concatenated IDAT stream (FlateDecode, Predictor 15)", covering
"gray, RGB, Indexed with the PLTE, or ICCBased from iCCP, at 1 to 16 bits". The PR
verified the result the obvious way: "Raw stream equals the IDAT bytes (qpdf)." It is
also candid about two limits of its own. The browser tier is out of scope and "keeps
embedPng", because that tier cannot use Node built-ins. And "A noisy PNG can produce a
larger PDF than before", which it caps at "1.05× the PNG size": the point of the section
after next, arrived at by the people who shipped it.
What the copy cannot do
The exclusions are the interesting part, and the same commit names them: "Pages with alpha, interlacing or a raster over the inflate cap take the decoding path as before."
Alpha, because PDF has no alpha channel inside an image sample stream. Transparency is
a second grayscale image in /SMask, so an RGBA PNG's rows interleave data that has to
be pulled apart, and pulling it apart means decoding. Interlacing, because an interlaced
PNG's zlib stream is not rows of the image at all. The spec is plain about it:
"Interlace method 1, known as Adam7, defines seven distinct passes over the image",
producing "a sequence of seven reduced images". A PDF reader handed that and told
/Columns 2550 will read nonsense, and on the evidence above it will not complain
while doing so.
This is not a hypothetical limit. Encoding my test pixels through @napi-rs/canvas,
which is a perfectly ordinary way to produce a PNG in Node, gave me colour type 6:
RGBA, with an opaque alpha channel the canvas added because canvases are RGBA. The
copy path cannot take that file, even though every alpha sample in it is 255. Getting
a passthrough-eligible PNG out of a canvas pipeline means caring about the colour type,
which nobody does until it matters.
One correction to make here, since the claim travels: 16-bit depth is not a limit of that fix. The PR lists "1–16 bits" as supported. Only alpha, interlacing and an oversized raster fall back.
Filters are not free wins
It would be tidy if the story were "predictors are better, use them". The measurement says otherwise, and it is worth saying so.
On the noisy scan page, libpng's filtered IDAT came to 4,030,407 bytes. Plain zlib at level 9 over the same samples with no filtering at all came to 3,484,417, which is 13.5% smaller. Row filters help when neighbouring pixels are similar, which is true of flat and synthetic artwork and much less true of scanner noise, where differencing against a neighbour turns smooth-ish values into a wider spread of small deltas that deflate cannot pack as tightly. My own no-filter PNG at level 9 was the smallest of the three PNG files for exactly that reason.
So the argument for copying the IDAT bytes is not that the PNG made the best choice. It is that the PNG made a choice, deliberately, with the whole image in front of it, and that choice is already paid for. Decoding and re-encoding throws the decision away, replaces it with one fixed default (no predictor, level 6) that was not made with this image in mind, and bills you for the CPU twice. Sometimes the default wins: on the noisy page, no predictor beat libpng's filters. But the copy is only ever as good as the encoder that made the PNG, and the re-encode is only ever as good as that default.
Where AegisPDF sits
AegisPDF builds PDFs from images and compresses existing ones, both in the browser, so both paths are in scope here. Read from source rather than from the feature list.
Its images-to-PDF path decodes, same as pdf-lib. images_to_pdf calls
decode_oriented, and that function exists for a stated reason: image::load_from_memory
"ignores EXIF", so phone JPEGs, which carry the sensor orientation as a tag rather than
rotating the pixels, "would import sideways". It reads the tag off the decoder and bakes
the rotation in. That is the right call for the photographs this feature mostly sees, and
it is also a decode, so a PNG going through it pays the cost described above. For a PNG
the rotation is almost always a no-op, which means the copy path is available in
principle. It is not implemented. (I read the Rust; I did not build the wasm, so I am
describing code rather than reporting a file size it produced.)
The compress path is a different design, deliberately. It is lossy: it downsamples images above a DPI cap and re-encodes them as JPEG, with presets of 220 DPI at quality 85, 150 at 72, and 110 at 60. The eligibility gate is tight, and the comment explains the tightness:
Anything that carries transparency, stencil masking, or a custom sample remap would be silently broken by a straight RGB/Gray JPEG re-encode.
So /SMask, /Mask, /Decode and /ImageMask are all skipped, as is anything that is
not 8 bits per component in a bare /DeviceRGB or /DeviceGray. That is the same family
of exclusions the IDAT copy needs, arrived at from the opposite direction.
Two details in that path are the ones this post has been circling. The first is a guard:
// Never grow an individual image stream.
if buf.len() >= s.content.len() {
return;
}
A re-encode that comes out bigger is discarded and the original stream stays. The easyconvert report is a file that grew when an image was put into it; this is the same guard, on the other path.
The second is a single line of cleanup when a Flate image becomes a JPEG one:
// The old Flate predictor params no longer apply to DCT data.
s.dict.remove(b"DecodeParms");
Which is the whole thesis in reverse. The predictor parameters are not decoration on an image stream, they are part of what the bytes mean. Carry them and a PNG's compressed data is a PDF image. Leave them behind on a stream that is no longer predicted and the reader decodes garbage.
What was verified here, and what was not
Every byte count above was measured on one machine today, not quoted.
The pixels were checked in three independent engines, because a measurement in one
renderer is a claim about that renderer. For every PDF in the tables, poppler 24.02.0
(pdftoppm -r 300), pdf.js 4.10.38 and MuPDF 1.28.1 were each pointed at the file.
All three return the identical image, and its MD5 equals the MD5 of the raw sample
bytes I started from. The IDAT copy is lossless and so is the decode-and-re-encode
path: this is a size and CPU story, not a quality one. The libpng-encoded PNGs matter
specifically because they remove the circularity of testing my own encoder against my
own PDF writer: those IDAT bytes were produced by ImageMagick 6.9.12-98 and consumed by
three decoders that have never met my code.
What was not verified:
The PDF specification text itself. ISO 32000-1 is section 7.4.4.4 for this material,
and it could not be fetched from this environment, as on an earlier post here. The
claims about predictor 10 to 15 semantics therefore rest on three independent
implementations read as source (MuPDF, pdf.js, lopdf 0.36.0) plus the empirical result
that libpng's output decodes correctly in all three when declared as /Predictor 15.
That is good evidence and it is not the spec. A reviewer with ISO 32000-1 to hand should
confirm the parameter table.
Anything about AegisPDF at runtime. The wasm was not built in this sandbox, so the product section is a reading of Rust and TypeScript source at the current tip, not an observation of the shipped tool. In particular, what PDFium writes for an image object on the images-to-PDF path was not inspected, so no claim is made about the size of a PDF AegisPDF produces from a PNG.
The two reports. Both were fetched and read, and are quoted rather than summarised where the wording carries weight. Neither file was obtained. The finlynq figures are their author's and are presented as such; the easyconvert figures are labelled unmeasured in the issue and are presented as such.
One encoder each side. The decode-path numbers are pdf-lib's. Other libraries differ, and some already do the copy. No claim is made about libraries whose source was not read.