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");