A font-license checkpoint for PDF-to-HTML: what fsType can and can't tell you
If you convert PDFs into web pages, someone eventually asks whether you are allowed to put the PDF's fonts into the HTML. The obvious design is a checkpoint that reads the font's license flag and stops the pipeline when it says no. Looking at what actually arrives at that point changed where I would put the check.
What fsType can and can't tell you
The flag is fsType, a 16-bit field in the font's OS/2 table. Per the OpenType spec (the OS/2 page on learn.microsoft.com, read on 2026-10-28), bits 0-3 hold the usage permission, with valid values 0, 2, 4 and 8. Bit 8 means no subsetting, and bit 9 means bitmap embedding only.
I scanned the fonts on my Mac and only found 0, 4 and 8:
- Pacifico, Liberation Sans, Helvetica and Noto Serif SC report 0
- Palatino and PingFang SC report 4
- Arial and Georgia report 8
Values 2, 256 and 512 exist only in test copies I made by editing a font file.
The producer has often decided already
I printed the same bilingual page to PDF with Chromium 149, using Pacifico and Liberation Sans copies at each fsType. Then I listed the font objects in each PDF.
A TrueType font with fsType 2 or 512 is not embedded at all, and its glyphs go out as Type 3 outlines. With 256 the whole font is embedded without subsetting, and the Liberation Sans PDF grew from 85,024 to 502,702 bytes. With 0, 4 and 8 you get a normal subset.
A CFF-based font (Noto Serif SC, my only CFF sample) became Type 3 outlines at every value. This is one machine, one browser build, one run each.
So in my files, a converter reading a Chromium-printed PDF never met a font that says "do not embed". The restriction had been applied upstream, and what remained was a font program of one of three kinds. Whether other PDF producers behave the same way, I did not test.
Reading it back
Reading it back is a short job:
import sys, io, fitz
from fontTools.ttLib import TTFont
d = fitz.open(sys.argv[1]); seen = set()
for pg in d:
for f in pg.get_fonts(full=True):
if f[0] in seen: continue
seen.add(f[0]); name, ext, typ, buf = d.extract_font(f[0])
fs = TTFont(io.BytesIO(buf))['OS/2'].fsType if ext in ('ttf', 'otf') and buf else None
print(f[3], typ, ext, 'fsType =', fs)
On the first four pages of a CC BY-SA 3.0 brochure from Wiki Education Foundation (1,180,300 bytes), it printed five lines:
RMLRZN+NewsGothicBT-Bold TrueType ttf fsType = 4RMLRZN+ArialMT TrueType ttf fsType = 8RMLRZN+PlantinMTStd-Regular Type1 cff fsType = NoneRMLRZN+NewsGothicBT-Demi TrueType ttf fsType = 4RMLRZN+NewsGothicBT-Roman TrueType ttf fsType = 4
The Type 1 line has no number because that font has no OS/2 table. In this sample, for one font in five, the flag you wanted to check does not exist.
Three routes, and what each costs
I ran the same brochure through ImgIng's PDF to HTML (https://imging.ai/, Beta, desktop browser, processed locally). I treat the converter as a black box and only describe its output.
The four TrueType subsets came out as inlined woff fonts, each holding only the glyphs the pages use. In my fsType test files, a Liberation Sans subset had 94 glyphs, so it cannot typeset anything else. The Type 1 font was not inlined: 509 text elements fell back to a generic sans-serif, and the summary panel listed pages 2-4 as font substitutions. Type 3 glyphs became vector paths.
| Font in the PDF | What the HTML gets | Cost |
|---|---|---|
| TrueType subset | inlined woff, used glyphs only | text stays selectable; only used glyphs |
| Type 3 outlines | paths with a transparent text layer | no font file, shape preserved |
| Type 1 | system font fallback | the shape changes |
Paths are not necessarily a worse outcome. Pacifico at fsType 2 still rendered in Pacifico's shapes because the outlines were already there, and a few words in the inlined version (quick, brown) showed uneven spacing that the path version did not. For fsType 256 the whole font is in the PDF, and the HTML inlined all of it, 211,588 bytes and 2,620 glyphs for Liberation Sans in my copies, against 94 glyphs in the subset.
Where I would put the check
Not at the PDF's front door. My suggestion, untested in a real pipeline, is to put it where a font is about to become a file in the output, and have it sort each font into one of the three routes above. Read the permission bits when an OS/2 table exists, treat bit 8 as a reason to look at output size, and treat a missing table as fallback. Log the reason per font and show it in the report, which is what that summary panel already does for substitutions.
Two limits. fsType is a number the foundry typed, so I read it as a declaration, not a legal answer. And how ImgIng itself decides what to inline, I can't tell from outside, so I would not claim it checks the flag. What I can say is that in my Chromium-printed files, fsType 2 and 512 fonts never reached it as fonts, because the browser had already turned them into outlines.
Comments
No comments yet. Start the discussion.