Skip to content

[font] Saturate synthetic bold and glyph origin arithmetic - #6585

Merged
behdad merged 3 commits into
harfbuzz:mainfrom
b84330808:font-saturate-synthetic-arithmetic
Nov 9, 2026
Merged

behdad merged 3 commits into
harfbuzz:mainfrom
b84330808:font-saturate-synthetic-arithmetic

Conversation

@b84330808

Copy link
Copy Markdown
Contributor

After #6154 and #6296, a number of signed operations in hb_font_t's synthetic embolden and slant paths could still overflow:

  • the embolden strength: roundf (abs (scale) * embolden) converted an unchecked float to int, and the result is later negated, which is undefined for INT32_MIN;
  • the extents adjustments (ascender/descender) in the horizontal and vertical extents getters;
  • glyph advances, single and multi;
  • glyph origins, single and multi, and the contour-point getter;
  • add_offset / subtract_offset, and the variation-offset path that multiplied by ±1 (INT32_MIN * -1);
  • the origin fallback arithmetic;
  • em_multf, which rounded a float product straight into hb_position_t.

All of these now go through hb_saturate_add / hb_saturate_sub, and float results are clamped with hb_clamp_to first. The strength computation clamps to int32 and stays above INT32_MIN so the later negation is safe. Results that do not overflow are unchanged. Four new tests in test/api/test-font.c cover the strength, extents, advances and origins at extreme scales and embolden values.

What I ran (Linux x86-64, gcc, meson 1.12.1):

  • meson setup build — glib, freetype and cairo found; introspection off
  • meson test -C build — 270 of 277 tests OK, 0 failures, 7 skipped (all subset+repack; the repacker dependency is not available in this environment). test-font passes with 21 subtests. The shape suites pass in this run: in-house 6346 subtests, aots 541, text-rendering 874.

What I did not run: a UBSan build or the fail-before comparison myself — the commit message records the author's UBSan runs, where the new tests fail on the base commit at hb-font.hh:365, :848, :1154 and :1167 and pass with this change. I also did not run the gobject/introspection tests (disabled in this build configuration), MSVC or arm-none-eabi builds, subset tests beyond what the default suite above includes, or any performance measurements.

harfbuzz#6154 made hb_position_t arithmetic saturate and harfbuzz#6296 did the same
for synthetic_glyph_extents(), but the rest of hb_font_t still used
plain signed arithmetic:

- changed() computed the embolden strength with abs (x_scale), which
  overflows for INT32_MIN, and converted the float product to int
  without a range check. A strength of INT32_MIN could also not be
  negated later on, for negative scales.
- get_font_[hv]_extents(), the advance, origin and contour point
  getters added the strength with += and could overflow.
- add_offset(), subtract_offset() and apply_offset() overflow when
  they apply a large origin, and so do the origin fallbacks in
  apply_glyph_[hv]_origins_with_fallback().
- em_multf() converted its float result to hb_position_t without a
  range check, which is undefined for a value such as an ascender
  above the em at a scale close to INT32_MAX.

Use hb_saturate_add(), hb_saturate_sub() and hb_clamp_to() in these
places, and keep the strength away from INT32_MIN so that negating it
stays defined. Results that fit do not change.

The shift variables that were int are now hb_position_t, since the
saturating helpers deduce one type and int32_t is not int everywhere.

Tested with test-font under UBSan: the new tests fail on the base
commit at hb-font.hh:365, :848, :1154 and :1167, and pass here.

Assisted-by: Claude
@b84330808
b84330808 requested a review from a team November 5, 2026 08:49

@ideal-song ideal-song left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the library change and the tests.

The saturation fixes are correct, and the extreme-strength test and the real-font metrics test look good too.

I left just two non-blocking questions as line comments.

Comment thread src/hb-font.hh
hb_position_t em_mult (int32_t v, int64_t mult)
{ return (hb_position_t) ((v * mult + 32768) >> 16); }
hb_position_t em_multf (float v, float mult)
{ return (hb_position_t) roundf (em_fmultf (v, mult)); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good that the strength can never be INT32_MIN. Negating it later is safe.

Comment thread test/api/test-font.c

/* The strength does not fit in 32 bits, in either direction. */
hb_font_set_scale (font, 2048, 2048);
hb_font_set_synthetic_bold (font, 1e30f, 1e30f, FALSE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the extreme strength in both directions is good.
Checking the INT32_MIN scale too is good.

Comment thread src/hb-font.hh
{ return (hb_position_t) ((v * mult + 32768) >> 16); }
hb_position_t em_multf (float v, float mult)
{ return (hb_position_t) roundf (em_fmultf (v, mult)); }
{ return hb_clamp_to<hb_position_t> (roundf (em_fmultf (v, mult))); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clamping the float conversion for metrics is good too.
It's also good that a real font is used to check it.

Comment thread test/api/test-font.c
g_assert_cmpint (a, ==, INT32_MAX);

hb_position_t x = 0, y = 0;
g_assert_true (hb_font_get_glyph_h_origin (font, 1, &x, &y));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking question:
Checking the plural origins too seems good to me — what do you think?

The test currently checks only the singular origins. Removing the saturation in the plural versions would still pass. Calling the plural functions once each and checking for INT32_MAX would cover it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, added in 723ea77. test_synthetic_bold_saturates now also calls hb_font_get_glyph_h_origins and hb_font_get_glyph_v_origins once each and checks both coordinates for INT32_MAX.

I also verified the new assertions actually cover the plural code by mutation: replacing the plural hb_saturate_add calls with plain addition makes the test fail (the value wraps to a negative). Full meson test -C build after the change: 270 ok, 0 fail, 7 skipped, same as before.

Comment thread src/hb-font.hh Outdated
/* Slant */
if (slant_xy)
*x += roundf (*y * slant_xy);
*x = hb_saturate_add (*x, hb_clamp_to<hb_position_t> (roundf (*y * slant_xy)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking question
Adding a slant case to the test seems good to me — what do you think?

The test never sets a slant, so removing the clamp on this line would still pass. Setting a large slant would check whether x saturates.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, added in 723ea77: a new test_synthetic_slant_saturates sets a slant of 1 on a font whose contour point is INT32_MAX - 1 in both axes, and checks that x saturates to INT32_MAX.

Mutation-checked too: dropping the hb_clamp_to on this line makes the new test fail (x comes out as -2 instead of INT32_MAX).

Review feedback: the plural origin getters and the slant
clamp in get_glyph_contour_point were not covered by the
tests, so removing their saturation would still pass.

test_synthetic_bold_saturates now also calls
hb_font_get_glyph_h_origins and hb_font_get_glyph_v_origins
once each and checks both coordinates for INT32_MAX. A new
test_synthetic_slant_saturates sets a slant of 1 on a contour
point one short of INT32_MAX in both axes and checks that x
saturates to INT32_MAX.

Verified by mutation: replacing the plural hb_saturate_add
calls with plain addition makes the first test fail (the
value wraps), and dropping the hb_clamp_to in the slant path
makes the second fail (x comes out as -2). Full
meson test -C build: 270 ok, 0 fail, 7 skipped
(subset+repack, missing repacker dependency; same as the
baseline run for this PR).

Assisted-by: Claude
@ideal-song

Copy link
Copy Markdown

Thanks, both questions are now covered: the plural origins check both coordinates, and the new slant test pins x to INT32_MAX. The mutation check makes this convincing. No further questions from me.

@behdad behdad left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correctness regression in the contour-point slant calculation; reproduced through the public API against the base and PR head.

Comment thread src/hb-font.hh Outdated
/* Slant */
if (slant_xy)
*x += roundf (*y * slant_xy);
*x = hb_saturate_add (*x, hb_clamp_to<hb_position_t> (roundf (*y * slant_xy)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Clamp the final slanted coordinate instead of the displacement

Clamping the displacement before adding *x changes valid results when the two values cancel. With equal x/y scales, a contour-point callback returning x = -2000000000, y = 2000000000, and hb_font_set_synthetic_slant(font, 2.f), hb_font_get_glyph_contour_point() returns x = 2000000000 on the base, but 147483647 here. The original floating-point sum fits in hb_position_t; the new code first truncates the displacement of 4000000000 to INT32_MAX.

Please clamp after the floating-point addition, for example:

*x = hb_clamp_to<hb_position_t> (*x + roundf (*y * slant_xy));

This preserves the previous calculation for representable results while making the final conversion safe. Please add the cancellation case alongside the existing slant saturation test.

Reproduced on macOS ARM64 with base fcebeeba6 and head 723ea77bc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 872872f: the slant shift is now added to *x first and the sum clamped with hb_clamp_to, as suggested.

Added test_synthetic_slant_cancelling next to test_synthetic_slant_saturates with the case from your report (x = -2000000000, y = 2000000000, slant 2, equal scales), asserting x == 2000000000. Mutation-checked: restoring the clamp-first order makes the new test fail with 147483647, the value from your report. The existing saturation case still passes, and the full suite matches the baseline: meson test -C build: 270 ok, 0 fail, 7 skipped.

Leaving the thread open for you to resolve.

get_glyph_contour_point clamped the slant shift to
hb_position_t before adding it to x. When x and the shift
have opposite signs and partly cancel, clamping first
changes the result: a contour point at x = -2000000000,
y = 2000000000 with a slant of 2 should land at
x = 2000000000, but came out as 147483647, because the
shift was clamped to INT32_MAX before the addition.

Add the shift first and clamp the sum instead. The
existing saturation case is unchanged: hb_clamp_to
widens to double to do the clamp, so an out-of-range sum
still saturates instead of hitting an undefined
float-to-int conversion.

The new test_synthetic_slant_cancelling covers the
cancelling case. Verified by mutation: restoring the
clamp-first order makes it fail with 147483647, the
reported value. Full meson test -C build: 270 ok,
0 fail, 7 skipped, same as baseline.

Assisted-by: Claude
@behdad
behdad merged commit 24ded43 into harfbuzz:main Nov 9, 2026
28 checks passed
behdad pushed a commit that referenced this pull request Nov 14, 2026
Without a vmtx table, hb_ot_get_glyph_v_advances() and
hb_ot_get_glyph_v_origins() derive the vertical advance from the
horizontal font extents as descender - ascender (resp. ascender -
descender) in plain int. Each extent is clamped to hb_position_t on
its own, but MVAR 'hasc'/'hdsc' deltas are 32-bit, so a variable
font can move the ascender to INT32_MAX and the descender to
INT32_MIN at the default scale, and the subtraction overflows
(UBSan signed-integer-overflow at hb-ot-font.cc:524 and :777).
Reached from vertical shaping and hb_font_get_glyph_v_advance() /
hb_font_get_glyph_v_origin().

#6585 saturated the corresponding hb_font_t arithmetic; use
hb_saturate_sub() for these two as well. Results that fit do not
change.

Add an 824-byte generated font (no vmtx, MVAR with extreme
hasc/hdsc deltas) and a test in test/api/test-font.c that hits both
paths.

Testing: meson test -C build (103 ok); an ASan+UBSan build
(experimental_api, glib disabled) passes its suite, and the new test
body run standalone against it fails before the fix and passes
after.

Assisted-by: Claude
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.

3 participants