Keeping HTML-to-PDF text editable
I needed a printable A4 flyer, but I also wanted the text to remain text in the
PDF — so I could make small edits in Illustrator without going back to the
source. HTML and headless Chrome seemed like a good fit: CSS understands
millimeters and @page, the layout stays in version control, and the PDF is
easy to regenerate.
It worked. It also produced several files that looked completely correct while being wrong in ways that only appeared downstream:
- a variable font was emitted as Type 3 outlines rather than editable text;
- a soft mask developed a hard edge in iOS PDF viewers;
- a QR code looked fine but was not reliably scannable after export; and
- a simple brightness threshold removed part of a cream-colored logo.
The useful general lesson is this: a PDF is not validated by looking at it. Validate the properties you care about — font types, compositing, machine readability — from the generated file.
The pipeline
This was the minimal export command:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --disable-gpu --no-pdf-header-footer \
--print-to-pdf=out.pdf --virtual-time-budget=9000 \
"file:///path/to/flyer.html"
With @page { size: A4; margin: 0 } and a page element sized in mm, Chrome
produced the expected page size. The important work started after that command:
checking what was actually inside out.pdf.
1. Variable fonts can become Type 3 fonts
The first check was:
pdffonts out.pdf
The result looked like this:
name type emb sub uni
--------------------------------- ----------------- --- --- ---
AAAAAA+InstrumentSerif-Regular CID TrueType yes yes yes
BAAAAA+DMSans-9ptRegular_opszAF5E3_wght1F40000 Type 3 yes yes yes
The headline font was an embedded TrueType font. The body font was Type 3.
A Type 3 font stores glyphs as PDF drawing procedures. It can render perfectly, but many PDF and vector editors will expose those glyphs as outlines rather than usable text. That defeats the purpose of keeping the PDF editable.
In this case the difference was a variable font. Chrome instantiated its axes
at render time and emitted the result as Type 3; the axis values were even
visible in the generated font name (opsz...wght...). This is a behavior to
test in your exact Chrome/font combination, not a claim that every variable
font always becomes Type 3.
The obvious fix — asking Google Fonts for a “static” stylesheet — did not fix this particular font. The files served by
https://fonts.googleapis.com/css?family=DM+Sans:400,500,700
were still derived from the variable family, and Chrome still produced Type 3.
What worked was creating static instances with fontTools:
from fontTools.ttLib import TTFont
from fontTools.varLib import instancer
for name, weight in [("regular", 400), ("medium", 500), ("bold", 700)]:
font = TTFont("DMSans[opsz,wght].ttf")
instancer.instantiateVariableFont(
font,
{"wght": weight, "opsz": 14},
inplace=True,
)
font.flavor = "woff2"
font.save(f"DMSans-{name}.woff2")
Declare those files in separate @font-face rules with explicit weights,
export again, and check the result with pdffonts. In my output they became
CID TrueType fonts and remained text that Illustrator could work with.
Two caveats matter here:
- “Embedded font” does not guarantee that every editor will preserve every text feature. Test the actual editor and PDF workflow you intend to use.
pdffontsis a quick, useful signal, not a complete editability test. Open a representative PDF in the downstream application before committing to a font stack.
The PDF can look identical either way. Inspect the font table, then test the real editing workflow.
2. Soft masks are renderer-dependent
The flyer used a soft-focus effect: one copy of an image stayed sharp while the rest was blurred, with a feathered transition between them. In CSS, that meant two image layers and a radial-gradient mask:
.sharp {
mask-image: radial-gradient(
ellipse 54% 27% at 50% 53%,
#000 42%,
rgba(0, 0, 0, 0.32) 84%,
transparent 100%
);
}
It looked beautiful in Chrome, Preview, and Acrobat. On iOS, the same exported PDF showed a hard seam where the gradient should have been smooth.
The issue was not malformed CSS. Chrome had translated the gradient into a PDF
soft mask (SMask), and the viewers did not render that mask identically. A
PDF feature that is perfectly acceptable for screen output can still be a poor
choice when the file must work across several renderers.
The robust fix was to do the compositing before PDF generation: blur the image, apply the feathered mask in pixels, and give Chrome one flattened image. Use a lossless PNG when the artwork has sharp edges or transparency; use JPEG only when its compression artifacts are acceptable.
import numpy as np
from PIL import Image, ImageFilter
sharp = Image.open("photo.png").convert("RGB")
W, H = sharp.size
blurred = sharp.filter(ImageFilter.GaussianBlur(radius=41))
yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
d = np.sqrt(((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2)
mask = np.interp(
d,
[0.42, 0.66, 0.84, 1.0],
[1.0, 0.72, 0.32, 0.0],
).astype(np.float32)[..., None]
out = (
np.asarray(sharp, np.float32) * mask
+ np.asarray(blurred, np.float32) * (1 - mask)
).clip(0, 255).astype(np.uint8)
Image.fromarray(out).save("photo-composited.png")
This reduced my PDF from 7.9 MB to 1.9 MB because Chrome had been flattening the layers to a full-page raster anyway — just less efficiently, and twice.
As a quick diagnostic, this can reveal whether a PDF contains soft masks:
strings out.pdf | grep -c SMask
Treat that as a clue, not a formal PDF inspection tool. An SMask count does
not tell you whether a mask is a gradient, an image alpha channel, or whether
it will fail in a particular viewer. The definitive check is to open the PDF in
the viewers and devices you support, or flatten the effect when portability is
more important than editability.
3. Keep the QR code vector, then decode the PDF
The flyer also needed a QR code. Two details made a difference.
First, keep the code vector. In this Chrome pipeline, an SVG referenced as an
<img> was rasterized during printing, while the same SVG inlined directly in
the HTML stayed vector. That may vary with the browser and SVG, so inspect the
output if it matters. For a QR code, vector modules are preferable: soft raster
edges make scanning harder, especially when the code is printed small.
Second, verify the code from the rendered PDF, not from the source HTML. The export pipeline can change scale, clipping, contrast, or compositing after the source has already passed its own tests.
For complete control, I generated rectangular runs from the QR module matrix instead of using the library’s single stroked path:
import segno
qr = segno.make("https://example.com", error="h")
rects = []
for y, row in enumerate(qr.matrix):
x = 0
while x < len(row):
if row[x]:
start = x
while x < len(row) and row[x]:
x += 1
rects.append(
f'<rect x="{start + 2}" y="{y + 2}" '
f'width="{x - start}" height="1"/>'
)
else:
x += 1
The +2 values provide a two-module quiet zone in this example; the SVG’s
viewBox and scaling need to account for it. Error correction level H can
recover roughly 30% damage, but it is not a substitute for sufficient physical
size, contrast, and a proper quiet zone.
After exporting, rasterize the page, crop the code, and decode that crop:
import cv2
value, points, _ = cv2.QRCodeDetector().detectAndDecode(page_crop)
if not value:
raise RuntimeError("QR code did not decode from the rendered PDF")
That test covers the artifact you will actually distribute, not merely the input you intended to distribute.
4. Remove a white background by connectivity
The logo was dark-on-white artwork, but some lettering was cream. A brightness threshold therefore removed parts of the logo along with the background.
When the background is contiguous with the image edges, a flood fill is a better model: remove pixels connected to the outside, rather than pixels that happen to be bright. In production I used a flood-filled mask and converted the filled region to transparency:
import numpy as np
from PIL import Image, ImageDraw
image = Image.open("logo.png").convert("RGBA")
background = Image.new("L", image.size, 0)
for seed in [(0, 0), (image.width - 1, 0),
(0, image.height - 1), (image.width - 1, image.height - 1)]:
ImageDraw.floodfill(background, seed, 255, thresh=28)
alpha = image.getchannel("A")
alpha = Image.fromarray(
np.minimum(np.asarray(alpha), 255 - np.asarray(background)).astype("uint8")
)
image.putalpha(alpha)
image.save("logo-transparent.png")
This preserves cream lettering and enclosed counters because they are not connected to the outside. Choose the flood-fill threshold carefully: anti- aliased edges need some tolerance, but too much tolerance can leak into the artwork.
A small preflight checklist
For a PDF that needs to survive editing, printing, and mobile viewing, my minimum preflight now looks like this:
pdffonts out.pdf # inspect embedded font types
pdfinfo out.pdf # confirm page size and page count
pdftoppm -png -r 150 out.pdf page # make a test raster
strings out.pdf | grep -c SMask # flag soft-mask use for investigation
Then I decode every QR code from the rasterized page and open the PDF in at least one desktop editor and one mobile viewer. If a visual effect is not worth that compatibility test, I flatten it before export.
Was it worth it?
Yes — but the reusable result is not a particular Chrome flag or font trick. It is the discipline of validating the generated artifact.
Type 3 fonts render perfectly. A soft mask can look fine on the machine that made it. A broken QR code still looks like a QR code. None of those failures necessarily appears when you open the PDF and squint at it.
The checks are cheap: pdffonts for fonts, pdfinfo for dimensions, a
rasterized-page decode for QR codes, and a deliberate viewer test for effects
that depend on PDF compositing. That small preflight is what turned this from a
fragile one-off flyer into a reproducible HTML-to-PDF workflow.