diff --git a/README.md b/README.md index e5b9d52..beb9ff2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ -# SafeStbTrueTypeSharp -Safe version of StbTrueTypeSharp 1.24 +# ArtificialNecessity.Fonts + +**A C# Memory-Safe Font Engine — TTF, OTF, CPU Glyph Raster + simplified Kerning, Glyph-Shaping, and Advance.** + +This library descends from [SafeStbTrueTypeSharp](https://github.com/rds1983/SafeStbTrueTypeSharp) (rds1983's fully-safe C# port of stb_truetype 1.24). It contains zero `unsafe` code — all font data access goes through bounds-checked `FakePtr` views over managed arrays. + +## License + +MIT / Public-Domain, following stb_truetype and SafeStbTrueTypeSharp upstream. + +## What we changed since the fork + +Everything after upstream commit `2f76ecb` (2020) is ours: + +| Area | Summary | +|---|---| +| **OTF/CFF fixes** | Corrected CFF/CharString parsing bugs and multi-component (composite) glyph handling; added OTF test fonts. | +| **Full GPOS pair kerning** | Replaced stb_truetype's minimal GPOS stub with a proper pair-kerning kernel: script/feature selection, Extension lookups, full ValueRecord layout, class-0 handling. Validated against a numeric HarfBuzz oracle (`tests/GposKerning.Tests`). | +| **Y-only grid-fit raster** | `stbtt_YPixelGridFitRemap` entry points: a monotonic y-remap applied during rasterization for crisp horizontal stems (see SilkyNvg `plans/crisp_text_yonly_gridfit_hinting.md`) without touching x-spacing. | +| **Synthetic styles** | Outline-preprocessing synthesis: fake-bold via stroke-union embolden, oblique shear, and horizontal scaling (`FontInfoSynth.cs`). | +| **Hygiene** | Removed the dead upstream MonoGame sample; restructured `tests/` into per-project subdirectories. | + +## Scope of our attentions + +We care about **CPU glyph rasterization and simplified horizontal text layout** for UI-grade Latin-script text: glyph loading (TrueType `glyf` and OTF/CFF outlines), advance widths, pair kerning, and high-quality anti-aliased raster output. We are deliberately **not** a full text-shaping engine (no HarfBuzz-style contextual shaping, ligature substitution/GSUB, vertical layout, or complex-script support). + +## The GPOS kernel: capabilities and limitations + +`stbtt_GetGlyphKernAdvance` makes a **font-level** decision (never mixed per-pair): if the font's GPOS carries a usable `kern` feature it is used; otherwise we fall back to the legacy `kern` table (this also covers fonts whose GPOS exists but is mark/mkmk-only). + +### Capabilities + +- **Script/feature selection**: prefers the `latn` script, falls back to `DFLT`, uses the default LangSys. Marks the lookups of *every* `kern` feature in the LangSys, applies them in LookupList index order (OpenType application order), and **accumulates adjustments across lookups**. Resolution is cached once per font. +- **Lookup types**: PairAdjustment (type 2), including type 9 Extension-wrapped subtables (32-bit offsets, as emitted by large fonts). +- **Both PairPos formats**: format 1 (per-glyph pair lists, binary-searched) and format 2 (class matrices). +- **Full ValueRecord layout**: record size computed from `popcount(valueFormat)`, including the device-table offset bits (0x0010–0x0080) that contribute to record *size*; `xAdvance` located correctly after XPlacement/YPlacement. +- **HarfBuzz “applied” semantics**: within a lookup the *first subtable that applies* wins. Format 1 applies only when a pair record is found; format 2 applies whenever coverage hits and both classes resolve — *even when the adjustment is zero* (the class-0 fix). A coverage hit without a pair record correctly falls through to the next subtable. +- **Oracle-tested**: `tests/GposKerning.Tests` compares our numeric results against HarfBuzz output for real fonts. + +### Limitations (deliberate) + +- **xAdvance of glyph 1 only.** We return a single horizontal advance adjustment; YAdvance, placements, and the second glyph's ValueRecord are ignored. +- **Pair kerning only.** No mark attachment (types 4–6), cursive attachment (type 3), single adjustment (type 1), or contextual positioning (types 7–8). +- **No device tables / variations.** Device-table and VariationIndex adjustments are skipped (their offsets are only counted for record sizing); values are unscaled font units. +- **LookupFlags ignored.** For base-glyph pair kerning the only flag observed in target fonts is `IgnoreMarks` (0x0008), which is a no-op when neither glyph is a mark. +- **Latin-centric**: only `latn`/`DFLT` with the default LangSys; no per-language systems, no complex scripts. \ No newline at end of file diff --git a/SafeStbTrueTypeSharp.sln b/SafeStbTrueTypeSharp.sln index d265c52..8d4702a 100644 --- a/SafeStbTrueTypeSharp.sln +++ b/SafeStbTrueTypeSharp.sln @@ -1,14 +1,10 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.28010.2036 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{14A355A8-A827-4D09-B3A4-4C7A0EF983EC}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StbSharp", "StbSharp", "{314ACA14-EC1F-4ECF-B902-A3D79850A1B2}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StbTrueTypeSharp.MonoGame.Test", "samples\StbTrueTypeSharp.MonoGame.Test\StbTrueTypeSharp.MonoGame.Test.csproj", "{5528B473-7EEA-43EF-8DE2-6338C5F157D2}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeStbTrueTypeSharp", "src\SafeStbTrueTypeSharp.csproj", "{B185B747-2A52-496F-83A3-94E3814963D9}" EndProject Global @@ -17,10 +13,6 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5528B473-7EEA-43EF-8DE2-6338C5F157D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5528B473-7EEA-43EF-8DE2-6338C5F157D2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5528B473-7EEA-43EF-8DE2-6338C5F157D2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5528B473-7EEA-43EF-8DE2-6338C5F157D2}.Release|Any CPU.Build.0 = Release|Any CPU {B185B747-2A52-496F-83A3-94E3814963D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B185B747-2A52-496F-83A3-94E3814963D9}.Debug|Any CPU.Build.0 = Debug|Any CPU {B185B747-2A52-496F-83A3-94E3814963D9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -29,9 +21,6 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {5528B473-7EEA-43EF-8DE2-6338C5F157D2} = {14A355A8-A827-4D09-B3A4-4C7A0EF983EC} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C9C812E7-F89A-4B6E-8F9D-981D5F8BBAB3} EndGlobalSection diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/CharacterRange.cs b/samples/StbTrueTypeSharp.MonoGame.Test/CharacterRange.cs deleted file mode 100644 index 99154cd..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/CharacterRange.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace StbSharp.MonoGame.Test -{ - public struct CharacterRange - { - public static readonly CharacterRange BasicLatin = new CharacterRange(0x0020, 0x007F); - public static readonly CharacterRange Latin1Supplement = new CharacterRange(0x00A0, 0x00FF); - public static readonly CharacterRange LatinExtendedA = new CharacterRange(0x0100, 0x017F); - public static readonly CharacterRange LatinExtendedB = new CharacterRange(0x0180, 0x024F); - public static readonly CharacterRange Cyrillic = new CharacterRange(0x0400, 0x04FF); - public static readonly CharacterRange CyrillicSupplement = new CharacterRange(0x0500, 0x052F); - public static readonly CharacterRange Hiragana = new CharacterRange(0x3040, 0x309F); - public static readonly CharacterRange Katakana = new CharacterRange(0x30A0, 0x30FF); - public static readonly CharacterRange Greek = new CharacterRange(0x0370, 0x03FF); - public static readonly CharacterRange CjkSymbolsAndPunctuation = new CharacterRange(0x3000, 0x303F); - public static readonly CharacterRange CjkUnifiedIdeographs = new CharacterRange(0x4e00, 0x9fff); - public static readonly CharacterRange HangulCompatibilityJamo = new CharacterRange(0x3130, 0x318f); - public static readonly CharacterRange HangulSyllables = new CharacterRange(0xac00, 0xd7af); - - public int Start - { - get; - } - - public int End - { - get; - } - - public int Size - { - get - { - return End - Start + 1; - } - } - - public CharacterRange(int start, int end) - { - Start = start; - End = end; - } - - public CharacterRange(int single) : this(single, single) - { - } - } -} \ No newline at end of file diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/FontBaker.cs b/samples/StbTrueTypeSharp.MonoGame.Test/FontBaker.cs deleted file mode 100644 index 98735e1..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/FontBaker.cs +++ /dev/null @@ -1,94 +0,0 @@ -using StbTrueTypeSharp; -using System; -using System.Collections.Generic; -using System.Linq; -using static StbTrueTypeSharp.PackContext; - -namespace StbSharp.MonoGame.Test -{ - public class FontBaker - { - private byte[] _bitmap; - private PackContext _context; - private Dictionary _glyphs; - private int bitmapWidth, bitmapHeight; - - public void Begin(int width, int height) - { - bitmapWidth = width; - bitmapHeight = height; - _bitmap = new byte[width * height]; - _context = new PackContext(); - - _context.stbtt_PackBegin(_bitmap, width, height, width, 1); - - _glyphs = new Dictionary(); - } - - public void Add(byte[] ttf, float fontPixelHeight, - IEnumerable characterRanges) - { - if (ttf == null || ttf.Length == 0) - throw new ArgumentNullException(nameof(ttf)); - - if (fontPixelHeight <= 0) - throw new ArgumentOutOfRangeException(nameof(fontPixelHeight)); - - if (characterRanges == null) - throw new ArgumentNullException(nameof(characterRanges)); - - if (!characterRanges.Any()) - throw new ArgumentException("characterRanges must have a least one value."); - - var fontInfo = new FontInfo(); - if (fontInfo.stbtt_InitFont(ttf, 0) == 0) - throw new Exception("Failed to init font."); - - var scaleFactor = fontInfo.stbtt_ScaleForPixelHeight(fontPixelHeight); - - int ascent, descent, lineGap; - fontInfo.stbtt_GetFontVMetrics(out ascent, out descent, out lineGap); - - foreach (var range in characterRanges) - { - if (range.Start > range.End) - continue; - - var cd = new stbtt_packedchar[range.End - range.Start + 1]; - for (var i = 0; i < cd.Length; ++i) - { - cd[i] = new stbtt_packedchar(); - } - - _context.stbtt_PackFontRange(ttf, 0, fontPixelHeight, - range.Start, - range.End - range.Start + 1, - cd); - - for (var i = 0; i < cd.Length; ++i) - { - var yOff = cd[i].yoff; - yOff += ascent * scaleFactor; - - var glyphInfo = new GlyphInfo - { - X = cd[i].x0, - Y = cd[i].y0, - Width = cd[i].x1 - cd[i].x0, - Height = cd[i].y1 - cd[i].y0, - XOffset = (int)cd[i].xoff, - YOffset = (int)Math.Round(yOff), - XAdvance = (int)Math.Round(cd[i].xadvance) - }; - - _glyphs[i + range.Start] = glyphInfo; - } - } - } - - public FontBakerResult End() - { - return new FontBakerResult(_glyphs, _bitmap, bitmapWidth, bitmapHeight); - } - } -} \ No newline at end of file diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/FontBakerResult.cs b/samples/StbTrueTypeSharp.MonoGame.Test/FontBakerResult.cs deleted file mode 100644 index 3d725aa..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/FontBakerResult.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace StbSharp.MonoGame.Test -{ - public class FontBakerResult - { - public FontBakerResult(Dictionary glyphs, byte[] bitmap, int width, int height) - { - if (glyphs == null) - throw new ArgumentNullException(nameof(glyphs)); - - if (bitmap == null) - throw new ArgumentNullException(nameof(bitmap)); - - if (width <= 0) - throw new ArgumentOutOfRangeException(nameof(width)); - - if (height <= 0) - throw new ArgumentOutOfRangeException(nameof(height)); - - if (bitmap.Length < width * height) - throw new ArgumentException("pixels.Length should be higher than width * height"); - - Glyphs = glyphs; - Bitmap = bitmap; - Width = width; - Height = height; - } - - public Dictionary Glyphs - { - get; - } - - public byte[] Bitmap - { - get; - } - - public int Width - { - get; - } - - public int Height - { - get; - } - } -} \ No newline at end of file diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/DroidSans.ttf b/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/DroidSans.ttf deleted file mode 100644 index 2537cc3..0000000 Binary files a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/DroidSans.ttf and /dev/null differ diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/DroidSansJapanese.ttf b/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/DroidSansJapanese.ttf deleted file mode 100644 index ca79221..0000000 Binary files a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/DroidSansJapanese.ttf and /dev/null differ diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/KoPubBatang-Regular.ttf b/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/KoPubBatang-Regular.ttf deleted file mode 100644 index c212794..0000000 Binary files a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/KoPubBatang-Regular.ttf and /dev/null differ diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/ZCOOLXiaoWei-Regular.ttf b/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/ZCOOLXiaoWei-Regular.ttf deleted file mode 100644 index 2d8731a..0000000 Binary files a/samples/StbTrueTypeSharp.MonoGame.Test/Fonts/ZCOOLXiaoWei-Regular.ttf and /dev/null differ diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Game1.cs b/samples/StbTrueTypeSharp.MonoGame.Test/Game1.cs deleted file mode 100644 index deadfe7..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/Game1.cs +++ /dev/null @@ -1,215 +0,0 @@ -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using Microsoft.Xna.Framework.Input; -using StbTrueTypeSharp; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; - -namespace StbSharp.MonoGame.Test -{ - /// - /// This is the main type for your game. - /// - public class Game1 : Game - { - private const int FontBitmapWidth = 8192; - private const int FontBitmapHeight = 8192; - - GraphicsDeviceManager _graphics; - SpriteBatch _spriteBatch; - - private SpriteFont _font; - - public Game1() - { - _graphics = new GraphicsDeviceManager(this) - { - PreferredBackBufferWidth = 1920, - PreferredBackBufferHeight = 1080, - SynchronizeWithVerticalRetrace = false - }; - IsFixedTimeStep = false; - - Content.RootDirectory = "Content"; - IsMouseVisible = true; - Window.AllowUserResizing = true; - } - - private void LoadFont() - { - var fontBaker = new FontBaker(); - - fontBaker.Begin(FontBitmapWidth, FontBitmapHeight); - fontBaker.Add(File.ReadAllBytes("Fonts/DroidSans.ttf"), 32, new[] - { - CharacterRange.BasicLatin, - CharacterRange.Latin1Supplement, - CharacterRange.LatinExtendedA, - CharacterRange.Cyrillic, - CharacterRange.Greek, - }); - - fontBaker.Add(File.ReadAllBytes("Fonts/DroidSansJapanese.ttf"), 32, new[] - { - CharacterRange.Hiragana, - CharacterRange.Katakana, - }); - - fontBaker.Add(File.ReadAllBytes("Fonts/ZCOOLXiaoWei-Regular.ttf"), 32, new[] - { - CharacterRange.CjkSymbolsAndPunctuation, - CharacterRange.CjkUnifiedIdeographs - }); - - fontBaker.Add(File.ReadAllBytes("Fonts/KoPubBatang-Regular.ttf"), 32, new[] - { - CharacterRange.HangulCompatibilityJamo, - CharacterRange.HangulSyllables - }); - - var _charData = fontBaker.End(); - - // Offset by minimal offset - int minimumOffsetY = 10000; - foreach (var pair in _charData.Glyphs) - { - if (pair.Value.YOffset < minimumOffsetY) - { - minimumOffsetY = pair.Value.YOffset; - } - } - - var keys = _charData.Glyphs.Keys.ToArray(); - foreach (var key in keys) - { - var pc = _charData.Glyphs[key]; - pc.YOffset -= minimumOffsetY; - _charData.Glyphs[key] = pc; - } - - var rgb = new Color[FontBitmapWidth * FontBitmapHeight]; - for (var i = 0; i < _charData.Bitmap.Length; ++i) - { - var b = _charData.Bitmap[i]; - rgb[i].R = b; - rgb[i].G = b; - rgb[i].B = b; - - rgb[i].A = b; - } - - var fontTexture = new Texture2D(GraphicsDevice, FontBitmapWidth, FontBitmapHeight); - fontTexture.SetData(rgb); - - var glyphBounds = new List(); - var cropping = new List(); - var chars = new List(); - var kerning = new List(); - - var orderedKeys = _charData.Glyphs.Keys.OrderBy(a => a); - foreach (var key in orderedKeys) - { - var character = _charData.Glyphs[key]; - - var bounds = new Rectangle(character.X, character.Y, - character.Width, - character.Height); - - glyphBounds.Add(bounds); - cropping.Add(new Rectangle(character.XOffset, character.YOffset, bounds.Width, bounds.Height)); - - chars.Add((char)key); - - kerning.Add(new Vector3(0, bounds.Width, character.XAdvance - bounds.Width)); - } - - var constructorInfo = typeof(SpriteFont).GetTypeInfo().DeclaredConstructors.First(); - _font = (SpriteFont)constructorInfo.Invoke(new object[] - { - fontTexture, glyphBounds, cropping, - chars, 20, 0, kerning, ' ' - }); - } - - /// - /// LoadContent will be called once per game and is the place to load - /// all of your content. - /// - protected override void LoadContent() - { - // Create a new SpriteBatch, which can be used to draw textures. - _spriteBatch = new SpriteBatch(GraphicsDevice); - - // TODO: use this.Content to load your game content here - // Create white texture - - // Load ttf - LoadFont(); - - GC.Collect(); - } - - /// - /// UnloadContent will be called once per game and is the place to unload - /// game-specific content. - /// - protected override void UnloadContent() - { - // TODO: Unload any non ContentManager content here - } - - /// - /// Allows the game to run logic such as updating the world, - /// checking for collisions, gathering input, and playing audio. - /// - /// Provides a snapshot of timing values. - protected override void Update(GameTime gameTime) - { - if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || - Keyboard.GetState().IsKeyDown(Keys.Escape)) - Exit(); - - base.Update(gameTime); - } - - /// - /// This is called when the game should draw itself. - /// - /// Provides a snapshot of timing values. - protected override void Draw(GameTime gameTime) - { - GraphicsDevice.Clear(Color.CornflowerBlue); - - _spriteBatch.Begin(); - - // Draw alphabet for all common languages. - _spriteBatch.DrawString(_font, "Eng: A a B b C c D d E e F f G g H h I i J j K k L l M m N n O o P p Q q R r S s T t U u V v W w X x Y y Z z", - new Vector2(0, 0), Color.White); - _spriteBatch.DrawString(_font, "Rus: А а, Б б, В в, Г г, Д д, Е е, Ё ё, Ж ж, З з, И и, Й й, К к, Л л, М м, Н н, О о, П п, Р р, С с, Т т, У у, Ф ф, Х х, Ц ц, Ч ч, Ш ш, Щ щ, Ъ ъ, Ы ы, Ь ь, Э э, Ю ю, Я я, І і, Ѳ ѳ, Ѣ ѣ, Ѵ ѵ", - new Vector2(0, 30), Color.Maroon); - _spriteBatch.DrawString(_font, "Scandinavian: Å å, Ø ø, Æ æ, œ, þ Fra: â ç è é ê î ô û ë ï ù á í ì ó ò ú, Romana: ă â î ș ț", - new Vector2(0, 60), Color.Green); - _spriteBatch.DrawString(_font, "Fra: â ç è é ê î ô û ë ï ù á í ì ó ò ú // Romana: ă â î ș ț", - new Vector2(0, 90), Color.Navy); - _spriteBatch.DrawString(_font, "Pol: Ą Ć Ę Ł Ń Ó Ś Ź Ż ą ć ę ł ń ó ś ź ż Zażółć gęślą jaźń, Prtgs: ã, õ, â, ê, ô, á, é, í, ó, ú, à, ç", - new Vector2(0, 120), Color.Yellow); - _spriteBatch.DrawString(_font, "Cze: ž š ů ě ř Příliš žluťoučký kůň úpěl ďábelské kódy, Lat/Lit: ā, č, ē, ģ, ī, ķ, ļ, ņ, ō, ŗ, š, ū, ž, ą, č, ę, ė, į, š, ų, ū, ž", - new Vector2(0, 150), Color.Black); - _spriteBatch.DrawString(_font, "Greek: Α α, Β β, Γ γ, Δ δ, Ε ε, Ζ ζ, Η η, Θ θ, Ι ι, Κ κ, Λ λ, Μ μ, Ν ν, Ξ ξ, Ο ο, Π π, Ρ ρ, Σ σ/ς, Τ τ, Υ υ, Φ φ, Χ χ, Ψ ψ, Ω ω ά έ ή ί ό ύ ώ", - new Vector2(0, 180), Color.Aqua); - _spriteBatch.DrawString(_font, "Jap: いろはにほ 。へどひらがなカタカナ, Kor: 한국어조선말, Cn: 国会这来对开关门时个书长万边东车爱儿。吾艾、贼德艾尺", - new Vector2(0, 210), Color.Cyan); - _spriteBatch.DrawString(_font, "Other symbols: Ñ ñ ¿ ¡ Ç ç á ê Ä ä à â Ö ö ô Ü ü ë ß ẞ Ÿ ÿ Œ Æ æ ï Ğ ğ Ş ş Ő ő Ű ű ù", - new Vector2(0, 240), Color.Moccasin); - -// _spriteBatch.Draw(_font.Texture, new Vector2(0, 300)); - - _spriteBatch.End(); - - base.Draw(gameTime); - } - } -} \ No newline at end of file diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/GlyphInfo.cs b/samples/StbTrueTypeSharp.MonoGame.Test/GlyphInfo.cs deleted file mode 100644 index 3ea4137..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/GlyphInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace StbSharp.MonoGame.Test -{ - public struct GlyphInfo - { - public int X, Y, Width, Height; - public int XOffset, YOffset; - public int XAdvance; - } -} \ No newline at end of file diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Icon.ico b/samples/StbTrueTypeSharp.MonoGame.Test/Icon.ico deleted file mode 100644 index 7d9dec1..0000000 Binary files a/samples/StbTrueTypeSharp.MonoGame.Test/Icon.ico and /dev/null differ diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/Program.cs b/samples/StbTrueTypeSharp.MonoGame.Test/Program.cs deleted file mode 100644 index ec4defd..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/Program.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace StbSharp.MonoGame.Test -{ - /// - /// The main class. - /// - public static class Program - { - /// - /// The main entry point for the application. - /// - [STAThread] - static void Main() - { - using (var game = new Game1()) - game.Run(); - } - } -} \ No newline at end of file diff --git a/samples/StbTrueTypeSharp.MonoGame.Test/StbTrueTypeSharp.MonoGame.Test.csproj b/samples/StbTrueTypeSharp.MonoGame.Test/StbTrueTypeSharp.MonoGame.Test.csproj deleted file mode 100644 index 0a384ad..0000000 --- a/samples/StbTrueTypeSharp.MonoGame.Test/StbTrueTypeSharp.MonoGame.Test.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - StbTrueTypeSharpTeam - StbTrueTypeSharp - net45 - C# port of stb_truetype.h - https://github.com/rds1983/StbSharp#license - https://github.com/rds1983/StbSharp - NU1701 - StbTrueTypeSharp.MonoGame.Test - StbTrueTypeSharp - 1.0.0.0 - true - - WinExe - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - diff --git a/src/Bmp.cs b/src/Bmp.cs index 858d2e9..9568b61 100644 --- a/src/Bmp.cs +++ b/src/Bmp.cs @@ -3,6 +3,19 @@ namespace StbTrueTypeSharp { + /// + /// Y-only grid-fit remap (SilkyNvg plans/crisp_text_yonly_gridfit_hinting.md): + /// a MONOTONIC mapping over y-UP pixel space (baseline = 0, ascenders positive, + /// i.e. fontUnitsY * scale_y BEFORE bitmap inversion). Applied to every + /// FLATTENED outline point right before edges are built, so a piecewise-linear + /// map is applied exactly — curves are already flattened to line segments and + /// there is no control-point-straddle distortion. x is never touched, so + /// x-subpixel phase shifts compose freely. The map MUST be monotonic + /// (non-decreasing): the bitmap-box variant relies on remapping only the two + /// box corners. + /// + public delegate float stbtt_YPixelGridFitRemap(float yUpPixel); + public class Bitmap { private interface IHolder @@ -345,9 +358,24 @@ public void stbtt__rasterize_sorted_edges(FakePtr e, int n, int vsu } public void stbtt__rasterize(stbtt__point[] pts, int[] wcount, int windings, - float scale_x, float scale_y, float shift_x, float shift_y, int off_x, int off_y, int invert) + float scale_x, float scale_y, float shift_x, float shift_y, int off_x, int off_y, int invert, + stbtt_YPixelGridFitRemap y_remap = null) { var y_scale_inv = invert != 0 ? -scale_y : scale_y; + // Y-only grid-fit: remap operates in y-UP pixel space (p.y * scale_y), + // BEFORE the invert sign and shift_y are applied. Edge endpoints below + // consume p.y * y_scale_inv + shift_y; we pre-bake the remapped y back + // into the point in FONT-UNIT space so the existing edge math is + // untouched: p.y' = remap(p.y * scale_y) / scale_y. + if (y_remap != null && scale_y != 0) + { + var inv_scale_y = 1.0f / scale_y; + for (var pi = 0; pi < pts.Length; ++pi) + { + pts[pi].y = y_remap(pts[pi].y * scale_y) * inv_scale_y; + } + } + var n = 0; var i = 0; var j = 0; @@ -395,7 +423,8 @@ public void stbtt__rasterize(stbtt__point[] pts, int[] wcount, int windings, } public void stbtt_Rasterize(float flatness_in_pixels, stbtt_vertex[] vertices, - int num_verts, float scale_x, float scale_y, float shift_x, float shift_y, int x_off, int y_off, int invert) + int num_verts, float scale_x, float scale_y, float shift_x, float shift_y, int x_off, int y_off, int invert, + stbtt_YPixelGridFitRemap y_remap = null) { var scale = scale_x > scale_y ? scale_y : scale_x; var winding_count = 0; @@ -404,7 +433,7 @@ public void stbtt_Rasterize(float flatness_in_pixels, stbtt_vertex[] vertices, out winding_count); if (windings != null) stbtt__rasterize(windings, winding_lengths, winding_count, scale_x, scale_y, shift_x, shift_y, - x_off, y_off, invert); + x_off, y_off, invert, y_remap); } } } \ No newline at end of file diff --git a/src/Buf.cs b/src/Buf.cs index f930ea9..db9c63b 100644 --- a/src/Buf.cs +++ b/src/Buf.cs @@ -1,4 +1,4 @@ -namespace StbTrueTypeSharp +namespace StbTrueTypeSharp { public class Buf { @@ -13,6 +13,13 @@ public Buf(FakePtr p, ulong size) cursor = 0; } + public Buf Clone() + { + var clone = new Buf(data, (ulong)size); + clone.cursor = cursor; + return clone; + } + public byte stbtt__buf_get8() { if (cursor >= size) @@ -140,11 +147,22 @@ public void stbtt__dict_get_ints(int key, int outcount, FakePtr _out_) public void stbtt__dict_get_ints(int key, out uint _out_) { - var temp = new FakePtr(new uint[1]); - - stbtt__dict_get_ints(key, 1, temp); + var operands = stbtt__dict_get(key); + if (operands.cursor < operands.size) + _out_ = operands.stbtt__cff_int(); + else + _out_ = 0; // key not found — caller should pre-initialize defaults + } - _out_ = temp[0]; + /// + /// Like stbtt__dict_get_ints but preserves the existing value if key is not found. + /// Matches C behavior where the variable is pre-initialized and only overwritten if found. + /// + public void stbtt__dict_get_ints_default(int key, ref uint _out_) + { + var operands = stbtt__dict_get(key); + if (operands.cursor < operands.size) + _out_ = operands.stbtt__cff_int(); } public int stbtt__cff_index_count() @@ -170,8 +188,6 @@ public Buf stbtt__cff_index_get(int i) public static Buf stbtt__get_subrs(Buf cff, Buf fontdict) { - var subrsoff = (uint)0; - var private_loc = new uint[2]; private_loc[0] = 0; private_loc[1] = 0; @@ -180,7 +196,9 @@ public static Buf stbtt__get_subrs(Buf cff, Buf fontdict) if (private_loc[1] == 0 || private_loc[0] == 0) return new Buf(FakePtr.Null, 0); var pdict = cff.stbtt__buf_range((int)private_loc[1], (int)private_loc[0]); - pdict.stbtt__dict_get_ints(19, 1, new FakePtr(subrsoff)); + var subrsoff_arr = new uint[1]; + pdict.stbtt__dict_get_ints(19, 1, new FakePtr(subrsoff_arr)); + var subrsoff = subrsoff_arr[0]; if (subrsoff == 0) return new Buf(FakePtr.Null, 0); cff.stbtt__buf_seek((int)(private_loc[1] + subrsoff)); diff --git a/src/Common.cs b/src/Common.cs index ec37471..68dad6b 100644 --- a/src/Common.cs +++ b/src/Common.cs @@ -383,7 +383,9 @@ public static int stbtt__GetGlyphClass(FakePtr classDefTable, int glyph) break; } - return -1; + // OpenType spec: any glyph not listed in the ClassDef is class 0 + // (was: -1, which silently dropped class-0 kern pairs, e.g. Roboto quotes) + return 0; } public static stbtt__active_edge stbtt__new_active(stbtt__edge e, int off_x, float start_point) diff --git a/src/FontInfo.cs b/src/FontInfo.cs index 813c0b9..c90a27a 100644 --- a/src/FontInfo.cs +++ b/src/FontInfo.cs @@ -1,9 +1,9 @@ -using System; +using System; using static StbTrueTypeSharp.Common; namespace StbTrueTypeSharp { - public class FontInfo + public partial class FontInfo { public Buf cff = null; public Buf charstrings = null; @@ -86,7 +86,7 @@ public int stbtt_InitFont_internal(byte[] data, int fontstart) this.fontdicts = new Buf(FakePtr.Null, 0); this.fdselect = new Buf(FakePtr.Null, 0); this.cff = new Buf(new FakePtr(ptr, (int)cff), 512 * 1024 * 1024); - b = this.cff; + b = this.cff.Clone(); b.stbtt__buf_skip(2); b.stbtt__buf_seek(b.stbtt__buf_get8()); b.stbtt__cff_get_index(); @@ -95,10 +95,10 @@ public int stbtt_InitFont_internal(byte[] data, int fontstart) b.stbtt__cff_get_index(); this.gsubrs = b.stbtt__cff_get_index(); topdict.stbtt__dict_get_ints(17, out charstrings); - topdict.stbtt__dict_get_ints(0x100 | 6, out cstype); - topdict.stbtt__dict_get_ints(0x100 | 36, out fdarrayoff); - topdict.stbtt__dict_get_ints(0x100 | 37, out fdselectoff); - this.subrs = Buf.stbtt__get_subrs(b, topdict); + topdict.stbtt__dict_get_ints_default(0x100 | 6, ref cstype); + topdict.stbtt__dict_get_ints_default(0x100 | 36, ref fdarrayoff); + topdict.stbtt__dict_get_ints_default(0x100 | 37, ref fdselectoff); + this.subrs = Buf.stbtt__get_subrs(b.Clone(), topdict); if (cstype != 2) return 0; @@ -909,9 +909,10 @@ public int stbtt__run_charstring(int glyph_index, CharStringContext c) c.stbtt__csctx_rccurve_to(dx4, dy4, dx5, dy5, dx6, dy6); break; default: - return 0; + // Unknown/deprecated escape operators (e.g. dotsection 12 0) — treat as no-op + break; } - } + } break; default: if (b0 != 255 && b0 != 28 && (b0 < 32 || b0 > 254)) @@ -977,6 +978,70 @@ public int stbtt_GetGlyphShape(int glyph_index, out stbtt_vertex[] pvertices) return stbtt__GetGlyphShapeT2(glyph_index, out pvertices); } + /// + /// Detects exactly-horizontal outline LINE segments of at least + /// min_length_font_units (SilkyNvg plans/crisp_text_yonly_gridfit_hinting.md + /// Phase 2 — stem-edge detection for y-only grid-fit). Returns edge count; + /// edge_ys are y positions in FONT UNITS (y-up), edge_is_top_of_ink is true + /// when the ink lies BELOW the edge (the edge is the upper boundary of a + /// bar). Ink side comes from segment direction: TrueType fills clockwise + /// (y-up), so a +x segment has ink below it; CFF/Type2 winds the opposite + /// way and is flipped accordingly. Curves are ignored — flat bar edges + /// (crossbars of H/e/t/f, arms of E) are straight lines in practice. + /// + public int stbtt_GetGlyphHorizontalLineEdges(int glyph_index, int min_length_font_units, + out float[] edge_ys, out bool[] edge_is_top_of_ink) + { + edge_ys = null; + edge_is_top_of_ink = null; + stbtt_vertex[] vertices; + var num_verts = stbtt_GetGlyphShape(glyph_index, out vertices); + if (num_verts <= 0) + return 0; + + var is_cff = this.cff.size != 0; + var count = 0; + float[] ys = null; + bool[] tops = null; + float prev_x = 0; + float prev_y = 0; + for (var i = 0; i < num_verts; ++i) + { + var v = vertices[i]; + if (v.type == STBTT_vline && v.y == prev_y && Math.Abs(v.x - prev_x) >= min_length_font_units) + { + // TrueType (CW, y-up): travelling +x means interior is below + // the segment -> upper boundary of ink. CFF (CCW): flipped. + var top_of_ink = v.x > prev_x; + if (is_cff) + top_of_ink = !top_of_ink; + if (ys == null) + { + ys = new float[8]; + tops = new bool[8]; + } + else if (count == ys.Length) + { + Array.Resize(ref ys, count * 2); + Array.Resize(ref tops, count * 2); + } + ys[count] = v.y; + tops[count] = top_of_ink; + ++count; + } + prev_x = v.x; + prev_y = v.y; + } + + if (count == 0) + return 0; + Array.Resize(ref ys, count); + Array.Resize(ref tops, count); + edge_ys = ys; + edge_is_top_of_ink = tops; + return count; + } + public void stbtt_GetGlyphHMetrics(int glyph_index, ref int advanceWidth, ref int leftSideBearing) { @@ -1064,129 +1129,219 @@ public int stbtt__GetGlyphKernInfoAdvance(int glyph1, int glyph2) public int stbtt__GetGlyphGPOSInfoAdvance(int glyph1, int glyph2) { - ushort lookupListOffset = 0; - FakePtr lookupList; - ushort lookupCount = 0; - FakePtr data; - var i = 0; - if (this.gpos == 0) + var kernLookups = stbtt__ResolveGposKernLookups(); + if (kernLookups == null) return 0; - data = this.data + this.gpos; + var gposData = this.data + this.gpos; + var lookupList = gposData + ttUSHORT(gposData + 8); + var totalAdvance = 0; + for (var i = 0; i < kernLookups.Length; ++i) + { + if (!kernLookups[i]) + continue; + var lookupTable = lookupList + ttUSHORT(lookupList + 2 + 2 * i); + totalAdvance += stbtt__GPOSLookupPairAdvance(lookupTable, glyph1, glyph2); + } + + return totalAdvance; + } + + // Cached result of stbtt__ResolveGposKernLookups (resolved once per font). + private bool[] gposKernLookups; + private bool gposKernResolved; + + // Resolves latn/DFLT default-LangSys 'kern' feature lookups ONCE per font. + // Returns null when GPOS has no usable kern feature for latn/DFLT — callers + // (stbtt_GetGlyphKernAdvance) then fall back to the legacy 'kern' table. + private bool[] stbtt__ResolveGposKernLookups() + { + if (this.gposKernResolved) + return this.gposKernLookups; + this.gposKernResolved = true; + if (this.gpos == 0) + return null; + var data = this.data + this.gpos; if (ttUSHORT(data + 0) != 1) - return 0; + return null; if (ttUSHORT(data + 2) != 0) + return null; + var scriptList = data + ttUSHORT(data + 4); + var featureList = data + ttUSHORT(data + 6); + var lookupList = data + ttUSHORT(data + 8); + var lookupCount = (int)ttUSHORT(lookupList); + + // Script selection: prefer 'latn', fall back to 'DFLT'; use the default LangSys. + var scriptTable = FakePtr.Null; + var dfltScriptTable = FakePtr.Null; + var scriptCount = (int)ttUSHORT(scriptList); + for (var si = 0; si < scriptCount; ++si) + { + var rec = scriptList + 2 + 6 * si; + if (rec[0] == 'l' && rec[1] == 'a' && rec[2] == 't' && rec[3] == 'n') + scriptTable = scriptList + ttUSHORT(rec + 4); + else if (rec[0] == 'D' && rec[1] == 'F' && rec[2] == 'L' && rec[3] == 'T') + dfltScriptTable = scriptList + ttUSHORT(rec + 4); + } + + if (scriptTable.IsNull) + scriptTable = dfltScriptTable; + if (scriptTable.IsNull) + return null; + var defaultLangSysOffset = ttUSHORT(scriptTable); + if (defaultLangSysOffset == 0) + return null; + var langSys = scriptTable + defaultLangSysOffset; + + // Feature selection: mark the lookups of every 'kern' feature in the LangSys, + // then apply marked lookups in LookupList INDEX order (OpenType application + // order), accumulating adjustments ACROSS lookups. + var kernLookups = new bool[lookupCount]; + var anyKern = false; + var featureIndexCount = (int)ttUSHORT(langSys + 4); + for (var fi = 0; fi < featureIndexCount; ++fi) + { + var featureIndex = ttUSHORT(langSys + 6 + 2 * fi); + var featureRec = featureList + 2 + 6 * featureIndex; + if (featureRec[0] != 'k' || featureRec[1] != 'e' || featureRec[2] != 'r' || featureRec[3] != 'n') + continue; + var featureTable = featureList + ttUSHORT(featureRec + 4); + var featureLookupCount = (int)ttUSHORT(featureTable + 2); + for (var li = 0; li < featureLookupCount; ++li) + { + int lookupIndex = ttUSHORT(featureTable + 4 + 2 * li); + if (lookupIndex < lookupCount) + { + kernLookups[lookupIndex] = true; + anyKern = true; + } + } + } + + if (!anyKern) + return null; + + this.gposKernLookups = kernLookups; + return kernLookups; + } + + // Applies one GPOS lookup (type 2 PairAdjustment, incl. type 9 Extension wrapping) + // to the glyph pair. Within a lookup, the FIRST subtable that applies wins. + // LookupFlags are deliberately ignored: for base-glyph pair kerning the only flag + // seen in target fonts is IgnoreMarks (0x0008), a no-op when neither glyph is a mark. + private int stbtt__GPOSLookupPairAdvance(FakePtr lookupTable, int glyph1, int glyph2) + { + var lookupType = (int)ttUSHORT(lookupTable); + var subTableCount = (int)ttUSHORT(lookupTable + 4); + var subTableOffsets = lookupTable + 6; + if (lookupType != 2 && lookupType != 9) return 0; - lookupListOffset = ttUSHORT(data + 8); - lookupList = data + lookupListOffset; - lookupCount = ttUSHORT(lookupList); - for (i = 0; i < lookupCount; ++i) + + for (var sti = 0; sti < subTableCount; sti++) { - var lookupOffset = ttUSHORT(lookupList + 2 + 2 * i); - var lookupTable = lookupList + lookupOffset; - var lookupType = ttUSHORT(lookupTable); - var subTableCount = ttUSHORT(lookupTable + 4); - var subTableOffsets = lookupTable + 6; - switch (lookupType) + var table = lookupTable + ttUSHORT(subTableOffsets + 2 * sti); + if (lookupType == 9) { - case 2: + // Extension positioning: format(u16), extensionLookupType(u16), extensionOffset(u32) + if (ttUSHORT(table) != 1) + continue; + if (ttUSHORT(table + 2) != 2) + continue; // inner lookup is not PairAdjustment + table = table + (int)ttULONG(table + 4); + } + + int xAdvance; + if (stbtt__GPOSPairSubtableApply(table, glyph1, glyph2, out xAdvance)) + return xAdvance; // first applying subtable ends the lookup + } + + return 0; + } + + // One PairPos subtable (format 1 or 2). Returns true if the subtable APPLIED + // (per HarfBuzz semantics: fmt1 = pair record found; fmt2 = coverage + valid classes, + // even when the adjustment value is 0). xAdvance is in unscaled font units. + public static bool stbtt__GPOSPairSubtableApply(FakePtr table, int glyph1, int glyph2, out int xAdvance) + { + xAdvance = 0; + var posFormat = (int)ttUSHORT(table); + var coverageIndex = stbtt__GetCoverageIndex(table + ttUSHORT(table + 2), glyph1); + if (coverageIndex == -1) + return false; + + var valueFormat1 = (int)ttUSHORT(table + 4); + var valueFormat2 = (int)ttUSHORT(table + 6); + // ValueRecord layout: record size = 2 x popcount(valueFormat) bytes (device-table + // bits 0x0010-0x0080 each contribute a 2-byte offset field to the SIZE but are + // ignored for the value); xAdvance sits after the fields below bit 2 + // (XPlacement, YPlacement), i.e. at byte offset 2 x popcount(valueFormat & 0x0003). + var valueRecordSize1 = 2 * stbtt__popcount(valueFormat1); + var valueRecordSize2 = 2 * stbtt__popcount(valueFormat2); + var hasXAdvance = (valueFormat1 & 4) != 0; + var xAdvanceOffset = 2 * stbtt__popcount(valueFormat1 & 3); + + switch (posFormat) + { + case 1: + { + var pairValueTable = table + ttUSHORT(table + 10 + 2 * coverageIndex); + var pairValueCount = (int)ttUSHORT(pairValueTable); + var pairValueArray = pairValueTable + 2; + var stride = 2 + valueRecordSize1 + valueRecordSize2; + var l = 0; + var r = pairValueCount - 1; + while (l <= r) { - var sti = 0; - for (sti = 0; sti < subTableCount; sti++) + var m = (l + r) >> 1; + var pairValue = pairValueArray + stride * m; + var straw = (int)ttUSHORT(pairValue); + if (glyph2 < straw) + r = m - 1; + else if (glyph2 > straw) + l = m + 1; + else { - var subtableOffset = ttUSHORT(subTableOffsets + 2 * sti); - var table = lookupTable + subtableOffset; - var posFormat = ttUSHORT(table); - var coverageOffset = ttUSHORT(table + 2); - var coverageIndex = stbtt__GetCoverageIndex(table + coverageOffset, glyph1); - if (coverageIndex == -1) - continue; - switch (posFormat) - { - case 1: - { - var l = 0; - var r = 0; - var m = 0; - var straw = 0; - var needle = 0; - var valueFormat1 = ttUSHORT(table + 4); - var valueFormat2 = ttUSHORT(table + 6); - var valueRecordPairSizeInBytes = 2; - var pairSetCount = ttUSHORT(table + 8); - var pairPosOffset = ttUSHORT(table + 10 + 2 * coverageIndex); - var pairValueTable = table + pairPosOffset; - var pairValueCount = ttUSHORT(pairValueTable); - var pairValueArray = pairValueTable + 2; - if (valueFormat1 != 4) - return 0; - if (valueFormat2 != 0) - return 0; - needle = glyph2; - r = pairValueCount - 1; - l = 0; - while (l <= r) - { - ushort secondGlyph = 0; - FakePtr pairValue; - m = (l + r) >> 1; - pairValue = pairValueArray + (2 + valueRecordPairSizeInBytes) * m; - secondGlyph = ttUSHORT(pairValue); - straw = secondGlyph; - if (needle < straw) - { - r = m - 1; - } - else if (needle > straw) - { - l = m + 1; - } - else - { - var xAdvance = ttSHORT(pairValue + 2); - return xAdvance; - } - } - } - break; - case 2: - { - var valueFormat1 = ttUSHORT(table + 4); - var valueFormat2 = ttUSHORT(table + 6); - var classDef1Offset = ttUSHORT(table + 8); - var classDef2Offset = ttUSHORT(table + 10); - var glyph1class = stbtt__GetGlyphClass(table + classDef1Offset, glyph1); - var glyph2class = stbtt__GetGlyphClass(table + classDef2Offset, glyph2); - var class1Count = ttUSHORT(table + 12); - var class2Count = ttUSHORT(table + 14); - if (valueFormat1 != 4) - return 0; - if (valueFormat2 != 0) - return 0; - if (glyph1class >= 0 && glyph1class < class1Count && glyph2class >= 0 && - glyph2class < class2Count) - { - var class1Records = table + 16; - var class2Records = class1Records + 2 * glyph1class * class2Count; - var xAdvance = ttSHORT(class2Records + 2 * glyph2class); - return xAdvance; - } - } - break; - } + if (hasXAdvance) + xAdvance = ttSHORT(pairValue + 2 + xAdvanceOffset); + return true; } - - break; } + + return false; // coverage hit but no pair record: NOT applied, try next subtable } + case 2: + { + var glyph1class = stbtt__GetGlyphClass(table + ttUSHORT(table + 8), glyph1); + var glyph2class = stbtt__GetGlyphClass(table + ttUSHORT(table + 10), glyph2); + var class1Count = (int)ttUSHORT(table + 12); + var class2Count = (int)ttUSHORT(table + 14); + if (glyph1class < 0 || glyph1class >= class1Count || glyph2class < 0 || glyph2class >= class2Count) + return false; // malformed classes: treat as not applied + var recordSize = valueRecordSize1 + valueRecordSize2; + var record = table + 16 + (glyph1class * class2Count + glyph2class) * recordSize; + if (hasXAdvance) + xAdvance = ttSHORT(record + xAdvanceOffset); + return true; // fmt2 applies whenever classes resolve (even value 0) + } + default: + return false; } + } - return 0; + private static int stbtt__popcount(int v) + { + v = v - ((v >> 1) & 0x55555555); + v = (v & 0x33333333) + ((v >> 2) & 0x33333333); + return (((v + (v >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24; } public int stbtt_GetGlyphKernAdvance(int g1, int g2) { + // GPOS pair kerning when the font exposes a latn/DFLT 'kern' feature; + // otherwise fall back to the legacy 'kern' table (also covers fonts whose + // GPOS exists but carries no kern feature, e.g. mark/mkmk-only GPOS). + // Font-level decision, never mixed per-pair. var xAdvance = 0; - if (this.gpos != 0) + if (this.gpos != 0 && stbtt__ResolveGposKernLookups() != null) xAdvance += stbtt__GetGlyphGPOSInfoAdvance(g1, g2); else if (this.kern != 0) xAdvance += stbtt__GetGlyphKernInfoAdvance(g1, g2); @@ -1306,6 +1461,43 @@ public void stbtt_GetGlyphBitmapBoxSubpixel(int glyph, float scale_x, float scal } } + /// + /// stbtt_GetGlyphBitmapBoxSubpixel with a y-only grid-fit remap + /// (SilkyNvg plans/crisp_text_yonly_gridfit_hinting.md). The remap operates + /// in y-UP pixel space (baseline 0, ascenders positive) and MUST be + /// monotonic non-decreasing — only the two box corners are remapped here, + /// which bounds all remapped outline y values iff the map is monotonic. + /// x is untouched (identical to the unmapped box). + /// + public void stbtt_GetGlyphBitmapBoxSubpixelYRemap(int glyph, float scale_x, float scale_y, + float shift_x, float shift_y, stbtt_YPixelGridFitRemap y_remap, + ref int ix0, ref int iy0, ref int ix1, ref int iy1) + { + if (y_remap == null) + { + stbtt_GetGlyphBitmapBoxSubpixel(glyph, scale_x, scale_y, shift_x, shift_y, ref ix0, ref iy0, ref ix1, ref iy1); + return; + } + var x0 = 0; + var y0 = 0; + var x1 = 0; + var y1 = 0; + if (stbtt_GetGlyphBox(glyph, ref x0, ref y0, ref x1, ref y1) == 0) + { + ix0 = 0; + iy0 = 0; + ix1 = 0; + iy1 = 0; + } + else + { + ix0 = (int)Math.Floor(x0 * scale_x + shift_x); + iy0 = (int)Math.Floor(-y_remap(y1 * scale_y) + shift_y); + ix1 = (int)Math.Ceiling(x1 * scale_x + shift_x); + iy1 = (int)Math.Ceiling(-y_remap(y0 * scale_y) + shift_y); + } + } + public void stbtt_GetGlyphBitmapBox(int glyph, float scale_x, float scale_y, ref int ix0, ref int iy0, ref int ix1, ref int iy1) { @@ -1391,6 +1583,36 @@ public void stbtt_MakeGlyphBitmapSubpixel(FakePtr output, int out_w, gbm.stbtt_Rasterize(0.35f, vertices, num_verts, scale_x, scale_y, shift_x, shift_y, ix0, iy0, 1); } + /// + /// stbtt_MakeGlyphBitmapSubpixel with a y-only grid-fit remap (SilkyNvg + /// plans/crisp_text_yonly_gridfit_hinting.md). The remap is applied to the + /// flattened outline in y-UP pixel space (see stbtt_YPixelGridFitRemap) and + /// to the bitmap-box corners, so the output origin matches + /// stbtt_GetGlyphBitmapBoxSubpixelYRemap. x (including shift_x subpixel + /// phase) is untouched. + /// + public void stbtt_MakeGlyphBitmapSubpixelYRemap(FakePtr output, int out_w, + int out_h, int out_stride, float scale_x, float scale_y, float shift_x, float shift_y, int glyph, + stbtt_YPixelGridFitRemap y_remap) + { + var ix0 = 0; + var iy0 = 0; + var ix1 = 0; + var iy1 = 0; + stbtt_vertex[] vertices; + var num_verts = stbtt_GetGlyphShape(glyph, out vertices); + var gbm = new Bitmap(); + stbtt_GetGlyphBitmapBoxSubpixelYRemap(glyph, scale_x, scale_y, shift_x, shift_y, y_remap, + ref ix0, ref iy0, ref ix1, ref iy1); + gbm.pixels = output; + gbm.w = out_w; + gbm.h = out_h; + gbm.stride = out_stride; + + if (gbm.w != 0 && gbm.h != 0) + gbm.stbtt_Rasterize(0.35f, vertices, num_verts, scale_x, scale_y, shift_x, shift_y, ix0, iy0, 1, y_remap); + } + public void stbtt_MakeGlyphBitmap(FakePtr output, int out_w, int out_h, int out_stride, float scale_x, float scale_y, int glyph) { @@ -1698,5 +1920,33 @@ public int stbtt_InitFont(byte[] data, int offset) { return stbtt_InitFont_internal(data, offset); } + + public int stbtt_GetUnitsPerEm() + { + return (int)ttUSHORT(this.data + this.head + 18); + } + + /// + /// Returns the sCapHeight field from the OS/2 table (int16, font units). + /// sCapHeight was added in OS/2 version 2. Returns 0 if the OS/2 table + /// is missing or is version 0/1 (which don't contain sCapHeight). + /// + public int stbtt_GetCapHeight(out bool available) + { + var tab = (int)stbtt__find_table(this.data, (uint)this.fontstart, "OS/2"); + if (tab == 0) + { + available = false; + return 0; + } + int version = ttUSHORT(this.data + tab); // OS/2 table version at offset 0 + if (version < 2) + { + available = false; + return 0; + } + available = true; + return ttSHORT(this.data + tab + 88); + } } } \ No newline at end of file diff --git a/src/FontInfoSynth.cs b/src/FontInfoSynth.cs new file mode 100644 index 0000000..1d939be --- /dev/null +++ b/src/FontInfoSynth.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using static StbTrueTypeSharp.Common; + +namespace StbTrueTypeSharp +{ + /// + /// Synthetic style outline preprocessing (SilkyNvg + /// plans/crisp_text_synthetic_styles.md §A.2/§A.3): fake-bold stroke-union + /// emboldening, fake-italic shear and Tz horizontal scaling, applied ONCE to + /// the stbtt_vertex[] outline in FONT UNITS, upstream of rasterization. + /// Both SilkyNvg raster tiers consume this same output, so they are + /// pixel-consistent by construction (the Design invariant). + /// + public partial class FontInfo + { + /// + /// stbtt_GetGlyphShape followed by the synthetic-style transform: + /// 1. embolden: append stroke-union rings at half-width + /// (winding-normalized — see + /// SynthesizeOutline), NON-ZERO winding does the union at raster time; + /// 2. shear/scale: x' = (x + y·skewX) · scaleX (font units, y-up). + /// Identity style returns the raw shape untouched. + /// + public int stbtt_GetGlyphShapeSynth(int glyph_index, float skewX, float scaleX, + float emboldenFontUnits, out stbtt_vertex[] pvertices) + { + int num_verts = stbtt_GetGlyphShape(glyph_index, out pvertices); + if (num_verts <= 0) + return num_verts; + return SynthOutline.SynthesizeOutline(ref pvertices, num_verts, skewX, scaleX, emboldenFontUnits); + } + + /// + /// Bitmap box for a synth-styled glyph — the synth analogue of + /// stbtt_GetGlyphBitmapBoxSubpixelYRemap (pass y_remap null for the plain + /// variant). The box is computed from the TRANSFORMED outline's control + /// polygon (conservative for quadratics, exact for lines) — mask and box + /// must agree, so RenderGlyphBitmap-side callers pass identical arguments. + /// + public void stbtt_GetGlyphBitmapBoxSubpixelSynth(int glyph, float scale_x, float scale_y, + float shift_x, float shift_y, float skewX, float scaleX, float emboldenFontUnits, + stbtt_YPixelGridFitRemap y_remap, ref int ix0, ref int iy0, ref int ix1, ref int iy1) + { + int num_verts = stbtt_GetGlyphShapeSynth(glyph, skewX, scaleX, emboldenFontUnits, out stbtt_vertex[] verts); + if (num_verts <= 0 || !SynthOutline.ComputeOutlineBounds(verts, num_verts, out float bx0, out float by0, out float bx1, out float by1)) + { + ix0 = 0; iy0 = 0; ix1 = 0; iy1 = 0; + return; + } + if (y_remap == null) + { + ix0 = (int)Math.Floor(bx0 * scale_x + shift_x); + iy0 = (int)Math.Floor(-by1 * scale_y + shift_y); + ix1 = (int)Math.Ceiling(bx1 * scale_x + shift_x); + iy1 = (int)Math.Ceiling(-by0 * scale_y + shift_y); + } + else + { + // Same y-UP-pixel-space remap contract as stbtt_GetGlyphBitmapBoxSubpixelYRemap. + ix0 = (int)Math.Floor(bx0 * scale_x + shift_x); + iy0 = (int)Math.Floor(-y_remap(by1 * scale_y) + shift_y); + ix1 = (int)Math.Ceiling(bx1 * scale_x + shift_x); + iy1 = (int)Math.Ceiling(-y_remap(by0 * scale_y) + shift_y); + } + } + + /// + /// Rasterizes a synth-styled glyph — the synth analogue of + /// stbtt_MakeGlyphBitmapSubpixelYRemap (y_remap null = plain). The box is + /// recomputed here with EXACTLY the box function above so mask placement + /// and the caller's box agree. + /// + public void stbtt_MakeGlyphBitmapSubpixelSynth(FakePtr output, int out_w, + int out_h, int out_stride, float scale_x, float scale_y, float shift_x, float shift_y, + float skewX, float scaleX, float emboldenFontUnits, int glyph, + stbtt_YPixelGridFitRemap y_remap = null) + { + int num_verts = stbtt_GetGlyphShapeSynth(glyph, skewX, scaleX, emboldenFontUnits, out stbtt_vertex[] verts); + if (num_verts <= 0) + return; + var ix0 = 0; var iy0 = 0; var ix1 = 0; var iy1 = 0; + stbtt_GetGlyphBitmapBoxSubpixelSynth(glyph, scale_x, scale_y, shift_x, shift_y, + skewX, scaleX, emboldenFontUnits, y_remap, ref ix0, ref iy0, ref ix1, ref iy1); + var gbm = new Bitmap(); + gbm.pixels = output; + gbm.w = out_w; + gbm.h = out_h; + gbm.stride = out_stride; + if (gbm.w != 0 && gbm.h != 0) + gbm.stbtt_Rasterize(0.35f, verts, num_verts, scale_x, scale_y, shift_x, shift_y, ix0, iy0, 1, y_remap); + } + } + + /// + /// The synthetic-style outline transform (font units, y-up). Separated from + /// FontInfo so SilkyNvg's sweeper preprocessor can reuse it on raw vertex + /// lists (shared code = pixel-consistent tiers). + /// + public static class SynthOutline + { + /// + /// Flattening tolerance for embolden ring construction, in font units. + /// 1/256 em at 2048 upem = 8 units; rings are re-consumed by the standard + /// rasterizer flattening anyway, so this only bounds ring-shape error. + /// + private const float RingFlattenToleranceFontUnits = 2f; + + /// Miter limit for ring joins, as a multiple of the embolden + /// half-width. Beyond this the join falls back to a bevel (Skia default 4). + private const float MiterLimit = 4f; + + /// + /// LEGACY A/B SWITCH (default false = single-loop mode). The original A2 + /// construction emitted a ±d loop PAIR per contour; the −d loop of a flesh + /// contour is region-theoretically redundant (it only cancels one layer over + /// the eroded interior) and is exactly the loop that SELF-CROSSES wherever + /// the local stroke width t < 2d (script tails, thin connectors) — its + /// inverted lobes broke the cancellation and left 0-winding (uncovered) + /// patches INSIDE thin features (David's diagnosis, 2026-07-28: the + /// lightened Miama 'e' tail, identical on both tiers). Single-loop mode + /// offsets EVERY contour by −fillSign·d (away from ink), preserving source + /// orientation: flesh grows, holes shrink, one uniform formula; inward + /// loops never exist, and outward self-crossings at concave corners are + /// double-POSITIVE lobes — winding-safe under both nonzero and |signed|. + /// + public static bool EmboldenLoopPairLegacyMode = false; + + /// + /// Applies the full synth transform IN PLACE (array may be reallocated): + /// 1. embolden > 0: flatten each contour, build ±d offset rings with + /// miter/bevel joins, append as line contours. WINDING NORMALIZATION + /// (spec §A.2 REQUIRED): the glyph's dominant fill sign w = + /// sign(Σ signed areas); each annulus is emitted with its LARGER ring + /// wound w and its SMALLER ring wound −w, so the stroke band adds ink + /// of the flesh sign everywhere and the annulus interior nets zero — + /// inner rims never erase (the erase bug), holes shrink correctly, + /// collapsed counters saturate solid (never cancel). + /// 2. shear/scale: x' = (x + y·skewX)·scaleX on ALL coordinates + /// including control points (linear map — quadratics stay quadratics). + /// Returns the new vertex count. + /// + public static int SynthesizeOutline(ref stbtt_vertex[] vertices, int numVerts, + float skewX, float scaleX, float emboldenFontUnits) + { + if (numVerts <= 0 || vertices == null) + return numVerts; + + if (emboldenFontUnits > 0f) + numVerts = AppendEmboldenRings(ref vertices, numVerts, emboldenFontUnits); + + if (skewX != 0f || scaleX != 1f) + { + for (int i = 0; i < numVerts; i++) + { + ref stbtt_vertex v = ref vertices[i]; + v.x = ShearScaleX(v.x, v.y, skewX, scaleX); + if (v.type == STBTT_vcurve || v.type == STBTT_vcubic) + v.cx = ShearScaleX(v.cx, v.cy, skewX, scaleX); + if (v.type == STBTT_vcubic) + v.cx1 = ShearScaleX(v.cx1, v.cy1, skewX, scaleX); + } + } + + return numVerts; + } + + private static short ShearScaleX(short x, short y, float skewX, float scaleX) + { + float transformed = (x + y * skewX) * scaleX; + return (short)ClampToShort(transformed); + } + + // netstandard2.0 compatibility helpers (no MathF / Math.Clamp there). + private static float Sqrtf(float v) => (float)Math.Sqrt(v); + + private static int ClampToShort(float v) + { + var rounded = (int)Math.Round(v); + if (rounded < short.MinValue) return short.MinValue; + if (rounded > short.MaxValue) return short.MaxValue; + return rounded; + } + + /// Min/max over all coordinates incl. control points (control + /// polygon bounds — conservative for curves, exact for lines). + public static bool ComputeOutlineBounds(stbtt_vertex[] vertices, int numVerts, + out float x0, out float y0, out float x1, out float y1) + { + x0 = float.MaxValue; y0 = float.MaxValue; + x1 = float.MinValue; y1 = float.MinValue; + bool any = false; + for (int i = 0; i < numVerts; i++) + { + stbtt_vertex v = vertices[i]; + Accumulate(v.x, v.y, ref x0, ref y0, ref x1, ref y1, ref any); + if (v.type == STBTT_vcurve || v.type == STBTT_vcubic) + Accumulate(v.cx, v.cy, ref x0, ref y0, ref x1, ref y1, ref any); + if (v.type == STBTT_vcubic) + Accumulate(v.cx1, v.cy1, ref x0, ref y0, ref x1, ref y1, ref any); + } + return any; + } + + private static void Accumulate(float px, float py, ref float x0, ref float y0, + ref float x1, ref float y1, ref bool any) + { + if (px < x0) x0 = px; + if (py < y0) y0 = py; + if (px > x1) x1 = px; + if (py > y1) y1 = py; + any = true; + } + + // ── embolden ring construction ── + + private static int AppendEmboldenRings(ref stbtt_vertex[] vertices, int numVerts, float halfWidth) + { + List> contours = FlattenToContours(vertices, numVerts); + if (contours.Count == 0) + return numVerts; + + // Dominant fill sign w over ALL contours (non-zero winding glyphs: + // outer contours dominate holes by area). y-up: CCW area > 0. + float totalArea = 0f; + foreach (List<(float x, float y)> contour in contours) + totalArea += SignedArea(contour); + float fillSign = totalArea >= 0f ? 1f : -1f; + + var ringVerts = new List(); + foreach (List<(float x, float y)> contour in contours) + { + if (contour.Count < 3) + continue; + if (!EmboldenLoopPairLegacyMode) + { + // SINGLE-LOOP MODE (default; see EmboldenLoopPairLegacyMode doc): + // offset AWAY FROM INK by d, preserving source orientation. + // Uniform for flesh AND holes: away-from-ink = −fillSign·d + // (left normal is the interior side for CCW traversal; the + // algebra collapses both cases to one formula). + List<(float x, float y)> awayFromInk = OffsetContour(contour, -fillSign * halfWidth); + if (awayFromInk.Count < 3) + continue; + // Preserve traversal orientation EXACTLY (OffsetContour is + // order-preserving): a collapse-inverted hole loop keeps its + // inverted sense, which is what makes collapsed counters + // SATURATE solid rather than re-punch a tiny hole. + float emittedSign = SignedArea(awayFromInk) >= 0f ? 1f : -1f; + EmitRing(ringVerts, awayFromInk, emittedSign); + } + else + { + List<(float x, float y)> offsetOut = OffsetContour(contour, +halfWidth); + List<(float x, float y)> offsetIn = OffsetContour(contour, -halfWidth); + if (offsetOut.Count < 3 || offsetIn.Count < 3) + continue; + // Larger-|area| ring carries the flesh sign, smaller carries the + // opposite — the annulus adds ±w band ink, nets 0 inside. + float areaOut = Math.Abs(SignedArea(offsetOut)); + float areaIn = Math.Abs(SignedArea(offsetIn)); + List<(float x, float y)> larger = areaOut >= areaIn ? offsetOut : offsetIn; + List<(float x, float y)> smaller = areaOut >= areaIn ? offsetIn : offsetOut; + EmitRing(ringVerts, larger, fillSign); + EmitRing(ringVerts, smaller, -fillSign); + } + } + + if (ringVerts.Count == 0) + return numVerts; + + var merged = new stbtt_vertex[numVerts + ringVerts.Count]; + Array.Copy(vertices, merged, numVerts); + for (int i = 0; i < ringVerts.Count; i++) + merged[numVerts + i] = ringVerts[i]; + vertices = merged; + return merged.Length; + } + + /// Emits a polyline ring as vmove + vline vertices with the + /// REQUESTED winding sign (reverses the point order when needed). + private static void EmitRing(List output, List<(float x, float y)> ring, float requiredSign) + { + float currentSign = SignedArea(ring) >= 0f ? 1f : -1f; + int count = ring.Count; + bool reverse = currentSign != requiredSign; + + var v = new stbtt_vertex(); + for (int i = 0; i < count; i++) + { + (float x, float y) p = ring[reverse ? count - 1 - i : i]; + v.type = (byte)(i == 0 ? STBTT_vmove : STBTT_vline); + v.x = (short)ClampToShort(p.x); + v.y = (short)ClampToShort(p.y); + v.cx = 0; v.cy = 0; v.cx1 = 0; v.cy1 = 0; + output.Add(v); + } + // Close the ring explicitly (contour-end vline back to the start — the + // rasterizer closes on the next vmove, but an explicit edge is exact). + (float x, float y) first = ring[reverse ? count - 1 : 0]; + v.type = (byte)STBTT_vline; + v.x = (short)ClampToShort(first.x); + v.y = (short)ClampToShort(first.y); + output.Add(v); + } + + private static float SignedArea(List<(float x, float y)> ring) + { + float area2 = 0f; + for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++) + area2 += (ring[j].x * ring[i].y) - (ring[i].x * ring[j].y); + return area2 * 0.5f; + } + + /// Flattens the outline into closed polyline contours (font + /// units) at ring tolerance. Quadratic + cubic subdivision by flatness of + /// the control polygon (same criterion family as stbtt__tesselate_curve). + private static List> FlattenToContours(stbtt_vertex[] vertices, int numVerts) + { + var contours = new List>(); + List<(float x, float y)> current = null; + float px = 0f, py = 0f; + + for (int i = 0; i < numVerts; i++) + { + stbtt_vertex v = vertices[i]; + switch (v.type) + { + case STBTT_vmove: + current = new List<(float x, float y)> { (v.x, v.y) }; + contours.Add(current); + px = v.x; py = v.y; + break; + case STBTT_vline: + current?.Add((v.x, v.y)); + px = v.x; py = v.y; + break; + case STBTT_vcurve: + if (current != null) + FlattenQuad(current, px, py, v.cx, v.cy, v.x, v.y, 0); + px = v.x; py = v.y; + break; + case STBTT_vcubic: + if (current != null) + FlattenCubic(current, px, py, v.cx, v.cy, v.cx1, v.cy1, v.x, v.y, 0); + px = v.x; py = v.y; + break; + } + } + + // Drop closing duplicates + degenerate contours. + for (int c = contours.Count - 1; c >= 0; c--) + { + List<(float x, float y)> contour = contours[c]; + while (contour.Count > 1 && NearlyEqual(contour[0], contour[contour.Count - 1])) + contour.RemoveAt(contour.Count - 1); + if (contour.Count < 3) + contours.RemoveAt(c); + } + return contours; + } + + private static bool NearlyEqual((float x, float y) a, (float x, float y) b) + => Math.Abs(a.x - b.x) < 0.5f && Math.Abs(a.y - b.y) < 0.5f; + + private static void FlattenQuad(List<(float x, float y)> output, float x0, float y0, + float cx, float cy, float x1, float y1, int depth) + { + float mx = (x0 + 2f * cx + x1) * 0.25f; + float my = (y0 + 2f * cy + y1) * 0.25f; + float dx = (x0 + x1) * 0.5f - mx; + float dy = (y0 + y1) * 0.5f - my; + if (depth < 16 && dx * dx + dy * dy > RingFlattenToleranceFontUnits * RingFlattenToleranceFontUnits) + { + FlattenQuad(output, x0, y0, (x0 + cx) * 0.5f, (y0 + cy) * 0.5f, mx, my, depth + 1); + FlattenQuad(output, mx, my, (x1 + cx) * 0.5f, (y1 + cy) * 0.5f, x1, y1, depth + 1); + } + else + { + output.Add((x1, y1)); + } + } + + private static void FlattenCubic(List<(float x, float y)> output, float x0, float y0, + float cx0, float cy0, float cx1, float cy1, float x1, float y1, int depth) + { + // Control-polygon flatness (same criterion as stbtt__tesselate_cubic). + float dx0 = cx0 - x0, dy0 = cy0 - y0; + float dx1 = cx1 - cx0, dy1 = cy1 - cy0; + float dx2 = x1 - cx1, dy2 = y1 - cy1; + float dx = x1 - x0, dy = y1 - y0; + float longlen = Sqrtf(dx0 * dx0 + dy0 * dy0) + Sqrtf(dx1 * dx1 + dy1 * dy1) + Sqrtf(dx2 * dx2 + dy2 * dy2); + float shortlen = Sqrtf(dx * dx + dy * dy); + float flatness_squared = longlen * longlen - shortlen * shortlen; + if (depth < 16 && flatness_squared > RingFlattenToleranceFontUnits * RingFlattenToleranceFontUnits) + { + float mx0 = (x0 + cx0) * 0.5f, my0 = (y0 + cy0) * 0.5f; + float mc = (cx0 + cx1) * 0.5f, myc = (cy0 + cy1) * 0.5f; + float mx1 = (cx1 + x1) * 0.5f, my1 = (cy1 + y1) * 0.5f; + float ax = (mx0 + mc) * 0.5f, ay = (my0 + myc) * 0.5f; + float bx = (mc + mx1) * 0.5f, by = (myc + my1) * 0.5f; + float qx = (ax + bx) * 0.5f, qy = (ay + by) * 0.5f; + FlattenCubic(output, x0, y0, mx0, my0, ax, ay, qx, qy, depth + 1); + FlattenCubic(output, qx, qy, bx, by, mx1, my1, x1, y1, depth + 1); + } + else + { + output.Add((x1, y1)); + } + } + + /// + /// Offsets a closed polyline by along each + /// edge's LEFT normal relative to traversal (sign of distance flips side). + /// Miter joins clamped at MiterLimit×|distance| → bevel fallback. + /// Self-intersections are permitted — non-zero winding absorbs them at + /// raster time (the whole point of the stroke-union technique). + /// + private static List<(float x, float y)> OffsetContour(List<(float x, float y)> contour, float distance) + { + int n = contour.Count; + var result = new List<(float x, float y)>(n + 8); + float d = Math.Abs(distance); + float side = Math.Sign(distance); + + for (int i = 0; i < n; i++) + { + (float x, float y) prev = contour[(i - 1 + n) % n]; + (float x, float y) cur = contour[i]; + (float x, float y) next = contour[(i + 1) % n]; + + float e0x = cur.x - prev.x, e0y = cur.y - prev.y; + float e1x = next.x - cur.x, e1y = next.y - cur.y; + float l0 = Sqrtf(e0x * e0x + e0y * e0y); + float l1 = Sqrtf(e1x * e1x + e1y * e1y); + if (l0 < 1e-6f && l1 < 1e-6f) + continue; + if (l0 < 1e-6f) { e0x = e1x; e0y = e1y; l0 = l1; } + if (l1 < 1e-6f) { e1x = e0x; e1y = e0y; l1 = l0; } + + // Left normals (y-up): edge (ex,ey) → n = (-ey, ex)/len. + float n0x = -e0y / l0 * side, n0y = e0x / l0 * side; + float n1x = -e1y / l1 * side, n1y = e1x / l1 * side; + + // Miter direction = normalized normal sum. + float mxd = n0x + n1x, myd = n0y + n1y; + float mlen = Sqrtf(mxd * mxd + myd * myd); + if (mlen < 1e-6f) + { + // 180° reversal: bevel with both edge normals. + result.Add((cur.x + n0x * d, cur.y + n0y * d)); + result.Add((cur.x + n1x * d, cur.y + n1y * d)); + continue; + } + mxd /= mlen; myd /= mlen; + // Miter length: d / cos(θ/2) where cos(θ/2) = m·n0. + float cosHalf = mxd * n0x + myd * n0y; + if (cosHalf < 1e-3f || 1f / cosHalf > MiterLimit) + { + // Bevel: two offset points, one per edge normal. + result.Add((cur.x + n0x * d, cur.y + n0y * d)); + result.Add((cur.x + n1x * d, cur.y + n1y * d)); + } + else + { + float miterLen = d / cosHalf; + result.Add((cur.x + mxd * miterLen, cur.y + myd * miterLen)); + } + } + return result; + } + } +} \ No newline at end of file diff --git a/src/SafeStbTrueTypeSharp.csproj b/src/SafeStbTrueTypeSharp.csproj index 9d4533c..5e3f72d 100644 --- a/src/SafeStbTrueTypeSharp.csproj +++ b/src/SafeStbTrueTypeSharp.csproj @@ -1,15 +1,13 @@ - + SafeStbTrueTypeSharpTeam SafeStbTrueTypeSharp - netstandard1.1 + netstandard2.0;net9.0 Safe C# port of stb_truetype.h https://wiki.creativecommons.org/wiki/public_domain - https://github.com/StbSharp - NU1701 - SafeStbTrueTypeSharp - SafeStbTrueTypeSharp - 1.0.0.0 - true + https://github.com/jeske/SafeStbTrueTypeSharp + StbTrueTypeSharp + 1.24.1 + latest - + \ No newline at end of file diff --git a/tests/GposKerning.Tests/FONTS.md b/tests/GposKerning.Tests/FONTS.md new file mode 100644 index 0000000..cab6186 --- /dev/null +++ b/tests/GposKerning.Tests/FONTS.md @@ -0,0 +1,8 @@ +# Test Fonts (licensed — deliberately separate from ../TestOtfLoading/UNLICENSED-FONTS.md) + +- **Roboto-Regular.ttf** — Apache License 2.0 (Google/Christian Robertson). + Redistributable; committed here as the primary GPOS kerning oracle font. + +- **Arial / Times New Roman / Consolas** — NOT committed. The oracle tests load + them from `C:\Windows\Fonts\` when present and skip when absent (non-Windows + or stripped installs). \ No newline at end of file diff --git a/tests/GposKerning.Tests/GposKerning.Tests.csproj b/tests/GposKerning.Tests/GposKerning.Tests.csproj new file mode 100644 index 0000000..da2ecd2 --- /dev/null +++ b/tests/GposKerning.Tests/GposKerning.Tests.csproj @@ -0,0 +1,20 @@ + + + net9.0 + enable + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/GposKerning.Tests/KernOracleTests.cs b/tests/GposKerning.Tests/KernOracleTests.cs new file mode 100644 index 0000000..c890118 --- /dev/null +++ b/tests/GposKerning.Tests/KernOracleTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using HarfBuzzSharp; +using StbTrueTypeSharp; +using Xunit; +using Xunit.Abstractions; +using static StbTrueTypeSharp.Common; +using HbBuffer = HarfBuzzSharp.Buffer; +using HbFont = HarfBuzzSharp.Font; + +namespace GposKerning.Tests; + +// Numeric HarfBuzz kerning oracle (plan: plans/crisp_text_gpos_kerning.md Phase 0). +// +// Contract under test: stbtt_GetGlyphKernAdvance(g1, g2) == +// hb xAdvance(g1) - unkerned hmtx advance(g1) +// Both sides in UNSCALED FONT UNITS (hb_font scale pinned to unitsPerEm) +// => exact integer equality, no tolerance. +// +// Feature set is pinned EXPLICITLY: +kern only, everything else OFF. +// HarfBuzz enables calt/clig/curs/dist by default; calt/clig are GSUB and can +// substitute glyphs BEFORE positioning — a phantom mismatch no GPOS fix closes. +public class KernOracleTests +{ + private readonly ITestOutputHelper _output; + + public KernOracleTests(ITestOutputHelper output) => _output = output; + + // Codepoint set for pair enumeration: Latin letters, digits, common punctuation. + private const string ProbeChars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + + ".,'\"!?;:-()[]/"; + + private static readonly Feature[] PinnedFeatures = + { + new Feature(new Tag('k', 'e', 'r', 'n'), 1), + new Feature(new Tag('l', 'i', 'g', 'a'), 0), + new Feature(new Tag('c', 'l', 'i', 'g'), 0), + new Feature(new Tag('c', 'a', 'l', 't'), 0), + new Feature(new Tag('c', 'u', 'r', 's'), 0), + new Feature(new Tag('d', 'i', 's', 't'), 0), + }; + + [Theory] + [InlineData("Roboto-Regular.ttf", false)] + [InlineData("arial.ttf", true)] + [InlineData("times.ttf", true)] + [InlineData("consola.ttf", true)] + public void StbKernMatchesHarfBuzz(string fontFile, bool systemFont) + { + string path = systemFont + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), fontFile) + : Path.Combine(AppContext.BaseDirectory, fontFile); + if (systemFont && !File.Exists(path)) + { + _output.WriteLine($"SKIP: system font not present: {path}"); + return; // guarded skip (non-Windows / stripped install) + } + + byte[] data = File.ReadAllBytes(path); + + // --- stb side --- + var stb = new FontInfo(); + Assert.True(stb.stbtt_InitFont(data, stbtt_GetFontOffsetForIndex(data, 0)) != 0, + $"stb failed to init {fontFile}"); + + // --- HarfBuzz side, scale pinned to unitsPerEm (font units in == font units out) --- + using var blob = Blob.FromFile(path); + using var face = new Face(blob, 0); + int upem = (int)face.UnitsPerEm; + using var hbFont = new HbFont(face); + hbFont.SetScale(upem, upem); + + var mismatches = new List(); + int pairsTested = 0, pairsKerned = 0, substituted = 0; + + foreach (char c1 in ProbeChars) + foreach (char c2 in ProbeChars) + { + int g1 = stb.stbtt_FindGlyphIndex(c1); + int g2 = stb.stbtt_FindGlyphIndex(c2); + if (g1 == 0 || g2 == 0) + continue; + + int advanceWidth = 0, lsb = 0; + stb.stbtt_GetGlyphHMetrics(g1, ref advanceWidth, ref lsb); + + using var buffer = new HbBuffer(); + buffer.Direction = Direction.LeftToRight; + buffer.Script = Script.Latin; + buffer.Language = new Language("en"); + buffer.AddUtf16(new string(new[] { c1, c2 })); // sets ContentType=Unicode + hbFont.Shape(buffer, PinnedFeatures); + + var infos = buffer.GlyphInfos; + var positions = buffer.GlyphPositions; + + // With the pinned feature set no GSUB substitution should occur. + // If it does, that's real signal (feature-set contamination), not a GPOS bug. + if (infos.Length != 2 || infos[0].Codepoint != (uint)g1 || infos[1].Codepoint != (uint)g2) + { + substituted++; + mismatches.Add($"SUBSTITUTION '{c1}{c2}': hb produced " + + $"[{string.Join(",", FormatGlyphs(infos))}] expected [{g1},{g2}]"); + continue; + } + + int hbKern = positions[0].XAdvance - advanceWidth; + int stbKern = stb.stbtt_GetGlyphKernAdvance(g1, g2); + + pairsTested++; + if (hbKern != 0) + pairsKerned++; + if (stbKern != hbKern) + mismatches.Add($"'{c1}{c2}' (g{g1},g{g2}): stb={stbKern} hb={hbKern}"); + } + + _output.WriteLine($"{fontFile}: upem={upem} pairsTested={pairsTested} " + + $"hbKernedPairs={pairsKerned} substitutions={substituted} mismatches={mismatches.Count}"); + foreach (var m in Take(mismatches, 50)) + _output.WriteLine(" " + m); + if (mismatches.Count > 50) + _output.WriteLine($" ... and {mismatches.Count - 50} more"); + + Assert.True(mismatches.Count == 0, + $"{fontFile}: {mismatches.Count} kern mismatches vs HarfBuzz oracle " + + $"({pairsKerned} hb-kerned pairs of {pairsTested} tested). " + + "See test output for the diagnostic baseline."); + } + + private static IEnumerable FormatGlyphs(GlyphInfo[] infos) + { + foreach (var i in infos) + yield return i.Codepoint.ToString(); + } + + private static IEnumerable Take(List list, int n) + { + for (int i = 0; i < list.Count && i < n; i++) + yield return list[i]; + } +} \ No newline at end of file diff --git a/tests/GposKerning.Tests/Roboto-Regular.ttf b/tests/GposKerning.Tests/Roboto-Regular.ttf new file mode 100644 index 0000000..3e6e2e7 Binary files /dev/null and b/tests/GposKerning.Tests/Roboto-Regular.ttf differ diff --git a/tests/GposKerning.Tests/ValueRecordTests.cs b/tests/GposKerning.Tests/ValueRecordTests.cs new file mode 100644 index 0000000..2498920 --- /dev/null +++ b/tests/GposKerning.Tests/ValueRecordTests.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using StbTrueTypeSharp; +using Xunit; + +namespace GposKerning.Tests; + +// Synthetic PairPos subtable blobs exercising ValueRecord layouts that the four +// target fonts never use (they are all vf1=0x0004/vf2=0). Validates the exact +// formulas from the plan: +// record size = 2 x popcount(valueFormat) (device-table bits count too) +// xAdvance offset = 2 x popcount(valueFormat & 0x0003) +// Tests stbtt__GPOSPairSubtableApply directly — no font file needed. +public class ValueRecordTests +{ + // ---- blob builders (big-endian) ---- + private static void U16(List b, int v) { b.Add((byte)(v >> 8)); b.Add((byte)v); } + + private static int PopCount(int v) => System.Numerics.BitOperations.PopCount((uint)v); + + // Fills a ValueRecord: every 2-byte field gets `filler`, except the XAdvance + // field (if present) which gets `xAdvance`. Device-table bits get offset 0. + private static void ValueRecord(List b, int valueFormat, int xAdvance, int filler) + { + for (int bit = 0; bit < 8; bit++) + { + if ((valueFormat & (1 << bit)) == 0) continue; + if (bit == 2) U16(b, xAdvance & 0xFFFF); + else if (bit >= 4) U16(b, 0); // device-table offset: 0 = none + else U16(b, filler & 0xFFFF); // XPlacement/YPlacement/YAdvance junk + } + } + + // Coverage format 1 listing the given glyphs (must be sorted). + private static List Coverage(params int[] glyphs) + { + var b = new List(); + U16(b, 1); U16(b, glyphs.Length); + foreach (var g in glyphs) U16(b, g); + return b; + } + + // PairPos format 1 with ONE pairSet for glyph1 containing (glyph2 -> xAdvance). + private static byte[] PairPosFmt1(int vf1, int vf2, int g1, int g2, int xAdv, int filler) + { + var b = new List(); + U16(b, 1); // posFormat + int covOffPos = b.Count; U16(b, 0); // coverageOffset (patched below) + U16(b, vf1); U16(b, vf2); + U16(b, 1); // pairSetCount + int ps0Pos = b.Count; U16(b, 0); // pairSetOffset[0] (patched below) + // pairSet + int pairSetStart = b.Count; + b[ps0Pos] = (byte)(pairSetStart >> 8); b[ps0Pos + 1] = (byte)pairSetStart; + U16(b, 1); // pairValueCount + U16(b, g2); // secondGlyph + ValueRecord(b, vf1, xAdv, filler); + ValueRecord(b, vf2, 0, filler); + // coverage + int covStart = b.Count; + b[covOffPos] = (byte)(covStart >> 8); b[covOffPos + 1] = (byte)covStart; + b.AddRange(Coverage(g1)); + return b.ToArray(); + } + + // PairPos format 2 with class1Count=class2Count=2; value only in cell (1,1). + // ClassDef format 1: g1 -> class 1, g2 -> class 1 (others default class 0). + private static byte[] PairPosFmt2(int vf1, int vf2, int g1, int g2, int xAdv, int filler) + { + var b = new List(); + U16(b, 2); // posFormat + int covOffPos = b.Count; U16(b, 0); + U16(b, vf1); U16(b, vf2); + int cd1Pos = b.Count; U16(b, 0); + int cd2Pos = b.Count; U16(b, 0); + U16(b, 2); U16(b, 2); // class1Count, class2Count + // class1Records: 2x2 cells + for (int c1 = 0; c1 < 2; c1++) + for (int c2 = 0; c2 < 2; c2++) + { + ValueRecord(b, vf1, (c1 == 1 && c2 == 1) ? xAdv : 0, filler); + ValueRecord(b, vf2, 0, filler); + } + int cd1 = b.Count; + b[cd1Pos] = (byte)(cd1 >> 8); b[cd1Pos + 1] = (byte)cd1; + U16(b, 1); U16(b, g1); U16(b, 1); U16(b, 1); // ClassDef fmt1: [g1] -> class 1 + int cd2 = b.Count; + b[cd2Pos] = (byte)(cd2 >> 8); b[cd2Pos + 1] = (byte)cd2; + U16(b, 1); U16(b, g2); U16(b, 1); U16(b, 1); // ClassDef fmt1: [g2] -> class 1 + int cov = b.Count; + b[covOffPos] = (byte)(cov >> 8); b[covOffPos + 1] = (byte)cov; + b.AddRange(Coverage(g1)); + return b.ToArray(); + } + + private static int Apply(byte[] blob, int g1, int g2, out bool applied) + { + applied = FontInfo.stbtt__GPOSPairSubtableApply(new FakePtr(blob), g1, g2, out var xAdv); + return xAdv; + } + + // valueFormats to exercise: plain, placement+advance, all-scalars, device bits, vf2 nonzero + public static TheoryData Formats => new() + { + { 0x0004, 0x0000 }, // XAdvance only (what real fonts use) + { 0x0005, 0x0000 }, // XPlacement | XAdvance + { 0x0007, 0x0000 }, // XPlacement | YPlacement | XAdvance + { 0x000F, 0x0000 }, // all four scalars + { 0x0054, 0x0000 }, // XAdvance + XPlaDevice + XAdvDevice (device bits size-only) + { 0x00F7, 0x0000 }, // everything except YAdvance + { 0x0004, 0x0004 }, // second-glyph record present (must not shift stride wrongly) + { 0x0005, 0x00F7 }, // fat second record + }; + + [Theory] + [MemberData(nameof(Formats))] + public void Fmt1_XAdvanceReadAtCorrectOffset(int vf1, int vf2) + { + var blob = PairPosFmt1(vf1, vf2, g1: 30, g2: 40, xAdv: -123, filler: 0x2222); + var xAdv = Apply(blob, 30, 40, out var applied); + Assert.True(applied); + Assert.Equal(-123, xAdv); + // pair not in pairSet -> NOT applied (fall-through contract) + Apply(blob, 30, 41, out var missApplied); + Assert.False(missApplied); + // glyph1 not covered -> not applied + Apply(blob, 31, 40, out var uncovered); + Assert.False(uncovered); + } + + [Theory] + [MemberData(nameof(Formats))] + public void Fmt2_XAdvanceReadAtCorrectOffset(int vf1, int vf2) + { + var blob = PairPosFmt2(vf1, vf2, g1: 30, g2: 40, xAdv: -77, filler: 0x3333); + var xAdv = Apply(blob, 30, 40, out var applied); + Assert.True(applied); + Assert.Equal(-77, xAdv); + // class-0 second glyph: cell (1,0) = 0, but subtable still APPLIES (fmt2 semantics) + var xAdv2 = Apply(blob, 30, 99, out var appliedZero); + Assert.True(appliedZero); + Assert.Equal(0, xAdv2); + } + + [Fact] + public void Fmt1_NoXAdvanceBit_AppliesWithZero() + { + // vf1 = XPlacement only: subtable applies (pair record exists) but xAdvance = 0 + var blob = PairPosFmt1(0x0001, 0x0000, g1: 30, g2: 40, xAdv: 0, filler: 0x1111); + var xAdv = Apply(blob, 30, 40, out var applied); + Assert.True(applied); + Assert.Equal(0, xAdv); + } +} \ No newline at end of file diff --git a/tests/TestOtfLoading/Lato-Regular.otf b/tests/TestOtfLoading/Lato-Regular.otf new file mode 100644 index 0000000..2fbddd0 Binary files /dev/null and b/tests/TestOtfLoading/Lato-Regular.otf differ diff --git a/tests/TestOtfLoading/Program.cs b/tests/TestOtfLoading/Program.cs new file mode 100644 index 0000000..1534da5 --- /dev/null +++ b/tests/TestOtfLoading/Program.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using StbTrueTypeSharp; +using static StbTrueTypeSharp.Common; + +string testDir = AppContext.BaseDirectory; +string fontPath = Path.Combine(testDir, "debug_frutiger_roman.otf"); + +byte[] data = File.ReadAllBytes(fontPath); +var fontInfo = new FontInfo(); +int offset = stbtt_GetFontOffsetForIndex(data, 0); +fontInfo.stbtt_InitFont(data, offset); + +Console.WriteLine($"Font: numGlyphs={fontInfo.numGlyphs}, subrs.size={fontInfo.subrs?.size ?? 0}, gsubrs.size={fontInfo.gsubrs?.size ?? 0}"); + +// Find 'i' and dump its charstring bytes +int gi = fontInfo.stbtt_FindGlyphIndex('i'); +Console.WriteLine($"'i' gid={gi}"); + +if (gi > 0) +{ + var cs = fontInfo.charstrings.stbtt__cff_index_get(gi); + Console.WriteLine($" charstring size={cs.size}"); + Console.Write(" bytes: "); + for (int i = 0; i < cs.size && i < 64; i++) + Console.Write($"{cs.data[i]:X2} "); + Console.WriteLine(); + Console.WriteLine(); + + // Try to run it and see where it fails + stbtt_vertex[] verts; + int nv = fontInfo.stbtt_GetGlyphShape(gi, out verts); + Console.WriteLine($" GetGlyphShape => {nv} vertices"); +} \ No newline at end of file diff --git a/tests/TestOtfLoading/TestOtfLoading.csproj b/tests/TestOtfLoading/TestOtfLoading.csproj new file mode 100644 index 0000000..13c32cc --- /dev/null +++ b/tests/TestOtfLoading/TestOtfLoading.csproj @@ -0,0 +1,13 @@ + + + Exe + net9.0 + + + + + + + + + \ No newline at end of file diff --git a/tests/TestOtfLoading/UNLICENSED-FONTS.md b/tests/TestOtfLoading/UNLICENSED-FONTS.md new file mode 100644 index 0000000..9365487 --- /dev/null +++ b/tests/TestOtfLoading/UNLICENSED-FONTS.md @@ -0,0 +1,31 @@ +# UNLICENSED TEST FONTS — DO NOT USE + +## Legal Notice + +The font files in this directory are **unlicensed** and may NOT be used for any +display, rendering, embedding, or distribution purposes of any kind. + +These files were extracted from random test-case PDFs that exhibited parsing +failures. They are included here **solely** for automated parser debugging and +regression testing of the SafeStbTrueTypeSharp CFF/OTF parsing code. + +## Specifically: + +- **Lato-Regular.otf** — Used as a known-good reference OTF/CFF font for parser + validation. The Lato font family is licensed under the SIL Open Font License, + but this copy is used here only for parser testing, not for any rendered output. + +- **debug_frutiger_roman.otf** — A subset of Frutiger-Roman extracted from a PDF + test document. This is a commercial typeface. This file exists only to reproduce + a specific CFF subroutine parsing bug and must not be used for any other purpose. + +- **debug_generated_cff.otf** — A generated OTF wrapper around raw CFF data + extracted from a PDF. Used only for parser testing. + +## Rules + +1. Do NOT render, display, or present glyphs from these fonts to any user. +2. Do NOT distribute these fonts outside this test directory. +3. Do NOT use these fonts in any product, sample, demo, or documentation. +4. These fonts are consumed ONLY by automated tests that verify byte-level parsing. +5. If a font is no longer needed for debugging, DELETE it. \ No newline at end of file diff --git a/tests/TestOtfLoading/debug_frutiger_roman.otf b/tests/TestOtfLoading/debug_frutiger_roman.otf new file mode 100644 index 0000000..f384554 Binary files /dev/null and b/tests/TestOtfLoading/debug_frutiger_roman.otf differ