
Halftone fills for ggplot2. The package places dots or hatch lines at draw time on a lattice whose pitch is in millimetres. A figure saved at 89 mm and at 183 mm therefore gets the same screen at both sizes.
That is the difference from ggfx. Its with_*() filters run on the rasterised layer, so dot size is in pixels and scales with the output. Overlapping groups here print on one lattice, so every group stays visible.

A Kaplan-Meier plot of the North Central Cancer Treatment Group lung cancer data (Loprinzi et al. 1994) at journal size: 89 mm, 600 dpi, theme_classic(base_size = 8). Three 95% confidence bands drawn with with_halftone(geom_ribbon()), step curves with with_halo(), censor marks and a risk table from km_steps(), km_censor() and km_risk().
install.packages("gghalftone",
repos = c("https://kinelhu.r-universe.dev", "https://cloud.r-project.org"))
R-universe serves prebuilt binaries for Windows and macOS, so this needs no compiler on the usual builds of R. A source-only build, such as Homebrew's, fetches the source and compiles it instead, which needs Rtools on Windows or the command line tools on macOS. The package has C++ kernels either way.
From GitHub, if you want the development version:
remotes::install_github("kinelhu/gghalftone")
pak::pak("kinelhu/gghalftone")
From a local clone:
R CMD INSTALL .
While changing the package itself, skip the install:
devtools::load_all("~/Dev/gghalftone")
Rendering needs ragg.
Point renv at the universe and let it resolve:
renv::install("gghalftone", repos = c("https://kinelhu.r-universe.dev", "https://cloud.r-project.org"))
renv::snapshot()
To pin a build with no network dependency, put a tarball in the project's cellar instead:
cd ~/Dev/gghalftone && R CMD build .
mkdir -p ~/my-project/renv/cellar && cp gghalftone_*.tar.gz ~/my-project/renv/cellar/
renv::install("gghalftone") then records "Source": "Cellar" and renv::restore() reads the tarball back out. Two things to watch: renv's own renv/.gitignore lists cellar/, so the tarball is untracked and a fresh clone restores against a file that is not there, and renv keeps the copy it has unless Version: in DESCRIPTION moves.
git add -f renv/cellar/gghalftone_0.1.0.tar.gz
library(gghalftone)
# Screen the fill of any layer: ribbons, areas, bars, densities, polygons, sf.
s <- km_steps(survfit(Surv(time, status) ~ ecog, data = d))
ggplot(s, aes(time, group = strata)) +
with_halftone(geom_ribbon(aes(ymin = lo, ymax = hi, fill = strata))) +
with_halo(geom_step(aes(y = surv, colour = strata))) +
theme_classic() + theme_halftone()
# Simple features work like any other layer.
ggplot(counties) + with_halftone(geom_sf(aes(fill = rate)))
# Screen a gridded field. Colour scales apply to the field; dots inherit the colour.
ggplot(field, aes(x, y, z = value, colour = value)) + geom_halftone()
# Draw one tone disc per point.
ggplot(markers, aes(cluster, gene, tone = expression, size = pct)) + geom_spot() + scale_tone_continuous() + scale_radius()
# Encode a variable as screen angle and dot shape instead of colour.
ggplot(f, aes(x, y, z = z, screen = series)) + geom_halftone() + scale_screen_discrete()
# Draw a line screen.
ggplot(vol, aes(x, y, z = z)) + geom_halftone(shape = "line", angle = 30)

Left to right, top to bottom: the Kaplan-Meier plot above, a smooth with its confidence band, three densities, a choropleth, the same Kaplan-Meier plot in one ink, a blue-noise stipple. All are produced by prototypes/gallery2.R, which overrides a default only where the figure needs it.
The defaults encode these rules. Each one was chosen by comparing renders at 600 dpi.
levels = k to quantise and dither with an ordered (Bayer 1973), blue-noise (Ulichney 1993) or error-diffusion (Floyd and Steinberg 1976) matrix, for example levels = 1 for a stipple.screen is always flat.
A photograph twice. A: four-colour process, geom_halftone_cmyk() separating the image into cyan, magenta, yellow and black at the classic screen angles, which produces the rosette. B: one ink with Floyd-Steinberg error diffusion, the newspaper screen. Both from halftone_raster().

A wind rose in one ink: direction by angle, frequency by radius, wind speed by tone. Speed is ordered, so it gets tone; a reader cannot rank three hatch angles. The lattice is computed in millimetres on the panel, so the coordinate system does not affect it. Legend keys are a sample of the screen at its real pitch and weight.
More of these, including an sf choropleth, ridgelines, violins and a demonstration that the screen does not change with output size, are in prototypes/showcase.R.
halftone_plot() takes a finished plot and returns it printed. It screens every filled layer, draws a paper hairline under every line that crosses a screen, and adds the theme modifier.
halftone_plot(p)
halftone_plot(p, pitch = 0.6, shape = "line") # any with_halftone() argument
The treatment follows the geom. Ribbons, areas, densities, bars, tiles, polygons, sf geometries, violins, boxplots and smooths are screened, each with the tone profile its geometry calls for. Lines, paths, steps, contours and points are haloed where a screen sits under them. Anything drawn on a screen gets the printer's knockout channel.
Text, error bars, rugs and reference lines are left alone. So is any geom the function does not recognise: an unknown layer draws as it always did, so the worst case is an unscreened layer rather than a wrong figure.
It screens the fill and chooses no colours. Line weights, point shapes and any colour you set or mapped stay as the plot set them. Applying it twice changes nothing, and a layer you wrapped yourself is left as you wrapped it.
Colours you did not choose belong to the theme. On ggplot2 4.0 an unmapped geom colour comes from theme(geom = ), whose stock accent is the blue geom_smooth() draws its line in. theme_halftone() sets that accent to the package's ink, which brings that colour into the register.

Six panels built the way anyone would build them, composed with patchwork, then handed to halftone_plot() once. Nothing in them is written for the screen: the fills are mapped, the lines are ggplot2's defaults. Each mapped fill also picks up its own dot shape and lattice angle, which is visible at full size.

Left: the plot as given. Middle: halftone_plot(p), one call. Right: the same figure composed layer by layer with the package's inks and weights.
with_press() draws the screen as a press puts it on paper, not as the plate describes it. Wrap it around a halftone layer, a plot, or a patchwork; given more than one layer, each is pressed as its own plate.
gain above about 0.2 to give it something to bridge. At gain = 0.25: 0.02 rounds the cusp, 0.04 draws a clear bridge, 0.08 pulls the fill-in down into the midtones. It needs the polyclip package.mottle_scale millimetres.Wrap one layer, or a whole plot. Given a plot, every layer is pressed as its own plate, with its own offset and mottle, and the plot you passed in is left alone so you can print the pair.
with_press(with_halftone(geom_col()), gain = 0.3, fillet = 0.04, mottle = 0.12, registration = 0.05)
with_press(p, gain = 0.26, slur = 0.06, fillet = 0.06, mottle = 0.13, registration = 0.05)

One choropleth three ways: as prepared, then dot gain alone, then the whole press. Gain on its own is close to what raising dot_max on the plain figure would give. The whole press adds mottle, slur, ink bridges and a plate offset. figures/v2/press/control.png matches the plain screen's ink weight to the pressed one, so the part that is not more ink can be seen on its own.
Everything here makes a figure less faithful to its data. It is a separate entry point, off by default and outside the journal register.

Three strata in one ink. The screen aesthetic sets a hatch angle per stratum; line type distinguishes the estimates.

Maunga Whau. One shape = "line" layer for the surface, and with_relief(geom_contour()) for illuminated contours (Tanaka 1950): lit segments in paper, shaded segments in ink.
The package does not ship a complete theme. theme_halftone() is a modifier that you add to your own theme. It sets a paper ground with no gridlines under the screen, legend keys large enough to show a screen, and on ggplot2 4.0 the ink palette. Fonts, sizes and axes come from the theme you add it to.
ggsave_journal("fig.pdf", p, "double") saves at 183 mm. The file extension selects the format: PNG or TIFF at 600 dpi, or vector PDF. halftone_proof() renders a plot at final size and a magnified crop. theme_halftone(palette = "process") uses press colours made of one or two process plates.
Version 0.1.0, archived at 10.5281/zenodo.23037875. Checked on Ubuntu, macOS and Windows with no errors or warnings.
The API of geom_halftone(), geom_halftone_cmyk(), geom_spot(), halftone_plot(), with_halftone(), with_halo(), with_relief(), with_press(), halftone_raster(), the screen and tone aesthetics, km_steps() and the theme_halftone() modifier is considered stable. Every export has a help page. Start with ?with_halftone.
survival::lung.datasets::volcano, digitised by Ross Ihaka from a topographic map of Auckland.Rscript -e 'Rcpp::compileAttributes(); roxygen2::roxygenise()'
R CMD INSTALL .
Rscript -e 'library(gghalftone); testthat::test_dir("tests/testthat")'
Four scripts render every figure, to figures/, which is not tracked:
Rscript prototypes/gallery2.R # the defaults gallery
Rscript prototypes/showcase.R # the wider API
Rscript prototypes/press_gallery.R # every figure as printed
Rscript prototypes/readme_figures.R # refresh man/figures/ from those