Skip to content

PDFBOX-3293 truetype hinting - #532

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

pcorless wants to merge 17 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
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.

@pcorless

Copy link
Copy Markdown
Author

Push changes to address the review comment for github and fix for genko_oc_shiryo1.pdf bolding. PDFJS-1732.pdf is a different beast, hinting shouldn't be applied on this one, still looking for a why.

@THausherr

Copy link
Copy Markdown
Contributor

not derivatives of FreeType (see hinting_plan.md "Oracle licensing").

Is this referring to a file in the FreeType distribution?

@pcorless

Copy link
Copy Markdown
Author

I've touched up that wording. FreeType uses "oracle" quite a bit when comparing to a golden set. I've renamed it "reference" to make it more clear. I've also pulled out references to my local notes I used to build the plan for this.

I've been testing withe the sneaky changes on/off and I think it will be worth while figuring out how to handle the licensing as it's a real value add against my test set.

@THausherr

Copy link
Copy Markdown
Contributor

Please merge the latest changes from the trunk, I have removed the trailing spaces so that the actual change is better visible.

Here's the latest copilot comments:

Blocking review comments

fontbox/src/main/java/org/apache/fontbox/ttf/BytecodeStream.java, around nextByte() and nextWord() — setEnd() establishes a function-body limit, and seek() honors that limit, but both read methods only check code.length. Consequently, a PUSHB/PUSHW operand at the end of a function body can consume bytes beyond the matching ENDF, including instructions from the surrounding program. This defeats the function-boundary protection and can produce incorrect execution or allow malformed font bytecode to escape its function body. Both methods should validate against limit rather than code.length, and nextWord() should verify that both bytes remain within the limit.

BytecodeStream.java

@@
public int nextByte()
{

  • if (ip >= code.length)
  • if (ip >= limit)
    {

Other important findings

fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeInterpreter.java, jump/function handling — Add an explicit regression test for a PUSHW or PUSHB whose operand bytes straddle the function limit. The current tests cover jumps out of a function body, but not operand reads crossing the limit, so they would not catch the defect above.

TrueTypeInterpreterTest.java

@@
+@test
+void testFunctionBodyCannotReadOperandsPastEndf()
+{

  • // The PUSHW is positioned so its second operand would be ENDF or code after ENDF.
  • // The interpreter must reject the read rather than consume bytes outside the body.
fontbox/src/test/resources/ttf/hinting/generate_golden.py and README.md — The documentation describes inconsistent FreeType targets: the generator uses FT_LOAD_TARGET_NORMAL, while the README describes monochrome/full-pixel tracing in places and the tests claim grayscale/v40 behavior. The golden-test contract should state one exact FreeType load target and use the same target in all scripts and documentation; otherwise regenerated reference files may silently validate a different interpreter mode.

fontbox/src/main/java/org/apache/fontbox/ttf/GlyphHinter.java — The implementation silently returns null for all composites when the component assembly or hinting path fails, causing the renderer to fall back to the raw outline. That is a reasonable resilience policy, but the tests should distinguish “unsupported composite hinting” from “successful composite hinting” and assert that representative composites actually receive instructions and produce the intended hinted result.

pcorless and others added 14 commits September 22, 2026 22:34
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.
head.lowestRecPPEM is the vendor's "smallest readable size in pixels" for the
outlines. Fonts that ship bitmap strikes for small sizes set it above the
sizes the strikes cover - MS Mincho/Gothic, MingLiU and PMingLiU say 25, the
Ricoh HG* family 28, Founder FZ* 20 - and their instructions, written for
black-and-white output, force every thin stroke to a full pixel when run
under grayscale at those sizes; PDF producers strip the strikes, so the text
renders noticeably heavier than unhinted. Text fonts sit at 6-9 (Times,
Arial, Liberation, DejaVu, Noto), so the gate never fires for them.

Verified against FreeType: our hinted output for MS Mincho at 14ppem has the
same ink as FreeType's grayscale interpreter (1478 vs 1481, unhinted 1147),
so the weight is inherent to the bytecode, not an interpreter fault; the
field is simply the vendor telling us not to run it there.

A gasp "GRIDFIT without DOGRAY" gate was considered and rejected: Arial and
Liberation flag 9-17ppem that way, so it would switch hinting off for Latin
body text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… F2Dot14

Two mismatches against FreeType's ttinterp.c found while re-checking the
rounding paths for a review:

ODD and EVEN rounded their operand with plain round-to-grid regardless of
the round state. The spec, and FreeType's Ins_ODD/Ins_EVEN, round with the
current round state and then test the parity of the resulting pixel
((v & 127) == 64 / == 0), so under RTHG, RDTG, RUTG, SROUND or ROFF the
result could differ.

SROUND/S45ROUND derived period, phase and threshold directly in F26Dot6.
FreeType derives them in F2Dot14 from the grid period (0x4000 for SROUND,
0x2D41 = sqrt(2)/2 for S45ROUND) and shifts afterwards, which makes the
S45ROUND "period - 1" threshold (0x2D41 - 1) >> 8 = 45 rather than 44.
setSuperRound now takes the F2Dot14 grid period and mirrors SetSuperRound.

The other modes (RTG, RTHG, RTDG, RDTG, RUTG, ROFF, super) were checked
line by line against Round_To_Grid & co. and match, including the negative
distance mirror and the zero clamp; RoundStateTest now pins those, the
grid-value fixed points and the S45 parameters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FreeType's Ins_SHPIX, under TT_SUPPORT_SUBPIXEL_HINTING_MINIMAL with
backward compatibility on, treats a glyph-zone SHPIX like DELTAP: the point
is moved (in y only) if it is already touched in y - or the glyph is a
composite and the freedom vector has a y component - and IUP has not yet
run on both axes; twilight-zone points always move. Ours ran SHPIX through
the plain move, so before IUP it nudged untouched points off their
interpolated positions. FreeType's comment cites older Rokkitt and DTL Argo
glitching without this. doShpix now shares deltaPointAllowed() with DELTAP.

BackwardCompatibilityMoveTest pins the movement rules directly on
ExecutionContext.movePoint (a transcription of FreeType's Direct_Move): flag
off is the plain move; flag on suppresses x but still touches, allows y
until IUP has run on both axes, gates a diagonal freedom vector per axis;
and the new SHPIX behaviour. The golden comparison against FreeType's
grayscale output still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hardening of the interpreter's control flow, prompted by a review; every
change fails toward "glyph renders unhinted" rather than toward a wrong
outline, and each matches FreeType's behaviour:

- A function body now runs bounded by its own ENDF (FunctionDef records
  the end; BytecodeStream.setEnd). A jump that leaves the body is an error,
  as IP > Def->end is in FreeType's JMPR.
- ENDF outside a function body is an error (ENDF_In_Exec_Stream) instead of
  silently ending the program.
- A nested FDEF/IDEF inside a body is an error (Nested_DEFS).
- CINDEX/MINDEX with an out-of-range index consume the index and leave the
  stack alone, as FreeType does outside pedantic mode, instead of throwing
  NegativeArraySizeException. MINDEX is rewritten to read as "pop the k-1
  above, pop the moved one, push back", with no tmp[k-1] arithmetic.

Tests written from FreeType's semantics rather than from this code: MINDEX
on the whole stack for k = 1, 2, 3 and 5 (k=2 is SWAP, k=3 is ROLL); CINDEX;
ROLL's full permutation; forward JMPR, a backward-JROT countdown loop and
JROF both ways; nested IF/ELSE whose skipped branches contain push data
equal to the IF/ELSE/EIF opcode bytes; a body containing ENDF as push data,
a function calling another (return flag must not leak), a recursive
function; and the three new errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…transforms

Tests only. hintingPpem takes the magnitude of the transform's vertical
basis vector, so it is rotation-invariant; a review asked for that to be
shown rather than argued. The new cases use the transform PageDrawer really
hands over (device DPI scale and y flip, composed with the text rendering
matrix and the 1/1000 font matrix): 7pt at 300dpi is 29, 10.5pt at 96dpi is
14, unchanged under 0/30/45/90/180/270/-90 degree rotation; Tz 50% and 200%
leave the ppem alone while a 1.5x vertical stretch changes it; and
fractional device sizes round half-up like a 26.6 size in FreeType
(18.64 -> 19, 12.5 -> 13, 12.49 -> 12, 0.49 -> 0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pcorless
pcorless force-pushed the PDFBOX-3293.truetype.hinting.submit.no-tricky branch from 59b0a8f to b11f250 Compare September 23, 2026 04:36
@pcorless

Copy link
Copy Markdown
Author

OK thanks and I'll start taking a look at those comments.

pcorless and others added 2 commits September 24, 2026 22:05
Fixes from a review of the interpreter against FreeType, then from a
point-by-point comparison on nine fonts (Liberation Sans/Serif, DejaVu
Sans Bold, Arial, Times, Courier New, Georgia, Andale Mono, Comic Sans;
printable ASCII plus accented letters at 9-32 ppem). Every coordinate now
matches FreeType 2.13.2 exactly, and GoldenHintingTest asserts that.

Review findings:
- INSTCTRL follows Ins_INSTCTRL: selectors 1-3 set or clear one bit, only
  in prep (selector 3 also per glyph). GlyphHinter honours the prep flags:
  bit 1 disables hinting, bit 2 starts glyphs from the default graphics
  state, bit 4 waives backward compatibility. ExecutionContext gains a
  CodeRange to tell fpgm, prep and glyph programs apart.
- A prep that throws no longer leaves stale CVT/storage behind a cached
  ppem.
- SPVTL sets the dual vector equal to the projection vector.
- SxVTL takes the top point from zp2 and the next from zp1, with the line
  running from the zp2 point; a zero-length line gives the x axis even
  when perpendicular (and SDPVTL keeps FreeType's dropped-flag quirk).
- MIRP and MSIRP place twilight points from rp0's original position
  (MSIRP via the new moveOriginal); MIAP places them along the freedom
  vector.

Comparison findings:
- ROUND_XY_TO_GRID rounds a component's y offset (v40 leaves x). Accents
  sat up to half a pixel low.
- MDRP measures the original distance in font units and scales it
  (orus), so a 31.5/64 distance rounds to a pixel, not to zero.
- IUP interpolates on font units with FT_DivFix/FT_MulFix and orders the
  references by them; under backward compatibility a third IUP is a no-op.
- A composite's program sees the assembled hinted points as its font
  units, at scale 1.
- Simple glyphs whose lsb differs from xMin are hinted unshifted and
  translated by -pp1.x afterwards, as FreeType does, rather than shifted
  in font units before scaling.
- UnitVector.normalize ports FT_Vector_NormLen; SPVFS/SFVFS sign-extend
  and normalize; moves use FreeType 2.13's F_dot_P with FT_MulDiv.

Tooling: HintedPointsDumpTool, ft_points_dump.py and compare_points.py
compare hinted points against FreeType for any local fonts, optionally
against a baseline build. ft_point_trace.c now traces the grayscale
target by default (it used mono, which disables backward compatibility),
with "mono" as an option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rget

Review follow-ups:

- BytecodeStream.nextByte/nextWord check the function body's end, as seek
  already did. Scanning a body skips push operands, so only a jump into
  the middle of a push's operands could make a push read the ENDF and the
  caller's next byte; reads never left the code array. Such bytecode now
  falls back to the unhinted outline. New test:
  testFunctionBodyCannotReadOperandsPastEndf.
- The README states the reference contract: FreeType 2.13.2 with
  FT_LOAD_NO_AUTOHINT | FT_LOAD_TARGET_NORMAL (v40, grayscale, backward
  compatibility), used by every script. trace_diff.py loaded with
  TARGET_MONO and now uses the grayscale target; the generate_golden.py
  docstring said monochrome while its code used grayscale.
- GoldenHintingTest requires every golden composite to carry instructions
  of its own, so its exact match covers the composite program and not
  only the component programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@THausherr

THausherr commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Copilot writes this:

fontbox/src/main/java/org/apache/fontbox/ttf/Fixed.java:83-85 — ceil() is incorrect for negative non-integral values. The implementation (value + 63) & ~63 only behaves like a ceiling for some negative inputs. For example, ceil(-65) returns -128, but the mathematical ceiling in F26Dot6 should be -64. Since the interpreter exposes this through the TrueType CEILING opcode, fonts using CEILING with negative distances will receive a value one pixel too small, potentially shifting hinted points and causing incorrect outlines. Please implement ceiling explicitly for signed values and add negative-value tests, e.g. ceil(-1) == 0, ceil(-63) == 0, ceil(-65) == -64, and ceil(-128) == -128.

I have no idea if this is realistic, i.e. if such values can occur (I tried with the regression tests and found nothing). Also this part of the code was there before so I wonder why copilot didn't complain until now.

Btw fontbox has not only some more ttf fonts in the repository (I see you did some tests with them), it also loads some ttf fonts during the tests. There's a dejavu font (you mentioned the font in a commit message), plus Keyboard.ttf, NotoEmoji-Regular.ttf, NotoMono-Regular.ttf, ipag.ttf. They are in the directory target/fonts.

…oke test

Review follow-ups:

- The execution budget used FreeType's glyph formula for every program,
  so fpgm/prep (no glyph points) got only 100 LOOPCALL iterations.
  FreeType's TT_RunIns gives them 300 + 22 * cvtSize. Keyboard.ttf's prep
  loops 240 times, so hinting failed for the whole font. Test:
  testPrepLoopCallBudgetIsSizedFromTheControlValues.
- HintingSmokeTest hints every glyph of each TrueType test font (the
  bundled ones and those the build downloads to target/fonts) at 9-48 ppem
  and requires that none fails; it catches the Keyboard.ttf case.
  GlyphHinter counts failures (getFailureCount/getFirstFailure) so a
  failure can be told apart from a glyph that is legitimately not hinted.
- Fixed.ceil is already a true ceiling for negative values ((v + 63) & ~63,
  FreeType's FT_PIX_CEIL_LONG); FixedTest now pins negative floor/ceil.
- Javadoc of GlyphHinter and TrueTypeFont.getHintedPath no longer says
  composite glyphs are not hinted, and names the PDFRenderer switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pcorless

pcorless commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

I've been testing with sample.pdf from the PDFBOX-3293 ticket and it's a good example of "tricky" vs. "No tricky". Render fine when enabled and skips hinting without it. I'll take another look at finding another way to detect these fonts.

@THausherr

THausherr commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Thank you; please change GlyphHinter line 232 from " catch (IOException | RuntimeException e)" to "catch (IOException | HintingException e)", we want to know the hard way if something really nasty happens.

I have looked at the test coverage, there are parts of TrueTypeInterpreter that aren't covered so I ran my visual tests to see if any of the missing parts were hit. Only two were, flipRange and NPUSHW, and only with microsoft fonts, e.g. with this code:

    @Test
    void testArial() throws IOException
    {
        TrueTypeFont ttf = new TTFParser().parse(new RandomAccessReadBufferedFile("c:/windows/fonts/arial.ttf"));
        ttf.getHintedPath(81, 13); // flipRange + NPUSHW
    }

So we can't really test this on the CI, but locally with people who have windows (me). Could you add some code like this that checks that the result is what is expected?

    @Test
    void testArial() throws IOException
    {
        File file = new File("c:/windows/fonts/arial.ttf");
        assumeTrue(file.exists());
        TrueTypeFont ttf = new TTFParser().parse(new RandomAccessReadBufferedFile(file));
        GeneralPath hintedPath = ttf.getHintedPath(81, 13); // hits flipRange + NPUSHW
        // .... todo
    }

(I'm not expecting 100% test coverage, our code is only about 60%, this is just about improving when it's possible)

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