Last month, @bastistician opened an
issue on the litedown repo
pointing out that knitr has a hook_pngquant() function for compressing PNG
plots from code chunks, but litedown lacks such a feature. He included a
reasonable workaround—calling system2("pngquant", ...) with
litedown::get_context("plot_files") in a chunk at the end of the vignette. It
shrank his vignette from 80 KB to 54 KB, which is a 33% reduction. Not bad.
The catch, of course, is that it requires pngquant to be
installed on the system. For R users, installing a system binary is more
friction than it sounds: it is brew install pngquant on macOS, a separate
package manager invocation on Linux, and hunting down a standalone executable on
Windows. If you maintain a package that others will build, you are now asking
all of them to do this—for every machine they use. By contrast,
install.packages("tinyimg") works the same way everywhere, which is the kind
of simplicity that makes a tool actually get used.
This is why I created tinyimg.
What it does
tinyimg compresses PNG files using two Rust crates bundled inside the package itself—no external tools needed:
- oxipng for lossless optimization: it re-encodes the PNG more efficiently without changing a single pixel.
- exoquant for lossy palette reduction: the image can be quantized down to at most 256 colors before handing it to oxipng for further loseless compression.
The main function is tinypng(). At its simplest:
library(tinyimg)
tmp = tempfile(fileext = ".png")
png(tmp, width = 800, height = 600)
plot(1:100, main = "A perfectly ordinary plot")
dev.off()
tinypng(tmp) # optimize in-place
You will see output like:
/tmp/Rtmpxyz/file123.png -> /tmp/Rtmpxyz/file123.png | 52.8 KB -> 39.5 KB (-25.2%)
That is the lossless path at the default optimization level of 2. If you want to
go harder, you can set level anywhere from 0 (fast, minimal gain) to 6 (slow,
maximum compression). Level 2 usually captures most of the low-hanging fruit,
whereas level 6 typically squeezes out only a few more percentage points while
taking 10–15x longer.1
Lossy compression
If lossless is not enough, you can enable lossy palette reduction via the
lossy argument. The value is a color-difference threshold in
CIELAB space—specifically
$\Delta E_{76}$. A threshold of 2.3 is the traditional “just noticeable
difference” (JND), meaning the color error introduced by the quantization is
theoretically imperceptible to the human eye:
tinypng(tmp, paste0(tmp, "-lossy.png"), lossy = 2.3)
In practice, for the kind of statistical graphics R produces—flat backgrounds,
solid colored points, clean lines—a lossy = 2.3 compression is nearly
indistinguishable from the original while cutting file sizes by 50–70%.
Aggressive settings like lossy = 32 can push reduction past 80%, but at that
point you may start to see banding in smooth gradients.2
To be honest, I am not sure 2.3 is a practically reasonable general-purpose threshold. From my own experiments with the benchmark plots, it feels rather conservative—the compressed images at somewhat higher values still look perfectly fine to me. Users will likely need to experiment on their own images to find the sweet spot. If you do systematic experiments and arrive at better guidance on what thresholds work well for typical R graphics, I would love to hear about your findings.
Using it with litedown
To answer the original issue: if you use litedown and want to compress all the plots in your vignette, just add a chunk like this at the end (assuming you use the PNG format for all plots):
if (requireNamespace("tinyimg"))
tinyimg::tinypng(litedown::get_context("plot_files"), lossy = 2.3)
One line. No system2(), no pngquant on the PATH, no CRAN environment
concerns. The lossy argument is optional—leave it out if you only want
lossless compression.
For knitr users, the situation is similar. You can pass the output directory
to tinypng() directly:
# if the chunk option fig.path is a dir used for all plots
tinypng(knitr::opts_chunk$get("fig.path"))
# or perhaps you can optimize the current working directory
tinypng(".")
It will recursively find and compress every PNG in the directory.
Installation
Since tinyimg uses Rust internally, building from source requires Cargo. The easiest route is to install a precompiled binary from CRAN or r-universe, which handles the Rust compilation on its end:
# CRAN version
install.packages("tinyimg", repos = "https://cloud.r-project.org")
# dev version
install.packages("tinyimg", repos = "https://yihui.r-universe.dev")
I have had a long-standing interest in Rust and the interface between Rust and R, but for years it stayed on the “someday” list. This GitHub issue gave me a good excuse to finally try it out, so tinyimg is my first real experiment with the combination. I am happy with how it turned out. The goal is not just PNG—JPEG support is on the roadmap, and the name “tinyimg” rather than “tinypng” was deliberate.
-
The benchmark page at https://pkg.yihui.org/tinyimg/examples/benchmark-png.html has the full numbers. For a complex 3D perspective plot, level 2 reduces file size by 34.7% while level 6 reaches 35.2%. The extra 0.5% is not worth tripling the runtime, in my opinion. ↩︎
-
For a scatterplot with the penguins dataset,
lossy = 64brings a 52.8 KB file down to 6.2 KB—an 88% reduction. Whether the result still looks acceptable at that level is very much “it depends.” Try it and see. ↩︎
Donate
As a freelancer (currently working as a contractor) and a dad of three kids, I truly appreciate your donation to support my writing and open-source software development! Your contribution helps me cope with financial uncertainty better, so I can spend more time on producing high-quality content and software. You can make a donation through methods below.
-
Venmo:
@yihui_xie, or Zelle:xie@yihui.name -
Paypal
-
If you have a Paypal account, you can follow the link https://paypal.me/YihuiXie or find me on Paypal via my email
xie@yihui.name. Please choose the payment type as “Family and Friends” (instead of “Goods and Services”) to avoid extra fees. -
If you don’t have Paypal, you may donate through this link via your debit or credit card. Paypal will charge a fee on my side.
-
-
Other ways:
WeChat Pay (微信支付:谢益辉) Alipay (支付宝:谢益辉) 

When sending money, please be sure to add a note “gift” or “donation” if possible, so it won’t be treated as my taxable income but a genuine gift. Needless to say, donation is completely voluntary and I appreciate any amount you can give.
Please feel free to email me if you prefer a different way to give. Thank you very much!
I’ll give back a significant portion of the donations to the open-source community and charities. For the record, I received about $30,000 in total (before tax) in 2024-25, and gave back about $15,000 (after tax).