Skip to content

Save: subset font programs embedded whole (#202) - #232

Merged
timzifer merged 8 commits into
mainfrom
claude/issue-202
Oct 14, 2026
Merged

timzifer merged 8 commits into
mainfrom
claude/issue-202

Conversation

@timzifer

Copy link
Copy Markdown
Owner

Fixes #202. Stacked on #231 (on #230, #229, #227, #226, #225).

API

SaveOptions.SubsetFonts bool.

How (fontsubset.go)

  • Glyphs shown: every page of the result is run (RunWith, all layers) with a TextDevice (glyphMeter) that records, per *Font, the GIDs and codes of every ShowText — the GIDs the renderer takes, through forms, patterns, Type 3 glyphs and annotation appearances. Fonts are grouped by their program stream (a program shared by several font dictionaries gets the union).
  • Stays whole: fonts of /AcroForm /DR (read from the catalogue, also for an empty form), fonts drawn with a stand-in or not readable, stdGlyphs stand-ins, Type 1 (/FontFile) and CID-keyed CFF programs, and all programs of a document whose pages did not run cleanly (errors or unsupported features).
  • TrueType (/FontFile2, or OpenType with glyf): subsetGlyf empties the glyphs not shown (glyph 0 and composite components kept), rebuilds glyf/loca (short or long format as fits) and head; glyph numbers stay, so encodings, /CIDToGIDMap and /W stay valid.
  • CFF (/FontFile3 Type1C, or OpenType CFF ): opentype's SubsetCFF (charstrings → endchar, numbering kept). It needs the table in an sfnt, so a bare program is wrapped in a minimal OTTO (wrapCFF: head, maxp 0.5, hhea, hmtx).
  • Tables a PDF is not drawn with are dropped (GSUB GPOS GDEF BASE JSTF MATH kern morx … fvar gvar avar HVAR MVAR STAT …, DSIG); a vertical font keeps GSUB (cera takes its vertical forms from it). For a variable TrueType font this leaves the default instance, which is what a PDF shows.
  • Verification: the subset is read back with readProgram; every glyph shown must have the same outline, and for simple fonts a font is loaded anew with the subset program (loadFontWith) and every code shown must give the same GID, outline and text. Otherwise the program stays whole. The corpus found two files (issue3566.pdf, issue6132.pdf) where the first version (outlines by GID only) changed what codes showed; the code check catches them.
  • Names: /BaseFont (also of a Type 0 font and its descendant) and /FontName get a tag from the glyphs (ABCDEF+), unless tagged already; /CIDSet is removed (it would list glyphs that are gone).
  • Replacements go through pdfedit.Doc.Replace (Save: make images smaller at a loss, on request (#201) #231).

Tests

  • TestSubsetFonts: Arimo (a 496 KB variable TTF) as a simple TrueType font and as CIDFontType2/Identity-H with a /CIDSet, and a synthetic 120-glyph bare CFF as Type1C: program at most 20 % / 60 % of before, names tagged, /CIDSet gone, render at scale 2 pixel-identical, text identical.
  • TestSubsetFontsWhole: a font of /DR stays whole and untagged; glyphs of a hidden layer and of an annotation's appearance are kept (pixel-identical with and without the layer).
  • Corpus API step subset fonts: pages 1–3 pixel-compared (strict, as optimise) and text compared. pdf.js corpus: no differences.
  • go test ./... green.

The /BBox of an annotation's appearance stream is mapped onto its /Rect,
and the border of a field lies on the edge of that box. With the box off
the pixels, the exact clip and the border's edge, both antialiased,
lightened the edge pixels twice: a field Editor.SetFields wrote was drawn
with other edges than the same value shown from a FormState, which is not
clipped. The clip of an annotation's appearance is now rounded outward to
whole device pixels when it stays on the axes, as PDFium, MuPDF, Poppler
and Ghostscript clip rectangles. The accuracy report against PDFium is
unchanged.

overlayFile puts its fields off the pixels, which the test held them to.

Fixes #222.
The first fix of #222 rounded the clip of every annotation's appearance
outward to whole pixels. That made the render of an appearance stream
differ from the page it is flattened to, whose content clips exactly: the
corpus API run reported 41 files of pdf.js whose flattened first page
differed, by up to 4.9 % of its pixels.

The other way round: the appearance cera generates for a widget is
clipped to the widget's box exactly, as the stream Editor.SetFields
writes for it and the form Editor.Flatten draws are. The render with a
FormState, the saved file and the flattened page are drawn alike at any
scale; the border of a field off the pixels is lighter on its outer edge
in all three, as the appearance streams of documents are.

The corpus API report and the accuracy report against PDFium are the same
as on main.
ImportOptions.Structure brings the elements of the source's structure
tree whose content is on imported pages, with the elements above them;
an element left without content is dropped. Where the result has a tree,
the elements follow its own in a Part under its Document element (or its
root): the source's Document becomes the Part, or one is made around its
elements, with the source's /Lang where it differs. A result without a
tree takes the first one brought as its own.

The keys of the parent tree are numbered anew after the result's: pages
(/StructParents), their annotations (/StructParent) and the form XObjects
they draw. A page imported twice is tagged once, its first copy; a form
only pages that did not come draw as tagged content loses its keys. Marked
content and object references are mapped to the copies. Role maps are
merged, a type the result maps otherwise written as what the source means
by it; a class the result defines otherwise is renamed Cls_2, an
identifier the result has id_2; namespaces are joined. /MarkInfo says
/Marked.

The corpus API run gets a step: the pages looked at, imported with their
structure into the document itself, keep their marked content in the
tree. Over the pdf.js corpus it finds no difference.

Fixes #183.
PDF 2.0 lets a page carry /OutputIntents of its own (14.11.5), which
take precedence over the document's on it. Such a page is now drawn with
a view of the document (Page.cmykDoc) whose DeviceCMYK converts through
the profile of the page's intent, read as the catalogue's is: the first
intent of kind GTS_PDFX, GTS_PDFA1 or ISO_PDFE1 with a CMYK profile.

Document is split into what all views share (docShared: the reader,
fonts, the form, the profiles and their budget, transfer functions) and
what stands on DeviceCMYK (cmykView: colour spaces, shadings and
patterns by reference, decoded images under an id of their own). Pages
with one profile share a view; forms and patterns drawn on a page take
its view. NaiveCMYK, CMYKProfile and IgnoreOutputIntent decide for the
pages as for the document. A page profile counts against
maxCMYKProfiles, and one cera cannot read is counted as icc-lut, the page
then converting as the document does.

The corpus API report and the accuracy report against PDFium are the
same as before.

Fixes #186.
SignOptions.Timestamp takes a TimestampFunc that gets a token (RFC 3161)
from a timestamp authority for the SHA-256 digest of the signature value;
the token goes into the signer info as the unsigned attribute
signature-time-stamp (PAdES B-T). cera does no network I/O: the function
makes the round trip, and may answer with the token or with the whole
TimeStampResp (cms.Token). A token that does not stamp the digest, or
whose authority did not sign it, is an error (cms.Stamps). The room for
/Contents grows by 16 KB; Editor.SignContext passes a context.

Editor.Timestamp writes a document timestamp (/Type /DocTimeStamp,
ETSI.RFC3161) over the update, in an invisible field. Editor.AddValidation
records certificates, OCSP responses and CRLs the caller fetched, written
into the document security store (/DSS with /Certs, /OCSPs, /CRLs and a
/VRI entry per signature, keyed by the SHA-1 of its /Contents) by Update,
Sign and Timestamp; what the store has stays and is not stored twice.
With a document timestamp after it the signatures are B-LTA. Both are no
change by the rules of a certification (PDF 2.0, 12.8.2.2.2), so a
document certified to allow none may get them, and Verify does not count
a document timestamp as a change.

Two faults of Verify's comparison of revisions, found on the way: a
reference to a page was compared by the page's content, so that a link
to a page whose /Annots a later signature changed made the signature
report annotations changed; and fields were matched by name, so that a
form with two fields of one name reported a value filled in. Pages are
now compared by their place, fields by their objects.

The corpus API step "sign" signs with a timestamp, adds validation data
and a document timestamp, and checks both signatures.

Fixes #193. Revocation from /DSS when verifying, and the smaller points
#143 left, are #228.
A path that reached into an area was clipped, and its numbers stayed in
the file: a curve half under the area could be read back whole. Now its
segments are split where they cross the edges of the area (lines and
cubic curves against the lines of a convex quad), and each run of pieces
in the area is replaced by the straight line across it; the clip stays.
The area is convex, so what the path fills outside it is the same. A
stroke is cut against the area narrowed by how far it reaches (half its
width, the mitre limit with mitre joins), so that its new joins stay in
the area; a dashed stroke is only clipped, since a shorter path moves its
dashes. A filled subpath is cut as a loop; a stroked open one loses the
runs at its ends. s, b and b* are written as S, B and B* with the path
closed itself, and a line there and back is stroked as the line, as the
renderer draws it. A clip in an area becomes an empty one, a clip that
reaches into one is cut too.

ApplyRedactions writes /OverlayText over the rectangle of a redaction
annotation, in Helvetica (fallback fonts for what it lacks) at the size
and in the colour of /DA, aligned by /Q, with /Repeat over and over.

Part of #198: the other points of its list stay open.
SaveOptions.Images takes ImageOptions: MaxPPI and TargetPPI resample
images drawn finer than asked, JPEGQuality stores photographs in grey or
RGB as JPEG, Bilevel stores images of one bit a pixel as CCITT Group 4.

The resolution of an image is measured where it is drawn largest: the
pages of the result are run with a device the interpreter tells of the
images it draws (imageSeer) instead of decoding them, in forms, patterns
and annotation appearances, all layers shown; masks are measured where
their image is drawn. An image is decoded to its samples in its own
colour space and resampled by averaging (palette indices by the middle
pixel; two and four bits become eight); one-bit images, stencil masks
among them, are not resampled. It keeps its colour space, /Decode and
masks, and is replaced only by an encoding that is at least 64 bytes
smaller. Images under DCT, JPX or JBIG2 and with colour key masks are
left as they are. Photographs are told from line art by the colours on
a grid over them.

The Group 4 encoder (internal/pdf/ccittenc.go) codes T.6 with the tables
of the decoder and is checked against it. pdfedit.Doc.Replace writes
objects of a source document in place of what it has.

The corpus API run gets the step "images": the file opens, is no larger
and the pages looked at are as bright as they were.

Fixes #201.
SaveOptions.SubsetFonts writes the TrueType and name-keyed CFF programs
of the pages with only the glyphs they show. The pages are run with a
TextDevice that collects, by font, the glyphs and codes shown — in
forms, patterns, Type 3 glyphs and annotation appearances, all layers
shown — so the glyphs are those the renderer takes. Glyphs not shown are
emptied and keep their numbers: glyf/loca rebuilt with the components of
composite glyphs kept, CFF charstrings replaced by endchar (opentype's
SubsetCFF; a bare program is wrapped in the smallest OpenType file for
it). Layout and variation tables, which a PDF is not drawn with, go; a
vertical font keeps GSUB for its vertical forms. /BaseFont and /FontName
get a subset tag, /CIDSet goes.

A program stays whole where that is not safe: fonts of the form (/DR),
fonts cera draws with a stand-in or could not read, CID-keyed CFF and
Type 1 programs, documents whose pages did not run cleanly. The subset
is read back: every glyph shown must have its outline, and every code of
a simple font its glyph and its text, or the program stays whole — the
corpus showed two files where a subset program found other glyphs for
its codes.

The corpus API run gets the step "subset fonts": pages look and read as
they did.

Fixes #202.
@timzifer
timzifer merged commit 61ef6fd into main Oct 14, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save: subset fonts embedded whole

1 participant