From 385b1ca4127edb1ee8f085f9c1ba5045ce1cd725 Mon Sep 17 00:00:00 2001 From: calixteman Date: Tue, 26 May 2026 21:08:26 +0200 Subject: [PATCH] Clamp out-of-range BlueScale to Adobe's valid window Fonts that ship a BlueScale outside the range AFDKO considers valid for their zone heights (0.5/maxZoneHeight <= BlueScale <= 1/maxZoneHeight) cause Firefox's CFF rasterizer to misalign overshooting glyphs against flat-topped ones at body sizes. Clamp into that window, only apply the lower clamp when BlueScale is also smaller than the default, so foundry fonts that pair the default 0.039625 with small zones are untouched. Fixes #9437. --- src/core/cff_parser.js | 38 ++++++++++++++++ test/unit/cff_parser_spec.js | 88 ++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src/core/cff_parser.js b/src/core/cff_parser.js index ee78174da..42502f5a6 100644 --- a/src/core/cff_parser.js +++ b/src/core/cff_parser.js @@ -30,6 +30,7 @@ import { } from "./charsets.js"; import { ExpertEncoding, StandardEncoding } from "./encodings.js"; import { DataBuilder } from "./data_builder.js"; +import { MathClamp } from "../shared/math_clamp.js"; // Maximum subroutine call depth of type 2 charstrings. Matches OTS. const MAX_SUBR_NESTING = 10; @@ -865,6 +866,43 @@ class CFFParser { // 15289), hence we just reset it to its default value. privateDict.setByName("ExpansionFactor", DEFAULT_EXPANSION_FACTOR); } + if (blueScale > 0) { + // Adobe's font validator (AFDKO, see `absfont.cpp`) flags BlueScale as + // out-of-range when `BlueScale * maxZoneHeight` is below 0.5 or above 1. + // The Type 2 hinting engine in coretype/FreeType disables the lower + // clamp at render time because library fonts with small zones and a + // default BlueScale (0.039625) trip the threshold even though they + // render correctly. To avoid changing those fonts here, only apply + // the lower clamp when BlueScale is also smaller than the default, + // i.e. when the font genuinely deviates from the standard value. + // The upper clamp matches what FreeType already enforces (psblues.c) + // and is safe to apply unconditionally. + let maxZoneHeight = 0; + for (const zones of [ + privateDict.getByName("BlueValues"), + privateDict.getByName("OtherBlues"), + ]) { + if (!zones) { + continue; + } + // BlueValues/OtherBlues are stored as deltas where the odd-indexed + // entries are the heights of each zone. + for (let i = 1; i < zones.length; i += 2) { + if (zones[i] > maxZoneHeight) { + maxZoneHeight = zones[i]; + } + } + } + if (maxZoneHeight > 0) { + const minBlueScale = + blueScale < DEFAULT_BLUE_SCALE ? 0.5 / maxZoneHeight : -Infinity; + const maxBlueScale = 1 / maxZoneHeight; + const clamped = MathClamp(blueScale, minBlueScale, maxBlueScale); + if (clamped !== blueScale) { + privateDict.setByName("BlueScale", clamped); + } + } + } // Parse the Subrs index also since it's relative to the private dict. if (!privateDict.getByName("Subrs")) { diff --git a/test/unit/cff_parser_spec.js b/test/unit/cff_parser_spec.js index dc9555846..36e913fc8 100644 --- a/test/unit/cff_parser_spec.js +++ b/test/unit/cff_parser_spec.js @@ -269,6 +269,94 @@ describe("CFFParser", function () { expect(privateDict.getByName("ExpansionFactor")).toEqual(0.06); }); + it("clamps a too-small BlueScale up to 0.5 / maxZoneHeight", function () { + cff.topDict.privateDict = new CFFPrivateDict(cff.strings); + // Zones (deltas): heights are the odd-indexed entries (all 20 here). + cff.topDict.privateDict.setByName( + "BlueValues", + [-20, 20, 530, 20, 220, 20, 30, 20] + ); + cff.topDict.privateDict.setByName("OtherBlues", [-270, 20]); + cff.topDict.privateDict.setByName("BlueScale", 0.016666999); + cff.topDict.setByName("Private", [0, 0]); + const fontDataWithSmallBlueScale = new CFFCompiler(cff).compile(); + + const reparsedCff = new CFFParser( + new Stream(fontDataWithSmallBlueScale), + {}, + SEAC_ANALYSIS_ENABLED + ).parse(); + + // maxZoneHeight = 20 -> minBlueScale = 0.5 / 20 = 0.025. + expect(reparsedCff.topDict.privateDict.getByName("BlueScale")).toEqual( + 0.025 + ); + }); + + it("clamps a too-large BlueScale down to 1 / maxZoneHeight", function () { + cff.topDict.privateDict = new CFFPrivateDict(cff.strings); + cff.topDict.privateDict.setByName( + "BlueValues", + [-20, 20, 530, 20, 220, 20, 30, 20] + ); + cff.topDict.privateDict.setByName("BlueScale", 0.1); + cff.topDict.setByName("Private", [0, 0]); + const fontDataWithLargeBlueScale = new CFFCompiler(cff).compile(); + + const reparsedCff = new CFFParser( + new Stream(fontDataWithLargeBlueScale), + {}, + SEAC_ANALYSIS_ENABLED + ).parse(); + + // maxZoneHeight = 20 -> maxBlueScale = 1 / 20 = 0.05. + expect(reparsedCff.topDict.privateDict.getByName("BlueScale")).toEqual( + 0.05 + ); + }); + + it("preserves a BlueScale that is already inside the valid range", function () { + cff.topDict.privateDict = new CFFPrivateDict(cff.strings); + cff.topDict.privateDict.setByName( + "BlueValues", + [-20, 20, 530, 20, 220, 20, 30, 20] + ); + cff.topDict.privateDict.setByName("BlueScale", 0.039625); + cff.topDict.setByName("Private", [0, 0]); + const fontDataWithNormalBlueScale = new CFFCompiler(cff).compile(); + + const reparsedCff = new CFFParser( + new Stream(fontDataWithNormalBlueScale), + {}, + SEAC_ANALYSIS_ENABLED + ).parse(); + + expect(reparsedCff.topDict.privateDict.getByName("BlueScale")).toEqual( + 0.039625 + ); + }); + + it("preserves the default BlueScale even when zones are very small", function () { + // Foundry fonts (e.g. Eurostile LT Std Medium, maxZoneHeight 6) ship the + // default BlueScale of 0.039625 together with small zones; that combination + // technically violates AFDKO's lower bound but is the rendered intent. + cff.topDict.privateDict = new CFFPrivateDict(cff.strings); + cff.topDict.privateDict.setByName("BlueValues", [-12, 6, 530, 6]); + cff.topDict.privateDict.setByName("BlueScale", 0.039625); + cff.topDict.setByName("Private", [0, 0]); + const fontDataDefaultBlueScale = new CFFCompiler(cff).compile(); + + const reparsedCff = new CFFParser( + new Stream(fontDataDefaultBlueScale), + {}, + SEAC_ANALYSIS_ENABLED + ).parse(); + + expect(reparsedCff.topDict.privateDict.getByName("BlueScale")).toEqual( + 0.039625 + ); + }); + it("refuses to add topDict key with invalid value (bug 1068432)", function () { const topDict = cff.topDict; const defaultValue = topDict.getByName("UnderlinePosition");