Skip to content

PDFBOX-3293 truetype hinting - #532

Draft
pcorless wants to merge 8 commits into
apache:trunkfrom
pcorless:PDFBOX-3293.truetype.hinting.submit.no-tricky
Draft

pcorless wants to merge 8 commits into
apache:trunkfrom
pcorless:PDFBOX-3293.truetype.hinting.submit.no-tricky

Conversation

@pcorless

Copy link
Copy Markdown

PDFBOX-3293

  • cuts in font hinting into FontBox and PDFBox
  • combined my background on this issue with help from Claude to get some of the finer points worked out
  • some more cleanup is needed but the basic structure is in place.

@pcorless
pcorless force-pushed the PDFBOX-3293.truetype.hinting.submit.no-tricky branch from 6c33bd6 to 933566a Compare September 16, 2026 02:52
@THausherr

Copy link
Copy Markdown
Contributor

Thank you, very interesting. From having a quick look I didn't see any breaking changes. I will set up some testing to see how the rendering is going.

You don't need to include the liberation font, it's already there, and is used in existing tests:

getResourceAsStream("/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"));

@pcorless

Copy link
Copy Markdown
Author

I'll get that font path touched up.

@THausherr

THausherr commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

I did a fast (human) visual compare by scrolling through the diff files. Besides the one chinese file that the issue is about, many were slightly different but the few where I had a closer look didn't seem to have any problem, one was slightly better, the horizontal bar in an "e" was more in the middle than without hinting. The rendering with hinting was faster (which is surprising, we'll see if this keeps happening) One difference has less quality:
mozilla/pdf.js#1732

file: PDFJS-1732.pdf

new rendering at 96 dpi (from the third notes line):
grafik

old rendering:
grafik

another file also caught my attention:
genko_oc_shiryo1.pdf
the new rendering looks partly bold.

new:
grafik

old:
grafik

Another small thing: IMHO the enabling of the hinting feature should be in PDFRenderer, not in TrueType. This can be done like with setSubsamplingAllowed(), and passed to PageDrawerParameters, then to PageDrawer.

@THausherr

Copy link
Copy Markdown
Contributor

Copilot comment:

High-risk issues I found

  1. GraphicsState.round() / round-state behavior may not match TrueType precisely

The round-state implementation is central to many opcodes. I’d specifically re-check:

ROUND_UP_TO_GRID
ROUND_DOWN_TO_GRID
ROUND_TO_DOUBLE_GRID
setSuperRound()

Why it’s risky:

These behaviors feed directly into MDRP, MIRP, MDAP, and MIAP.
A small rounding mismatch can shift stems by a pixel.

This one isn’t obviously broken, but it is critical enough that I’d want more proof against FreeType for edge values and negative distances.
2) TrueTypeInterpreter.installLogicalOps() likely has semantic issues

These lines stand out:

ODD / EVEN use Fixed.round(ctx.pop()) >> 6
AND / OR use Java truthiness semantics
NOT checks == 0

The likely issue is the exact definition of ODD/EVEN in TT bytecode. They depend on the rounded distance and parity at the 1/64 pixel level, and I’d want to verify the exact threshold behavior.

This may already be covered by tests, but I’d still flag it as worth a deeper look.
3) ROLL looks suspicious

Current implementation:

pops c, b, a
pushes b, c, a

That is not obviously the standard TrueType ROLL behavior unless the stack order matches a specific convention.

I’d double-check against the spec. ROLL is one of those stack-manipulation ops that can be silently wrong and then corrupt later execution.
4) MINDEX appears fragile

The implementation pops k, then pops k values, then pushes them back in a rearranged order.

That logic is a common place for off-by-one or order bugs. I would explicitly test:

k=1
k=2
larger values
interaction with the exact stack ordering expected by the spec

I don’t think this should block the PR on its own, but it deserves scrutiny.
5) TrueTypeInterpreter.jump() / control flow is a likely source of subtle bugs

This is one of the most important areas.

Concern points:

Jumps are relative to instructionStart()
instructionStart() is set just before opcode fetch
skipToElseOrEif() / skipToEif() manually skip over nested control flow and push operands

Potential issues:

whether relative offsets are measured from the right byte
whether ELSE/EIF skipping preserves correct nesting for all nested cases
whether JMPR, JROT, JROF interact correctly with instructionStart

This is exactly the sort of logic that can work for tested fonts but fail on odd control-flow constructs.
6) FDEF / IDEF / function body handling needs special scrutiny

The function-definition model stores:

the original program bytes
entry point offset

Then later replays from the stored program using callBody().

Things I’d verify carefully:

whether nested function definitions inside a function body are handled correctly
whether ENDF return semantics behave correctly when called recursively
whether ctx.setReturnFromFunction(false) is reset safely for nested calls
whether skipFunctionBody() properly handles push opcodes and nested IFs inside function bodies

This area is conceptually sound, but it’s one of the easiest places for “it works for Liberation Sans” but not for other fonts.
7) ExecutionContext.movePoint() backward-compatibility behavior is very heuristic-heavy

This is the riskiest logic in the whole patch.

The method now suppresses movement in certain cases when:

backward compatibility is enabled
IUP has run on both axes
y movement should freeze

That may be correct for the intended render mode, but it is a policy layer mixed into the VM’s primitive point-move operation.

Risks:

it can affect multiple opcodes unexpectedly
it changes behavior based on timing of IUP, which is hard to reason about
it may make the interpreter less spec-faithful than intended

If this is intended to emulate FreeType’s grayscale compatibility mode, I’d strongly suggest isolating it more explicitly so the core point move semantics stay clean.
8) hintingPpem() in PageDrawer looks plausible but needs proof for transforms

The code computes ppem from the transform’s vertical basis vector magnitude.

That’s clever, but because hinting is sensitive to device resolution and transform composition, I’d want:

explicit tests for rotation
explicit tests for anisotropic scaling
explicit tests for fractional transforms

You added some tests, which is good, but this is still a place where “looks right” can hide bugs.
Most concerning likely bug class

If I had to prioritize, I’d focus on:

control flow / stack ops
rounding semantics
function-definition replay
backward-compatibility movement suppression

Those are the areas most likely to produce subtle regressions.

@THausherr

Copy link
Copy Markdown
Contributor

I can't comment on the copilot comments because I didn't look at the new code yet (e.g. because I have no knowledge whatsoever about hinting, the only thing I know is that it adjusts the points to a grid). I mostly looked at changes in the existing code and it looked like the new feature peacefully coexist, which is nice.

@pcorless

Copy link
Copy Markdown
Author

I'll start working through those copilot comments.

Performance is an interesting one. On my test suite I also see no penalty for having hinting enabled. The caching works really well for most fonts.

I referenced pdf.js on my initial research as I thought it would be a good reference. As I dug in though it turned out their hinting was coming from a native system library not from JavaScript.

The two sample files you posted both fall into the "tricky" fonts category. FreeType has identified a list of fonts that need to be handled as special case. Differently being they need the bytecode to assembled/scale the glpyhs on every size. FreeTypes license is pretty flexible so I suspect it can be tied back in but it will take some legal work to properly update the LICENSE and NOTICE files.

Pushed changes for your two comments.

@THausherr

Copy link
Copy Markdown
Contributor

Thank you; please merge the latest changes from the trunk, and enable line 708 in PagePane.java, the call renderer.setHintingEnabled(...) this way you (and me) can see the difference in PDFDebugger.
grafik

@THausherr

Copy link
Copy Markdown
Contributor

Re Freetype license, it is not mentioned on
https://www.apache.org/legal/resolved.html
so we may have to ask, but they accept BSD and the freetype license is said to be "BSD like".
Re license, we need an ICLA from you
https://www.apache.org/licenses/contributor-agreements.html

pcorless and others added 8 commits September 17, 2026 20:43
Adds the four tables a bytecode interpreter needs, as ordinary TTFTable
implementations registered with TTFParser and exposed from TrueTypeFont:

  cvt   ControlValueTable         control values in font units
  fpgm  FontProgramTable          the font program, run once per font
  prep  ControlValueProgramTable  the control value program, run per size
  gasp  GaspTable                 per-ppem grid-fitting and smoothing flags

Parsing only - nothing executes these yet, and no existing behaviour
changes. GaspTable resolves the flags for a ppem the way the specification
describes: the first range whose upper limit is at or above the requested
size, with the implied final 0xFFFF range.

Expected values in HintingTablesTest were read from LiberationSans with
ttx, so the test pins the parse against an independent tool rather than
against itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The virtual machine that executes TrueType hinting programs, package-private
to org.apache.fontbox.ttf: nothing outside the package can reach it, and it
is not exported from the OSGi bundle.

  TrueTypeInterpreter  the driver and the 256-entry dispatch table
  ExecutionContext     per-run state: operand stack, zones, call depth
  GraphicsState        vectors, reference and zone pointers, round state
  Zone                 a set of points with current/original/unscaled
                       coordinates and per-axis touch flags
  BytecodeStream       a bounds-checked cursor over a program
  Fixed                F26Dot6 and F2Dot14 integer math
  UnitVector           the projection, freedom and dual-projection vectors
  FunctionDef          an FDEF entry point
  HintingException     any failure; callers fall back to the raw outline
  ExecutionTracer      optional per-instruction trace, off in normal use

All 173 opcodes are implemented. The 32-variant MDRP and MIRP families and
the other flag-encoded groups decode their flags from the low bits of the
opcode rather than being enumerated, so roughly a hundred dispatch slots are
served by a dozen handlers.

Everything stays in integer fixed point, matching FreeType, so output can be
compared against it exactly rather than approximately. Fixed reproduces
FT_MulDiv including its behaviour when the divisor is zero, and DIV
truncates rather than rounds as FT_MulDiv_No_Round does.

Two bounds keep a crafted font from running forever, both sized as
FreeType sizes them in TT_RunIns: backward jumps and cumulative LOOPCALL
iterations. A backward jump and LOOPCALL are the only ways TrueType
bytecode can loop, so bounding them bounds the program; a four-byte glyph
program otherwise spins indefinitely. CALL nesting is capped at 64 as
FreeType does.

The storage area and twilight zone belong to the size rather than to one
program run, as in FreeType's TT_Size: a font may compute values into them
in prep and read them back from every glyph program. Both are cleared when
the ppem changes, before prep runs, as tt_size_run_prep does.

Unit tests cover the opcodes individually, the rounding state machine, the
fixed-point math, the bytecode cursor, and graphics state defaults, deep
copy and per-glyph reset. Byte-exact agreement with FreeType is established
separately by the golden comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GlyphHinter drives the interpreter for one font: it builds the VM from the
font's maxp, head, cvt, fpgm and prep tables, runs the font program once,
re-runs the control value program whenever the ppem changes, and for each
glyph scales the outline into the pixel grid, appends the phantom points,
executes the glyph's instructions and scales the result back to font units.
TrueTypeFont.getHintedPath(gid, ppem) is the entry point.

Hinting is off by default. TrueTypeFont.SYSPROP_HINTING
("org.apache.fontbox.ttf.hinting") or setHintingEnabled(boolean) turns it
on; while it is off getHintedPath returns null and callers use the raw
outline, so existing output is unchanged.

It is best-effort throughout. A composite whose components cannot be
resolved, a glyph with no instructions, a ppem the gasp table excludes, a
malformed program - each falls back to null for that glyph alone, never for
the rest of the font. The first failure in a font is logged with a stack
trace and the rest at debug level, so a font that never hints does not
flood the log.

Composites are assembled the way FreeType assembles them: each component is
hinted on its own, then transformed and offset into the composite's space,
with the assembled positions becoming the originals the composite's own
instructions measure against. Component offsets are not grid-rounded.

Grayscale rendering follows FreeType's v40 interpreter: movement in x is
suppressed and y is frozen once IUP has run on both axes, which is what
stops stems being darkened under antialiasing.

Every entry point is synchronized, so one font hints one glyph at a time.
That matters because a system-substituted font is held in a process-wide
cache and several rendering threads can share one instance.

Known limitation: FreeType exempts a short list of "tricky" fonts - mostly
CJK fonts that assemble glyphs from sub-pixel-sized components - from the
grayscale movement restrictions and from the gasp grid-fit bit, because
they are unreadable without full bytecode control. Identifying them
requires a lookup table, which is not included here; those fonts render
unhinted, exactly as they do today. Adding that support is a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PDVectorFont gains getHintedNormalizedPath(code, ppem), defaulting to null
so nothing changes for fonts that cannot hint. PDTrueTypeFont and
PDCIDFontType2 implement it for embedded glyf outlines, normalising to the
1000-unit em square exactly as getNormalizedPath does; PDType0Font
delegates to its descendant.

PageDrawer derives the ppem from the glyph-space-to-device transform. The
text rendering matrix alone maps glyph space to PDF user space, so the
device transform is composed in first - otherwise a 7pt font would be
grid-fit at 7 pixels per em rather than the 29 it is actually rendered at
on a 300dpi raster. The ppem is the magnitude of the transform's vertical
basis vector, which is rotation-invariant: a rotated glyph is grid-fit in
its own upright space and the full transform applied afterwards, as
FreeType does for vertical CJK text.

GlyphCache keeps hinted paths under a (code, ppem) key, since a hinted
outline is only valid at the size it was fitted for, and caches the
fallback under the same key so a glyph that does not hint is not retried.
The existing code-keyed cache and its path are untouched: while hinting is
off PageDrawer takes that path and the feature costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unit tests establish that each opcode does what the specification says.
They cannot establish that a glyph comes out where FreeType puts it, which
is the only definition of correct that matters here, so the output is
compared against FreeType directly.

ttf/hinting/ holds the harness: a small C program links against FreeType
and dumps the grid-fitted points of a glyph, a Python script drives it to
produce the reference files, and a second script diffs a per-instruction
trace from this interpreter against the equivalent FreeType trace to
localise a divergence to the instruction that caused it. The reference
files are checked in so the tests need neither FreeType nor Python to run;
the README records how to regenerate them, and the generator emits the
license header so a regeneration does not drop it.

GoldenHintingTest compares LiberationSans at 11, 13, 16 and 24 ppem
against those references. Every x coordinate agrees to within one 64th of a
pixel for simple and composite glyphs alike. Vertical positions are checked
for grid alignment without collapse rather than for exact equality, since
the y axis is where the v40 backward-compatibility rules deliberately
diverge.

The harness found six bugs that the unit tests could not: interpolation
using scaled rather than unscaled originals, grid-rounded composite
component offsets, SHP/SHC/SHZ moving the reference point, DIV rounding
instead of truncating, swapped MDRP/MIRP round and minimum-distance flag
bits, and composite component originals taken from the unhinted rather than
the assembled outline.

GlyphTraceTool is the developer entry point for the trace side; it is not a
test and asserts nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…touch up respective tests with source of truth.
@pcorless
pcorless force-pushed the PDFBOX-3293.truetype.hinting.submit.no-tricky branch from 7b011f5 to 7c68966 Compare September 18, 2026 03:16
@pcorless

Copy link
Copy Markdown
Author

I've pushed the hinting hookup in the debugger.

I think I was was wrong on the "tricky" fonts on those two samples, will continue to investigate. I have some notes on the issue and need to revisit them.

I'll get you those contributor agreements soon.

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.

2 participants