diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index 8d2bcba1..dd5e0cfc 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -1045,6 +1045,7 @@ sections: - PdfDocument_Fonts_Type1_UnrecognizedFontFile3Bytes_ThrowsUnsupportedImageFeatureException - PdfDocument_Fonts_Differences_ThinspaceName_ResolvesAndPaintsEmbeddedGlyph - PdfDocument_Fonts_Differences_Uni03BCName_ResolvesViaEnrichedEmbeddedFontGlyphMap + - PdfDocument_Fonts_Differences_LigatureUnderscoreName_ResolvesViaEnrichedEmbeddedFontGlyphMap - PdfDocument_Load_TextType1CDifferencesAglLigaturesFixture_PaintsVisibleGlyphInk - id: CanvasNetPdf-PdfDocument-Type3FontResolution @@ -1281,8 +1282,13 @@ sections: encodings (throwing UnsupportedImageFeatureException for any other base encoding name), and applying an /Encoding/Differences array's code-then-glyph-name run-length overrides via a standard Adobe-glyph-name-to-Unicode-codepoint mapping - (itself falling back, for a name this mapping does not directly cover, to the Adobe - Glyph List's own generic uniXXXX/uXXXX hex-codepoint naming convention), + (itself falling back, for a name this mapping does not directly cover, first to the + Adobe Glyph List's own generic uniXXXX/uXXXX hex-codepoint naming convention (itself + rejecting a value above 0x10FFFF or within the UTF-16 surrogate range + 0xD800-0xDFFF as unresolved, since neither is a valid Unicode scalar value), then + to the AGL's own underscore ligature-naming convention - two or more + underscore-separated component names, each itself resolved the same way, whose + concatenated characters match one of the mapping's own dedicated ligature names), tolerating an unrecognized glyph name (leaving that code at its base-encoding mapping) but throwing InvalidDataException for /Differences beginning with a glyph name before any starting code. @@ -1297,7 +1303,13 @@ sections: /Differences glyph name is not evidence of document corruption - real-world producers routinely emit producer-specific names this library does not itself recognize - so that one code is left at its base-encoding mapping instead of - rejecting the whole document. + rejecting the whole document. The underscore ligature-naming convention is likewise + required because real-world PDF producers (for example XeTeX/LuaTeX-derived + toolchains) routinely spell a subsetted font's own ligature glyphs as + underscore-joined component names (for example f_i for the "fi" ligature) instead + of the Adobe Glyph List's own dedicated ligature names, and without recognizing + that convention those ligature glyphs render as missing/tofu glyphs even though the + embedded font and a dedicated AGL codepoint for the same ligature both exist. tests: - PdfDocument_Fonts_UnrecognizedEncoding_ThrowsUnsupportedImageFeatureException - PdfDocument_Fonts_DefaultEncoding_IsWinAnsiEncoding @@ -1309,6 +1321,10 @@ sections: - PdfDocument_Fonts_Differences_NacuteName_DoesNotThrow - PdfDocument_Fonts_Differences_ThinspaceName_ResolvesAndPaintsEmbeddedGlyph - PdfDocument_Fonts_Differences_Uni03BCName_ResolvesViaEnrichedEmbeddedFontGlyphMap + - PdfDocument_Fonts_Differences_LigatureUnderscoreName_ResolvesViaEnrichedEmbeddedFontGlyphMap + - PdfDocument_Fonts_Differences_UnrecognizedUnderscoreName_FallsBackToBaseEncoding + - PdfDocument_Fonts_Differences_SurrogateRangeUniHexName_FallsBackToBaseEncoding + - PdfDocument_Fonts_Differences_LigatureComponentSurrogateRange_FallsBackToBaseEncoding - PdfDocument_Fonts_Differences_NameBeforeStartingCode_ThrowsInvalidDataException - PdfDocument_Fonts_StandardEncoding_DiffersFromWinAnsiEncoding - PdfDocument_Fonts_StandardEncoding_Absent_DefaultWinAnsiDoesNotPaintQuoteright diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index 9708db7e..afcdc9b3 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -1236,11 +1236,15 @@ trimmed, genuinely real-world `text-type1c-differences-agl-ligatures.pdf` fixtur `PdfFixtures\README.md`) - two actual embedded, subsetted Type1C fonts (`Gotham-Bold`/ `Gotham-Book`) whose own `/Differences` arrays name glyphs (`/uni03BC`, `/thinspace`) this exact regression previously left unresolved - and asserts visible glyph ink paints; this fixture's -content stream also exercises an `/f_f` ligature-glyph name that remains unresolved by design (an -AGL underscore-ligature decomposition rule, not a `uniXXXX`/`uXXXX` hex name, and explicitly out -of this fix's scope), so this broad fixture test deliberately only asserts "some ink paints -somewhere" rather than per-glyph pixel positions - the two precise synthetic tests above already -isolate the fixed mechanisms exactly. +content stream also genuinely contains an `/f_f` ligature-glyph name (an AGL underscore-ligature +decomposition naming the "ff" ligature glyph) against real body text, which this fix also allows +to resolve via `TryResolveLigatureUnderscoreName` - but this broad fixture test only asserts "some +ink paints somewhere" on the whole page (a page that already painted ink before this fix, from the +unrelated text surrounding the ligature), so it cannot by itself distinguish a resolved `/f_f` +glyph from an unresolved tofu glyph; the dedicated, isolated proof that the mechanism itself works +is `PdfDocument_Fonts_Differences_LigatureUnderscoreName_ResolvesViaEnrichedEmbeddedFontGlyphMap` +above, which pixel-asserts the specific glyph position against a synthetic embedded font built +for exactly that purpose. #### CanvasNetPdf-PdfDocument-Type3FontResolution: Type3 Fonts Resolve Required Fields, Fail Closed on Malformed Ones @@ -1475,6 +1479,10 @@ dogfooding shape for the 12 pre-existing Liberation files. `PdfDocument_Fonts_Differences_UnrecognizedGlyphName_FallsBackToBaseEncoding`, `PdfDocument_Fonts_Differences_FfFfiFflLigatureNames_DoNotThrow`, `PdfDocument_Fonts_Differences_NacuteName_DoesNotThrow`, +`PdfDocument_Fonts_Differences_LigatureUnderscoreName_ResolvesViaEnrichedEmbeddedFontGlyphMap`, +`PdfDocument_Fonts_Differences_UnrecognizedUnderscoreName_FallsBackToBaseEncoding`, +`PdfDocument_Fonts_Differences_SurrogateRangeUniHexName_FallsBackToBaseEncoding`, +`PdfDocument_Fonts_Differences_LigatureComponentSurrogateRange_FallsBackToBaseEncoding`, `PdfDocument_Fonts_Differences_NameBeforeStartingCode_ThrowsInvalidDataException`, `PdfDocument_Fonts_StandardEncoding_DiffersFromWinAnsiEncoding`, `PdfDocument_Fonts_StandardEncoding_Absent_DefaultWinAnsiDoesNotPaintQuoteright` @@ -1510,7 +1518,37 @@ helper `ApplyDifferences` now calls instead of consulting `StandardGlyphNames` d this resolves end to end against an actual embedded font (both `ApplyDifferences`'s own codepoint resolution and the embedded-font loader's own synthetic-cmap-equivalent glyph-name lookup must agree on the same codepoint for a glyph to paint, so those tests exercise the full pipeline, not -only this requirement's own codepoint-resolution half). As of +only this requirement's own codepoint-resolution half). A further theory, +`PdfDocument_Fonts_Differences_LigatureUnderscoreName_ResolvesViaEnrichedEmbeddedFontGlyphMap`, +proves the AGL's own underscore ligature-naming convention (two or more underscore-separated +component glyph names, for example `f_i`, `f_l`, `f_f`, `f_f_i`, `f_f_l` - a real-world spelling +several PDF producers, for example XeTeX/LuaTeX-derived toolchains, use for a subsetted font's own +ligature glyphs, as distinct from `StandardGlyphNames`'s own dedicated `fi`/`fl`/`ff`/`ffi`/`ffl` +names) resolves end to end against an actual embedded Type1C font whose own CFF charset literally +spells its only non-`.notdef` glyph with that exact underscore name: the new +`TryResolveLigatureUnderscoreName` helper resolves each component recursively (so a component may +itself use the `uniXXXX`/`uXXXX` hex convention), concatenates the resolved characters, and looks +the concatenated string up as an AGL glyph name in its own right - `f` + `i` concatenates to `fi`, +which `StandardGlyphNames` already maps to U+FB01. This is the real-world regression a PDF +producer's `/Differences` array naming ligature glyphs this way previously rendered as +missing/tofu glyphs. A companion test, +`PdfDocument_Fonts_Differences_UnrecognizedUnderscoreName_FallsBackToBaseEncoding`, declares an +underscore-joined name whose concatenation (`"foobar"`) is not any recognized AGL ligature name, +asserting it is tolerated identically to any other unrecognized `/Differences` name (falling back +to the base encoding) rather than throwing or fabricating a codepoint. Two further tests prove +`TryParseUppercaseHexDigits` also rejects a value in the UTF-16 surrogate range +(`0xD800`-`0xDFFF`), not only a value above `0x10FFFF`: a lone surrogate is not a valid Unicode +scalar value on its own, and - unlike every other unresolved codepoint, which is merely tolerated + +- would otherwise reach `TryResolveLigatureUnderscoreName`'s `char.ConvertFromUtf32` call and +throw `ArgumentOutOfRangeException` instead of failing closed. +`PdfDocument_Fonts_Differences_SurrogateRangeUniHexName_FallsBackToBaseEncoding` declares +`/uniD800` directly (the generic hex-codepoint path, with no ligature involved), asserting no +exception and a base-encoding fallback exactly like the above-`0x10FFFF` case; +`PdfDocument_Fonts_Differences_LigatureComponentSurrogateRange_FallsBackToBaseEncoding` declares +`/uniD800_i` (a ligature-underscore name whose first component is the surrogate-range value), +asserting the same no-exception fallback - proving the surrogate rejection is load-bearing for +the ligature path specifically, not merely a defensive check that happens to also work there. As of Phase B, a font explicitly declaring `/StandardEncoding` and showing byte code `0x27` (which diverges between the two base encodings - `StandardEncoding` maps it to U+2019 "quoteright", `WinAnsiEncoding` maps it to diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs index 13cc90ad..5094e872 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs @@ -17,6 +17,7 @@ // cspell:ignore ZapfDingbats Dingbats registerserif registersans copyrightserif copyrightsans // cspell:ignore trademarkserif trademarksans radicalex Noto thinspace using System.Globalization; +using System.Text; using DemaConsulting.CanvasNet.Codecs; using DemaConsulting.CanvasNet.Fonts; @@ -551,18 +552,73 @@ private void ApplyDifferences(PdfObject differences, int[] table) => /// first consults (the common-name subset), then - for a /// name that subset does not itself cover - falls back to the AGL's own generic /// uniXXXX/uXXXX hex-codepoint naming convention (four uppercase hex digits - /// after uni, or four-to-six uppercase hex digits after u), per the - /// published Adobe Glyph List specification. Introduced so a /Differences array - /// naming a glyph this way (for example /uni03BC, seen in real-world subsetted - /// fonts whose own CFF charset uses that exact literal spelling) resolves to the correct - /// codepoint instead of being silently tolerated as unrecognized (see - /// 's own remarks for that tolerant fallback). + /// after uni, or four-to-six uppercase hex digits after u), then finally to + /// the AGL's own underscore ligature-naming convention (see + /// ), per the published Adobe Glyph List + /// specification. Introduced so a /Differences array naming a glyph this way (for + /// example /uni03BC, seen in real-world subsetted fonts whose own CFF charset uses + /// that exact literal spelling) resolves to the correct codepoint instead of being + /// silently tolerated as unrecognized (see 's own remarks + /// for that tolerant fallback). /// /// The /Differences array's glyph name to resolve. /// The resolved Unicode codepoint, or 0 when unresolved. /// if resolved to a codepoint. private static bool TryResolveGlyphNameToCodepoint(string name, out int codepoint) => - StandardGlyphNames.TryGetValue(name, out codepoint) || TryParseAdobeGlyphListHexName(name, out codepoint); + StandardGlyphNames.TryGetValue(name, out codepoint) || + TryParseAdobeGlyphListHexName(name, out codepoint) || + TryResolveLigatureUnderscoreName(name, out codepoint); + + /// + /// Resolves an Adobe-Glyph-List "ligature" glyph name - two or more component glyph names + /// joined by underscores (for example f_i, a common real-world subsetted-font + /// spelling of the "fi" ligature glyph, as distinct from the AGL's own dedicated fi + /// name already covered by ) - per the published AGL + /// specification's own ligature-naming convention: each underscore-separated component is + /// itself resolved to a Unicode codepoint (recursively, via + /// , so a component may itself use the + /// uniXXXX/uXXXX hex convention, for example uni0066_uni0069), the + /// resolved characters are concatenated in order, and the resulting string is looked up as + /// an AGL glyph name in its own right - for example f_i concatenates to fi, + /// which already maps to the dedicated "LATIN SMALL + /// LIGATURE FI" codepoint U+FB01. Introduced because real-world PDF producers (for + /// example XeTeX/LuaTeX and several PostScript-derived toolchains) routinely name a + /// subsetted font's ligature glyphs this way rather than with the AGL's own dedicated + /// ligature names, leaving a /Differences array entry naming such a glyph otherwise + /// unresolved (and its text rendered as missing/tofu glyphs) even though + /// already has a perfectly good codepoint for the exact + /// same ligature under its dedicated name. + /// + /// The glyph name to resolve. + /// The resolved Unicode codepoint, or 0 when unresolved. + /// + /// if has at least two non-empty + /// underscore-separated components, every component itself resolves to a codepoint, and + /// the components' concatenated characters match a name in . + /// + private static bool TryResolveLigatureUnderscoreName(string name, out int codepoint) + { + codepoint = 0; + + var components = name.Split('_'); + if (components.Length < 2) + { + return false; + } + + var concatenated = new StringBuilder(); + foreach (var component in components) + { + if (component.Length == 0 || !TryResolveGlyphNameToCodepoint(component, out var componentCodepoint)) + { + return false; + } + + concatenated.Append(char.ConvertFromUtf32(componentCodepoint)); + } + + return StandardGlyphNames.TryGetValue(concatenated.ToString(), out codepoint); + } /// /// Parses the Adobe Glyph List's generic hex-codepoint glyph-name conventions: uniXXXX @@ -604,17 +660,25 @@ private static bool TryParseAdobeGlyphListHexName(string name, out int codepoint /// digit uXXXX/.../uXXXXXX convention can spell a six-digit value as large as /// 0xFFFFFF, which is not a valid AGL codepoint name at all, so such a name must /// stay unresolved (per 's own tolerant handling for - /// unrecognized names) rather than being treated as resolved. + /// unrecognized names) rather than being treated as resolved. Likewise rejects a value in + /// the UTF-16 surrogate range (0xD800-0xDFFF): a lone surrogate value is not + /// a valid Unicode scalar value on its own (surrogates only exist in pairs, as a UTF-16 + /// encoding detail, never as a standalone codepoint), so resolving one here would hand + /// 's char.ConvertFromUtf32 call (and + /// any other codepoint consumer) a value it cannot legally convert, throwing + /// instead of this method's own documented + /// fail-closed "stay unresolved" contract. /// /// The candidate hex digit span to parse. /// /// The parsed value, or 0 when contains any - /// non-uppercase-hex character, or when the parsed value exceeds 0x10FFFF. + /// non-uppercase-hex character, when the parsed value exceeds 0x10FFFF, or when the + /// parsed value falls within the UTF-16 surrogate range (0xD800-0xDFFF). /// /// /// if every character in was an - /// uppercase hex digit and the parsed value is a valid Unicode codepoint (at most - /// 0x10FFFF). + /// uppercase hex digit and the parsed value is a valid Unicode scalar value (at most + /// 0x10FFFF and outside the surrogate range). /// private static bool TryParseUppercaseHexDigits(ReadOnlySpan digits, out int value) { @@ -628,7 +692,7 @@ private static bool TryParseUppercaseHexDigits(ReadOnlySpan digits, out in } if (!int.TryParse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value) || - value > 0x10FFFF) + value > 0x10FFFF || value is >= 0xD800 and <= 0xDFFF) { value = 0; return false; diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 6bdaaf06..2539df3d 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -7591,6 +7591,67 @@ public void PdfDocument_Fonts_Differences_OutOfRangeUHexName_FallsBackToBaseEnco Assert.NotEqual(default, surface[11, 44]); } + /// + /// Proves that a syntactically-valid-looking four-hex-digit uniXXXX glyph name + /// whose value falls within the UTF-16 surrogate range (0xD800-0xDFFF) is + /// rejected rather than resolved: a lone surrogate value is not a valid Unicode scalar + /// value on its own, so TryParseUppercaseHexDigits must reject it (exactly like the + /// above-0x10FFFF case) and leave the affected code at whatever its base encoding + /// already assigned it, rather than propagating an invalid codepoint that would later + /// throw from char.ConvertFromUtf32 if it + /// ever reached TryResolveLigatureUnderscoreName. + /// + [Fact] + public void PdfDocument_Fonts_Differences_SurrogateRangeUniHexName_FallsBackToBaseEncoding() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] " + + "/Encoding << /Differences [65 /uniD800] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and the glyph painted (base encoding's codepoint 65 mapping was + // preserved rather than overwritten by the surrogate-range override). + Assert.NotEqual(default, surface[11, 44]); + } + + /// + /// Proves that a ligature-underscore glyph name whose component resolves to a UTF-16 + /// surrogate-range value (for example uniD800_i, a deliberately pathological name + /// whose first component names a lone surrogate) does not throw + /// : TryResolveLigatureUnderscoreName + /// resolves each component via TryResolveGlyphNameToCodepoint, which (via + /// TryParseUppercaseHexDigits's surrogate-range rejection) now fails closed for that + /// component instead of handing an invalid scalar value to char.ConvertFromUtf32, + /// so the whole ligature name is left unresolved and the affected code falls back to its + /// base encoding - exactly like any other unrecognized /Differences name. + /// + [Fact] + public void PdfDocument_Fonts_Differences_LigatureComponentSurrogateRange_FallsBackToBaseEncoding() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] " + + "/Encoding << /Differences [65 /uniD800_i] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and the glyph painted (base encoding's codepoint 65 mapping was + // preserved rather than overwritten by the unresolved ligature override). + Assert.NotEqual(default, surface[11, 44]); + } + /// /// Proves that each of the AGL generic uXXXX/uXXXXX/uXXXXXX /// hex-codepoint glyph-name lengths (four, five, and six uppercase hex digits) actually @@ -7624,6 +7685,84 @@ public void PdfDocument_Fonts_Differences_ValidUHexName_ResolvesViaEnrichedEmbed Assert.NotEqual(default, surface[11, 44]); } + /// + /// Proves that the AGL underscore ligature-naming convention (f_i, f_l, + /// f_f, f_f_i, f_f_l) - the real-world spelling several PDF producers + /// (for example XeTeX/LuaTeX-derived toolchains) use for a subsetted font's own ligature + /// glyphs, as distinct from the AGL's own dedicated fi/fl/ff/ffi/ + /// ffl names already covered by StandardGlyphNames - resolves end-to-end via + /// TryResolveLigatureUnderscoreName: a /Differences array names code 65 with + /// the given underscore name, and an embedded Type1C font whose own CFF charset literally + /// spells its only non-.notdef glyph with that exact same underscore name, so the + /// glyph paints only if the name's components ("f", "i", and so on) each resolve to a + /// codepoint, their concatenation matches one of StandardGlyphNames's own dedicated + /// ligature names, and BuildEmbeddedFontGlyphNameMap's enrichment then wires the + /// resolved codepoint back to the literal embedded glyph name (mirroring + /// 's + /// own end-to-end pattern). This is the reported real-world regression: a PDF's + /// /Differences array naming ligature glyphs this way previously rendered as + /// missing/tofu glyphs because neither StandardGlyphNames nor the AGL hex + /// convention recognized the underscore-joined name at all. The final case, + /// uni0066_uni0069, instead spells both components using the AGL's own generic + /// uniXXXX hex-codepoint convention (rather than a literal single-letter name), + /// proving TryResolveLigatureUnderscoreName's per-component recursion into + /// TryResolveGlyphNameToCodepoint - not just a direct StandardGlyphNames + /// lookup - correctly resolves each component before concatenation. + /// + [Theory] + [InlineData("f_i")] // "f" + "i" -> "fi" -> U+FB01 LATIN SMALL LIGATURE FI. + [InlineData("f_l")] // "f" + "l" -> "fl" -> U+FB02 LATIN SMALL LIGATURE FL. + [InlineData("f_f")] // "f" + "f" -> "ff" -> U+FB00 LATIN SMALL LIGATURE FF. + [InlineData("f_f_i")] // "f" + "f" + "i" -> "ffi" -> U+FB03 LATIN SMALL LIGATURE FFI. + [InlineData("f_f_l")] // "f" + "f" + "l" -> "ffl" -> U+FB04 LATIN SMALL LIGATURE FFL. + [InlineData("uni0066_uni0069")] // AGL-hex components "uni0066" + "uni0069" -> "f" + "i" -> "fi" -> U+FB01. + public void PdfDocument_Fonts_Differences_LigatureUnderscoreName_ResolvesViaEnrichedEmbeddedFontGlyphMap( + string glyphName) + { + // Arrange + var (resourcesBody, extraObjects) = BuildEmbeddedType1CFontResources( + fontDictExtra: "/FirstChar 65 /LastChar 65 /Widths [600] " + + $"/Encoding << /Differences [65 /{glyphName}] >>", + customGlyphName: glyphName); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: the embedded font's square glyph painted at the expected text-space location. + Assert.NotEqual(default, surface[11, 44]); + } + + /// + /// Proves that a glyph name with underscore-separated components that do not themselves + /// resolve to a recognized ligature (for example "foo_bar", whose concatenation + /// "foobar" is not any AGL ligature name) is tolerated exactly like any other unrecognized + /// /Differences name - left at whatever its base encoding already assigned it - + /// rather than throwing or otherwise failing: TryResolveLigatureUnderscoreName must + /// fail closed (return ) rather than guess or fabricate a codepoint + /// when the concatenated candidate name is not in StandardGlyphNames. + /// + [Fact] + public void PdfDocument_Fonts_Differences_UnrecognizedUnderscoreName_FallsBackToBaseEncoding() + { + // Arrange + var fontBytes = BuildEmbeddedFontBytes([(65, 1)]); + var (resourcesBody, extraObjects) = BuildSimpleTrueTypeFontResources( + fontBytes, + fontDictExtra: "/FirstChar 65 /LastChar 66 /Widths [600 600] " + + "/Encoding << /Differences [65 /foo_bar] >>"); + + var bytes = BuildSinglePagePdfWithResources(100, 100, "BT /F1 20 Tf 5 50 Td (A) Tj ET", resourcesBody, extraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: no exception, and the glyph painted (base encoding's codepoint 65 mapping was + // preserved rather than cleared by the unrecognized override). + Assert.NotEqual(default, surface[11, 44]); + } + /// /// Proves that /Differences recognizes the ff/ffi/ffl Latin /// ligature glyph names (found via a real-world pdfLaTeX Computer Modern font from diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md index 9c6a2345..cc357ee3 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtures/README.md @@ -143,7 +143,14 @@ Glyph List's generic `uniXXXX` hex-codepoint naming convention. Code 27 `/thinsp now a direct `StandardGlyphNames` entry, not a missing-vocabulary case. Its content stream genuinely exercises both codes against real body text (`"...50μL..."`, `"...+1 % compared..."`). -> **Note**: this fixture's content stream also exercises a `/f_f` ligature-glyph name (an -> underscore-joined AGL ligature decomposition, not a `uniXXXX`/`uXXXX` hex name) that remains -> unresolved by design - see `PdfDocumentTests`'s own remarks on this fixture's test for why that -> is a deliberate, out-of-scope limitation, not a regression. +> **Note**: this fixture's content stream also genuinely contains a `/f_f` ligature-glyph name +> (an underscore-joined AGL ligature decomposition, naming the "ff" ligature glyph) against real +> body text - previously left unresolved by design, this name now resolves via +> `TryResolveLigatureUnderscoreName`'s underscore-decomposition fallback. However, this fixture's +> own test only asserts that some pixel paints somewhere on the page - a page that already +> painted ink before this fix, from the unrelated text surrounding the ligature - so it cannot by +> itself distinguish a resolved `/f_f` glyph from an unresolved tofu glyph. The isolated proof +> that the mechanism itself works is `PdfDocumentTests`'s own dedicated synthetic regression +> tests, which pixel-assert the specific glyph position against a synthetic embedded font built +> for exactly that purpose; this fixture merely shows the real-world name occurs and the document +> still opens and renders without error.