If you turn PDF pages into JPEGs and the small text comes out looking slightly dirty, check the quality slider before blaming the resolution. On one 216 DPI text page, 11.69% of the white pixels sitting right next to the strokes turned grey at JPEG quality 75. At 85 it was 4.86%, at 92 it was 0.40%, and PNG left all of them alone. The metric is self-defined, on one sample, run once each, so read it as a measurement of this page and not a law about JPEG.

Scope before anything else. The sample is a self-made 3-page A4 PDF with no background fill. Page 1 is dense text in five sizes from 10.5 pt down to 5 pt, plus a table drawn with 0.3 pt lines. Page 2 is a vector chart page with 6 to 7.5 pt labels. Both pages were rendered at 216 DPI onto white with poppler, and the same pixels were then saved at each quality setting with Pillow. All of it was done on a single Apple-silicon Mac. There are no scans or handwriting in this set, and the photo page of the sample is left out here.

The metric is a self-defined rule

On the chart page PSNR barely moved between quality settings, so this run also counts something narrower. Take every pixel that was pure white in the lossless render and sits 1 to 3 pixels away from a stroke. After encoding, count how many of them drifted more than 8 levels out of 255. That share is called "turned grey" here. It is not a standard measure, the threshold of 8 is an arbitrary pick, and a different threshold would give different percentages. What it does capture is the fuzzy halo you see when you zoom into small characters, because that halo lives exactly in those few pixels around each stroke.

Below is a simplified sketch written from that definition. The numbers in this post come from the full script, whose neighbourhood shape may not match this sketch pixel for pixel.

import numpy as np from PIL import Image from scipy.ndimage import binary_dilation ref = np.asarray(Image.open("page.png").convert("RGB")).astype(int) enc = np.asarray(Image.open("page_q75.jpg").convert("RGB")).astype(int) paper = (ref == 255).all(axis=2) # pure white in the lossless render halo = binary_dilation(~paper, iterations=3) & paper # white pixels within 3 px of ink drift = np.abs(enc - ref).max(axis=2) # worst channel per pixel print(f"turned grey: {(drift[halo] > 8).mean():.2%}") 

Each quality step cleaned up a different amount

format text page bytes text page grey chart page bytes chart page grey
PNG (lossless) 838,404 0% 127,051 0%
JPEG q75 535,729 11.69% 178,232 10.13%
JPEG q85 647,884 4.86% 201,943 4.22%
JPEG q92 796,608 0.40% 233,599 0.70%
WebP q75 287,552 3.13% 49,492 6.12%
WebP q90 390,340 0.13% 66,868 0.68%

Going from 75 to 85 cut the text page share to under half, and going from 85 to 92 cut it by about twelve times. The chart page follows the same shape. The bytes column matters as much. On the text page, the JPEG at 92 is only about 5% smaller than the lossless PNG, so the clean setting buys very little over going lossless. On the chart page every JPEG row is bigger than the PNG, which means q75 there gives you a larger file and a grey halo at the same time. WebP at 90 kept the text page down to 0.13% at roughly half the bytes of JPEG q92.

PSNR stayed calm while the halo came and went

On the chart page, PSNR went 38.80, 39.73 and 40.21 dB across q75, q85 and q92. That is 1.41 dB in total, while the grey share fell from 10.13% to 0.70%. On the text page the PSNR moved more, from 37.86 to 45.92 dB, but a reading of 37.86 dB on its own gives no hint that more than one in ten halo pixels had changed. My guess is that the page is mostly blank paper, about 81% white on the text page by a quarter-size count, and the average gets pulled toward that clean background. That guess was not tested.

A browser export scored the same way

For a second data point, the same PDF was exported with ImgIng https://imging.ai/ (desktop browser, default 216 DPI), and its JPEG export was scored against its own PNG export with the same rule. Its text page came out at 0.13% and its chart page at 0.34%. Different renderer, different reference, so those two stay out of the table above. Back on the poppler encodes, the q75 halo is easiest to spot on the red 7.5 pt heading: zoomed in 14 times, a grey-white rim runs around the red strokes.

A check before keeping a JPEG of a text page

The check that works with any converter is to zoom into the smallest text on the page and look at the white right next to the strokes before keeping the file. If the halo is there, raising the quality or switching format is the first fix to try, before touching the DPI. For pages that are mostly lettering, JPEG below 85 is a setting I would not keep on this evidence, and once you are at 92 the PNG is close enough in size that lossless might be simpler. Two things stay open. Only one threshold was used, and nothing here says what a chat app does to the image after you send it, because that was never tested.